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/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""agentpause — predictive scheduling for autonomous LLM agents.
|
|
2
|
+
|
|
3
|
+
Suspend an agent gracefully *before* it hits a provider rate limit, and resume
|
|
4
|
+
it without redoing work. The core works on any provider (cloud or local); true
|
|
5
|
+
KV-cache warm start is an optional plugin for self-hosted runtimes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .errors import (
|
|
9
|
+
AgentPauseError,
|
|
10
|
+
BackendError,
|
|
11
|
+
CheckpointError,
|
|
12
|
+
RateLimitHit,
|
|
13
|
+
TelemetryError,
|
|
14
|
+
)
|
|
15
|
+
from .breaker import CircuitBreaker, CircuitOpenError
|
|
16
|
+
from .estimator import Estimator
|
|
17
|
+
from .fallback import FallbackBackend
|
|
18
|
+
from .retry import RetryPolicy
|
|
19
|
+
from .risk import Budget, Decision, RiskModel, decide, should_checkpoint
|
|
20
|
+
from .state import Checkpoint, StateStore
|
|
21
|
+
from .scheduler import PredictiveScheduler, Session
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"PredictiveScheduler",
|
|
27
|
+
"Session",
|
|
28
|
+
"Estimator",
|
|
29
|
+
"RiskModel",
|
|
30
|
+
"Budget",
|
|
31
|
+
"Decision",
|
|
32
|
+
"decide",
|
|
33
|
+
"should_checkpoint",
|
|
34
|
+
"Checkpoint",
|
|
35
|
+
"StateStore",
|
|
36
|
+
"RetryPolicy",
|
|
37
|
+
"FallbackBackend",
|
|
38
|
+
"CircuitBreaker",
|
|
39
|
+
"CircuitOpenError",
|
|
40
|
+
"AgentPauseError",
|
|
41
|
+
"RateLimitHit",
|
|
42
|
+
"TelemetryError",
|
|
43
|
+
"CheckpointError",
|
|
44
|
+
"BackendError",
|
|
45
|
+
"__version__",
|
|
46
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Provider adapters: turn real LLM providers into the two callables
|
|
2
|
+
(backend, telemetry) that :class:`agentpause.PredictiveScheduler` expects.
|
|
3
|
+
|
|
4
|
+
Adapters are optional — the core has zero dependencies. Import them
|
|
5
|
+
explicitly, e.g.::
|
|
6
|
+
|
|
7
|
+
from agentpause.adapters.litellm import LiteLLMAdapter
|
|
8
|
+
"""
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""LangGraph adapter: predictive suspension inside any graph.
|
|
2
|
+
|
|
3
|
+
`LangGraph <https://github.com/langchain-ai/langgraph>`_ persists state with a
|
|
4
|
+
checkpointer and can pause a run with ``interrupt()`` — but it decides
|
|
5
|
+
*reactively*: it saves after each node and crashes (or retries) when the
|
|
6
|
+
provider returns 429. This adapter adds the missing *predictive* gate.
|
|
7
|
+
|
|
8
|
+
Usage — drop a guard into the node that calls the LLM::
|
|
9
|
+
|
|
10
|
+
from agentpause.adapters.langgraph import AgentPauseGuard
|
|
11
|
+
|
|
12
|
+
guard = AgentPauseGuard(telemetry=adapter.telemetry) # e.g. from LiteLLMAdapter
|
|
13
|
+
|
|
14
|
+
def agent_node(state):
|
|
15
|
+
guard.check(state["messages"]) # ← may pause the graph HERE
|
|
16
|
+
reply = llm.invoke(state["messages"])
|
|
17
|
+
guard.record(state["messages"], reply.usage_metadata["total_tokens"])
|
|
18
|
+
return {"messages": state["messages"] + [reply]}
|
|
19
|
+
|
|
20
|
+
When the estimated cost of the next call does not fit the remaining budget,
|
|
21
|
+
``check()`` calls LangGraph's ``interrupt()``: the run pauses cleanly and the
|
|
22
|
+
configured checkpointer persists it. Resume it later with
|
|
23
|
+
``graph.invoke(Command(resume=True), config)``.
|
|
24
|
+
|
|
25
|
+
Design notes
|
|
26
|
+
------------
|
|
27
|
+
* **Fresh telemetry on every pass.** On resume LangGraph re-executes the node
|
|
28
|
+
from the top, so ``check()`` runs again and re-reads the budget — a value
|
|
29
|
+
serialized before the pause is stale by definition. If the budget is
|
|
30
|
+
*still* too low, the guard interrupts again rather than letting the call
|
|
31
|
+
fail with a 429.
|
|
32
|
+
* ``interrupt_fn`` is injectable so the guard is fully testable offline;
|
|
33
|
+
by default it lazily imports ``langgraph.types.interrupt``.
|
|
34
|
+
* The guard owns an :class:`~agentpause.estimator.Estimator`; feed it with
|
|
35
|
+
``record()`` after each real call so ε and σ adapt to the workload.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import time
|
|
41
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
42
|
+
|
|
43
|
+
from ..estimator import Estimator
|
|
44
|
+
from ..risk import Budget, decide
|
|
45
|
+
|
|
46
|
+
__all__ = ["AgentPauseGuard"]
|
|
47
|
+
|
|
48
|
+
# text -> token count
|
|
49
|
+
TokenCounter = Callable[[str], int]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _default_token_counter(text: str) -> int:
|
|
53
|
+
"""Rough offline token estimate (~4 chars/token). Replace via ``count_tokens``."""
|
|
54
|
+
return max(1, len(text) // 4)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _default_interrupt() -> Callable[[Any], Any]:
|
|
58
|
+
try:
|
|
59
|
+
from langgraph.types import interrupt
|
|
60
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
61
|
+
raise ImportError(
|
|
62
|
+
"The LangGraph adapter needs the 'langgraph' package. "
|
|
63
|
+
"Install it with: pip install agentpause[langgraph]"
|
|
64
|
+
) from exc
|
|
65
|
+
return interrupt
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AgentPauseGuard:
|
|
69
|
+
"""The predictive gate for a LangGraph node.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
telemetry: callable ``() -> remaining_tokens`` reading the rate-limit
|
|
73
|
+
window (e.g. ``LiteLLMAdapter(...).telemetry``).
|
|
74
|
+
estimator: cost estimator (defaults to a fresh :class:`Estimator`).
|
|
75
|
+
count_tokens: text->token counter (defaults to ~4 chars/token).
|
|
76
|
+
safety_k: safety factor; higher suspends earlier.
|
|
77
|
+
interrupt_fn: the pause primitive. Defaults to LangGraph's
|
|
78
|
+
``interrupt`` (imported lazily); tests inject a fake.
|
|
79
|
+
wait_threshold_s: if the budget does not fit but the provider window
|
|
80
|
+
resets within this many seconds, the guard *sleeps in place*
|
|
81
|
+
instead of interrupting the graph — a short pause is cheaper than
|
|
82
|
+
a suspend/resume cycle. Requires telemetry that reports
|
|
83
|
+
``reset_seconds`` (e.g. ``LiteLLMAdapter(...).budget``).
|
|
84
|
+
sleep_fn: how to wait (defaults to ``time.sleep``; tests inject a fake).
|
|
85
|
+
max_resumes: how many resume/wait passes with a still-too-low budget
|
|
86
|
+
are tolerated per ``check()`` call — a loop bound, not a policy;
|
|
87
|
+
each pass re-reads telemetry.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
telemetry: Callable[[], "int | Budget"],
|
|
93
|
+
estimator: Optional[Estimator] = None,
|
|
94
|
+
count_tokens: Optional[TokenCounter] = None,
|
|
95
|
+
safety_k: float = 2.0,
|
|
96
|
+
interrupt_fn: Optional[Callable[[Any], Any]] = None,
|
|
97
|
+
wait_threshold_s: float = 15.0,
|
|
98
|
+
sleep_fn: Callable[[float], None] = time.sleep,
|
|
99
|
+
async_sleep_fn: Optional[Callable[[float], Any]] = None,
|
|
100
|
+
max_resumes: int = 100,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.telemetry = telemetry
|
|
103
|
+
self.estimator = estimator if estimator is not None else Estimator()
|
|
104
|
+
self.count_tokens = count_tokens if count_tokens is not None else _default_token_counter
|
|
105
|
+
self.safety_k = safety_k
|
|
106
|
+
self._interrupt = interrupt_fn
|
|
107
|
+
self.wait_threshold_s = wait_threshold_s
|
|
108
|
+
self.sleep_fn = sleep_fn
|
|
109
|
+
self.async_sleep_fn = async_sleep_fn
|
|
110
|
+
self.max_resumes = max_resumes
|
|
111
|
+
|
|
112
|
+
# -- internals -----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _input_tokens(self, messages: List[Dict[str, str]]) -> int:
|
|
115
|
+
return sum(self.count_tokens(m.get("content") or "") for m in messages)
|
|
116
|
+
|
|
117
|
+
def _interrupt_call(self, payload: Any) -> Any:
|
|
118
|
+
if self._interrupt is None:
|
|
119
|
+
self._interrupt = _default_interrupt()
|
|
120
|
+
return self._interrupt(payload)
|
|
121
|
+
|
|
122
|
+
# -- the predictive gate ---------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def check(self, messages: List[Dict[str, str]]) -> None:
|
|
125
|
+
"""Pause the graph if the next LLM call would not fit the budget.
|
|
126
|
+
|
|
127
|
+
Reads telemetry fresh on every pass (both the token/TPM and, when
|
|
128
|
+
reported, the request/RPM dimension). Three outcomes:
|
|
129
|
+
|
|
130
|
+
* ``continue`` — returns, the node proceeds to the LLM call.
|
|
131
|
+
* ``wait`` — the window resets within ``wait_threshold_s``: the guard
|
|
132
|
+
sleeps in place and re-checks, without pausing the whole graph.
|
|
133
|
+
* ``checkpoint`` — calls ``interrupt()``: the node aborts and the
|
|
134
|
+
checkpointer persists the run. On resume the node re-executes and
|
|
135
|
+
only proceeds once the (freshly read) budget fits.
|
|
136
|
+
"""
|
|
137
|
+
input_tokens = self._input_tokens(messages)
|
|
138
|
+
for _ in range(self.max_resumes):
|
|
139
|
+
raw = self.telemetry()
|
|
140
|
+
budget = raw if isinstance(raw, Budget) else Budget(remaining_tokens=int(raw))
|
|
141
|
+
estimated = self.estimator.estimate(input_tokens)
|
|
142
|
+
sigma = self.estimator.sigma(estimated)
|
|
143
|
+
d = decide(budget, estimated, sigma, self.safety_k, self.wait_threshold_s)
|
|
144
|
+
if d.action == "continue":
|
|
145
|
+
return
|
|
146
|
+
if d.action == "wait":
|
|
147
|
+
# reset is imminent: sleeping beats a suspend/resume cycle
|
|
148
|
+
self.sleep_fn((budget.reset_seconds or 1.0) + 0.5)
|
|
149
|
+
continue
|
|
150
|
+
# interrupt() raises on the first pass (the node aborts here);
|
|
151
|
+
# on a resume pass it RETURNS, and the loop re-reads telemetry.
|
|
152
|
+
self._interrupt_call({
|
|
153
|
+
"reason": "predicted_rate_limit",
|
|
154
|
+
"remaining": budget.remaining_tokens,
|
|
155
|
+
"remaining_requests": budget.remaining_requests,
|
|
156
|
+
"reset_seconds": budget.reset_seconds,
|
|
157
|
+
"estimated": estimated,
|
|
158
|
+
"sigma": round(sigma, 1),
|
|
159
|
+
"safety_k": self.safety_k,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
async def acheck(self, messages: List[Dict[str, str]]) -> None:
|
|
163
|
+
"""Async twin of :meth:`check`, for async LangGraph nodes.
|
|
164
|
+
|
|
165
|
+
Same three outcomes; the ``wait`` pause uses ``asyncio.sleep`` (or the
|
|
166
|
+
injected ``async_sleep_fn``) so the event loop is never blocked.
|
|
167
|
+
"""
|
|
168
|
+
import asyncio
|
|
169
|
+
|
|
170
|
+
input_tokens = self._input_tokens(messages)
|
|
171
|
+
for _ in range(self.max_resumes):
|
|
172
|
+
raw = self.telemetry()
|
|
173
|
+
budget = raw if isinstance(raw, Budget) else Budget(remaining_tokens=int(raw))
|
|
174
|
+
estimated = self.estimator.estimate(input_tokens)
|
|
175
|
+
sigma = self.estimator.sigma(estimated)
|
|
176
|
+
d = decide(budget, estimated, sigma, self.safety_k, self.wait_threshold_s)
|
|
177
|
+
if d.action == "continue":
|
|
178
|
+
return
|
|
179
|
+
if d.action == "wait":
|
|
180
|
+
delay = (budget.reset_seconds or 1.0) + 0.5
|
|
181
|
+
if self.async_sleep_fn is not None:
|
|
182
|
+
await self.async_sleep_fn(delay)
|
|
183
|
+
else:
|
|
184
|
+
await asyncio.sleep(delay)
|
|
185
|
+
continue
|
|
186
|
+
self._interrupt_call({
|
|
187
|
+
"reason": "predicted_rate_limit",
|
|
188
|
+
"remaining": budget.remaining_tokens,
|
|
189
|
+
"remaining_requests": budget.remaining_requests,
|
|
190
|
+
"reset_seconds": budget.reset_seconds,
|
|
191
|
+
"estimated": estimated,
|
|
192
|
+
"sigma": round(sigma, 1),
|
|
193
|
+
"safety_k": self.safety_k,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
# -- learning ---------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
def record(self, messages: List[Dict[str, str]], used: int) -> None:
|
|
199
|
+
"""Feed the estimator with the real consumption of a completed call.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
messages: the input that was sent to the LLM.
|
|
203
|
+
used: total tokens the provider reports for the call.
|
|
204
|
+
"""
|
|
205
|
+
self.estimator.record(self._input_tokens(messages), used)
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""LiteLLM adapter: one adapter, every provider.
|
|
2
|
+
|
|
3
|
+
`LiteLLM <https://github.com/BerriAI/litellm>`_ exposes 100+ LLM providers
|
|
4
|
+
(OpenAI, Groq, Anthropic, local servers, ...) behind a single
|
|
5
|
+
``completion()`` call, and surfaces each provider's rate-limit response
|
|
6
|
+
headers. This adapter turns that into the two callables the scheduler needs:
|
|
7
|
+
|
|
8
|
+
from agentpause import PredictiveScheduler
|
|
9
|
+
from agentpause.adapters.litellm import LiteLLMAdapter
|
|
10
|
+
|
|
11
|
+
adapter = LiteLLMAdapter(model="groq/llama-3.1-8b-instant")
|
|
12
|
+
sched = PredictiveScheduler(backend=adapter.backend, telemetry=adapter.telemetry)
|
|
13
|
+
|
|
14
|
+
Design notes
|
|
15
|
+
------------
|
|
16
|
+
* **Telemetry is read from response headers** (``x-ratelimit-remaining-tokens``),
|
|
17
|
+
which providers attach to every reply — so each ``backend()`` call refreshes
|
|
18
|
+
the budget reading for free.
|
|
19
|
+
* **The telemetry ping.** A budget value serialized in a checkpoint goes stale
|
|
20
|
+
(the window resets while the agent sleeps). So when ``telemetry()`` is asked
|
|
21
|
+
for a value and has no *fresh* reading, it performs a deliberately tiny
|
|
22
|
+
1-token call just to read the headers again. Cost: ~a dozen tokens and one
|
|
23
|
+
round-trip — negligible next to a wrong suspend/resume decision.
|
|
24
|
+
* ``completion_fn`` is injectable so the adapter is fully testable offline;
|
|
25
|
+
by default it lazily imports ``litellm.completion``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
import time
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
from ..errors import BackendError, RateLimitHit, TelemetryError
|
|
35
|
+
from ..risk import Budget
|
|
36
|
+
|
|
37
|
+
__all__ = ["LiteLLMAdapter", "TelemetryError", "RateLimitHit"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_reset(value: str) -> Optional[float]:
|
|
41
|
+
"""Convert provider reset values to seconds-from-now.
|
|
42
|
+
|
|
43
|
+
Handles duration strings ('7.66s', '2m59.56s', '450ms' — OpenAI/Groq
|
|
44
|
+
style) and RFC 3339 timestamps ('2026-07-07T21:04:05Z' — Anthropic style).
|
|
45
|
+
"""
|
|
46
|
+
if not value:
|
|
47
|
+
return None
|
|
48
|
+
text = str(value).strip()
|
|
49
|
+
# RFC 3339 timestamp → seconds until that instant (never negative)
|
|
50
|
+
if "T" in text and "-" in text:
|
|
51
|
+
try:
|
|
52
|
+
from datetime import datetime, timezone
|
|
53
|
+
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
54
|
+
return max(0.0, (dt - datetime.now(timezone.utc)).total_seconds())
|
|
55
|
+
except ValueError:
|
|
56
|
+
return None
|
|
57
|
+
m = re.fullmatch(
|
|
58
|
+
r"(?:(\d+)h)?(?:(\d+)m(?!s))?(?:([\d.]+)\s*(ms|s)?)?",
|
|
59
|
+
text,
|
|
60
|
+
)
|
|
61
|
+
if not m or not any(m.groups()):
|
|
62
|
+
return None
|
|
63
|
+
hours = int(m.group(1) or 0)
|
|
64
|
+
minutes = int(m.group(2) or 0)
|
|
65
|
+
number = float(m.group(3) or 0)
|
|
66
|
+
seconds = number / 1000 if m.group(4) == "ms" else number
|
|
67
|
+
return hours * 3600 + minutes * 60 + seconds
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_INSTALL_HINT = ("The LiteLLM adapter needs the 'litellm' package. "
|
|
71
|
+
"Install it with: pip install agentpause[litellm]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _default_completion() -> Callable[..., Any]:
|
|
75
|
+
try:
|
|
76
|
+
from litellm import completion
|
|
77
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
78
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
79
|
+
return completion
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _default_acompletion() -> Callable[..., Any]:
|
|
83
|
+
try:
|
|
84
|
+
from litellm import acompletion
|
|
85
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
86
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
87
|
+
return acompletion
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _default_token_counter() -> Callable[..., int]:
|
|
91
|
+
try:
|
|
92
|
+
from litellm import token_counter
|
|
93
|
+
except ImportError as exc: # pragma: no cover - depends on environment
|
|
94
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
95
|
+
return token_counter
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LiteLLMAdapter:
|
|
99
|
+
"""Backend + telemetry for any LiteLLM-supported provider.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
model: LiteLLM model string, e.g. ``"groq/llama-3.1-8b-instant"``
|
|
103
|
+
or ``"gpt-4o-mini"``.
|
|
104
|
+
completion_fn: the function performing the call. Defaults to
|
|
105
|
+
``litellm.completion`` (imported lazily); tests inject a fake.
|
|
106
|
+
remaining_header: response header holding the remaining token budget.
|
|
107
|
+
fallback_remaining: value ``telemetry()`` returns if the provider
|
|
108
|
+
sends no budget header. ``None`` (default) raises
|
|
109
|
+
:class:`TelemetryError` instead, so the problem is never silent.
|
|
110
|
+
max_age_s: how long a header reading stays "fresh". Older readings
|
|
111
|
+
trigger a telemetry ping.
|
|
112
|
+
clock: time source (injectable for tests). Defaults to
|
|
113
|
+
``time.monotonic``.
|
|
114
|
+
**completion_kwargs: extra arguments forwarded to every completion
|
|
115
|
+
call (e.g. ``temperature=0.2``).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
model: str,
|
|
121
|
+
completion_fn: Optional[Callable[..., Any]] = None,
|
|
122
|
+
acompletion_fn: Optional[Callable[..., Any]] = None,
|
|
123
|
+
token_counter_fn: Optional[Callable[..., int]] = None,
|
|
124
|
+
remaining_header: str = "x-ratelimit-remaining-tokens",
|
|
125
|
+
requests_header: str = "x-ratelimit-remaining-requests",
|
|
126
|
+
reset_header: str = "x-ratelimit-reset-tokens",
|
|
127
|
+
fallback_remaining: Optional[int] = None,
|
|
128
|
+
max_age_s: float = 10.0,
|
|
129
|
+
clock: Callable[[], float] = time.monotonic,
|
|
130
|
+
**completion_kwargs: Any,
|
|
131
|
+
) -> None:
|
|
132
|
+
self.model = model
|
|
133
|
+
self._completion = completion_fn
|
|
134
|
+
self._acompletion = acompletion_fn
|
|
135
|
+
self._token_counter = token_counter_fn
|
|
136
|
+
self.remaining_header = remaining_header.lower()
|
|
137
|
+
self.requests_header = requests_header.lower()
|
|
138
|
+
self.reset_header = reset_header.lower()
|
|
139
|
+
self.fallback_remaining = fallback_remaining
|
|
140
|
+
self.max_age_s = max_age_s
|
|
141
|
+
self._clock = clock
|
|
142
|
+
self.completion_kwargs = completion_kwargs
|
|
143
|
+
self._remaining: Optional[int] = None
|
|
144
|
+
self._remaining_input: Optional[int] = None
|
|
145
|
+
self._remaining_output: Optional[int] = None
|
|
146
|
+
self._remaining_requests: Optional[int] = None
|
|
147
|
+
self._reset_seconds: Optional[float] = None
|
|
148
|
+
self._read_at: Optional[float] = None
|
|
149
|
+
|
|
150
|
+
# -- the two callables the scheduler wants --------------------------------
|
|
151
|
+
|
|
152
|
+
def backend(self, messages: List[Dict[str, str]]) -> Tuple[str, int]:
|
|
153
|
+
"""Perform the LLM call; returns ``(reply_text, total_tokens_used)``.
|
|
154
|
+
|
|
155
|
+
Every real call doubles as a telemetry reading: the provider's
|
|
156
|
+
rate-limit headers ride along with the response.
|
|
157
|
+
"""
|
|
158
|
+
response = self._call(messages=messages, **self.completion_kwargs)
|
|
159
|
+
self._absorb_headers(response)
|
|
160
|
+
reply = response.choices[0].message.content or ""
|
|
161
|
+
used = int(response.usage.total_tokens)
|
|
162
|
+
return reply, used
|
|
163
|
+
|
|
164
|
+
def telemetry(self) -> int:
|
|
165
|
+
"""Remaining tokens in the provider's rate-limit window.
|
|
166
|
+
|
|
167
|
+
Answers from the last response's headers if that reading is still
|
|
168
|
+
fresh (younger than ``max_age_s``); otherwise performs a tiny
|
|
169
|
+
1-token ping to read the headers again. Never trusts old numbers —
|
|
170
|
+
a stale budget is how agents crash mid-task.
|
|
171
|
+
"""
|
|
172
|
+
if not self._is_fresh():
|
|
173
|
+
self.ping()
|
|
174
|
+
if self._remaining is not None:
|
|
175
|
+
return self._remaining
|
|
176
|
+
if self.fallback_remaining is not None:
|
|
177
|
+
return self.fallback_remaining
|
|
178
|
+
raise TelemetryError(
|
|
179
|
+
f"No '{self.remaining_header}' header in the provider response. "
|
|
180
|
+
"Either the provider does not expose token budgets, or the header "
|
|
181
|
+
"name differs (set remaining_header=...), or set "
|
|
182
|
+
"fallback_remaining=... to proceed with a fixed assumption."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def abackend(self, messages: List[Dict[str, str]]) -> Tuple[str, int]:
|
|
186
|
+
"""Async twin of :meth:`backend` (uses ``litellm.acompletion``)."""
|
|
187
|
+
response = await self._acall(messages=messages, **self.completion_kwargs)
|
|
188
|
+
self._absorb_headers(response)
|
|
189
|
+
reply = response.choices[0].message.content or ""
|
|
190
|
+
used = int(response.usage.total_tokens)
|
|
191
|
+
return reply, used
|
|
192
|
+
|
|
193
|
+
async def atelemetry(self) -> int:
|
|
194
|
+
"""Async twin of :meth:`telemetry` (the ping, if needed, is async)."""
|
|
195
|
+
if not self._is_fresh():
|
|
196
|
+
await self.aping()
|
|
197
|
+
if self._remaining is not None:
|
|
198
|
+
return self._remaining
|
|
199
|
+
if self.fallback_remaining is not None:
|
|
200
|
+
return self.fallback_remaining
|
|
201
|
+
raise TelemetryError(
|
|
202
|
+
f"No '{self.remaining_header}' header in the provider response."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
async def abudget(self) -> Budget:
|
|
206
|
+
"""Async twin of :meth:`budget`."""
|
|
207
|
+
return Budget(
|
|
208
|
+
remaining_tokens=await self.atelemetry(),
|
|
209
|
+
remaining_requests=self._remaining_requests,
|
|
210
|
+
reset_seconds=self._reset_seconds,
|
|
211
|
+
remaining_input_tokens=self._remaining_input,
|
|
212
|
+
remaining_output_tokens=self._remaining_output,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def count_tokens(self, text: str) -> int:
|
|
216
|
+
"""Per-model token count via litellm, with a safe heuristic fallback.
|
|
217
|
+
|
|
218
|
+
Wire it into the scheduler for a much more accurate input estimate::
|
|
219
|
+
|
|
220
|
+
PredictiveScheduler(..., count_tokens=adapter.count_tokens)
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
if self._token_counter is None:
|
|
224
|
+
self._token_counter = _default_token_counter()
|
|
225
|
+
return int(self._token_counter(model=self.model, text=text))
|
|
226
|
+
except Exception:
|
|
227
|
+
return max(1, len(text) // 4) # ~4 chars/token heuristic
|
|
228
|
+
|
|
229
|
+
def budget(self) -> Budget:
|
|
230
|
+
"""The full telemetry reading: tokens (TPM), requests (RPM), reset time.
|
|
231
|
+
|
|
232
|
+
Same freshness policy as :meth:`telemetry` (stale reading → tiny ping).
|
|
233
|
+
Pass this to the scheduler to unlock the three-valued decision
|
|
234
|
+
(``continue`` / ``wait`` / ``checkpoint``)::
|
|
235
|
+
|
|
236
|
+
sched = PredictiveScheduler(backend=adapter.backend,
|
|
237
|
+
telemetry=adapter.budget)
|
|
238
|
+
"""
|
|
239
|
+
return Budget(
|
|
240
|
+
remaining_tokens=self.telemetry(),
|
|
241
|
+
remaining_requests=self._remaining_requests,
|
|
242
|
+
reset_seconds=self._reset_seconds,
|
|
243
|
+
remaining_input_tokens=self._remaining_input,
|
|
244
|
+
remaining_output_tokens=self._remaining_output,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# -- internals -------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def ping(self) -> None:
|
|
250
|
+
"""Issue a deliberately tiny call whose only purpose is fresh headers."""
|
|
251
|
+
response = self._call(
|
|
252
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
253
|
+
max_tokens=1,
|
|
254
|
+
)
|
|
255
|
+
self._absorb_headers(response)
|
|
256
|
+
|
|
257
|
+
async def aping(self) -> None:
|
|
258
|
+
"""Async twin of :meth:`ping`."""
|
|
259
|
+
response = await self._acall(
|
|
260
|
+
messages=[{"role": "user", "content": "ping"}],
|
|
261
|
+
max_tokens=1,
|
|
262
|
+
)
|
|
263
|
+
self._absorb_headers(response)
|
|
264
|
+
|
|
265
|
+
async def _acall(self, **kwargs: Any) -> Any:
|
|
266
|
+
if self._acompletion is None:
|
|
267
|
+
self._acompletion = _default_acompletion()
|
|
268
|
+
try:
|
|
269
|
+
return await self._acompletion(model=self.model, **kwargs)
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
self._map_provider_error(exc)
|
|
272
|
+
raise
|
|
273
|
+
|
|
274
|
+
def _map_provider_error(self, exc: Exception) -> None:
|
|
275
|
+
"""Translate provider exceptions into agentpause's typed errors.
|
|
276
|
+
|
|
277
|
+
429 → RateLimitHit (with retry-after when reported).
|
|
278
|
+
5xx / timeouts → BackendError(retriable=True): transient infrastructure.
|
|
279
|
+
Anything else propagates unchanged (4xx = request problem, not retriable).
|
|
280
|
+
"""
|
|
281
|
+
status = getattr(exc, "status_code", None)
|
|
282
|
+
if status == 429 or exc.__class__.__name__ == "RateLimitError":
|
|
283
|
+
headers = getattr(exc, "headers", None) or {}
|
|
284
|
+
ra = headers.get("retry-after") if hasattr(headers, "get") else None
|
|
285
|
+
raise RateLimitHit(retry_after=float(ra) if ra is not None else None) from exc
|
|
286
|
+
if (status is not None and 500 <= int(status) <= 599) or exc.__class__.__name__ in (
|
|
287
|
+
"Timeout", "APITimeoutError", "APIConnectionError", "ServiceUnavailableError",
|
|
288
|
+
"InternalServerError",
|
|
289
|
+
):
|
|
290
|
+
raise BackendError(f"transient provider failure: {exc}", retriable=True) from exc
|
|
291
|
+
|
|
292
|
+
def _call(self, **kwargs: Any) -> Any:
|
|
293
|
+
if self._completion is None:
|
|
294
|
+
self._completion = _default_completion()
|
|
295
|
+
try:
|
|
296
|
+
return self._completion(model=self.model, **kwargs)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
self._map_provider_error(exc)
|
|
299
|
+
raise
|
|
300
|
+
|
|
301
|
+
def _is_fresh(self) -> bool:
|
|
302
|
+
return (
|
|
303
|
+
self._read_at is not None
|
|
304
|
+
and (self._clock() - self._read_at) <= self.max_age_s
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _absorb_headers(self, response: Any) -> None:
|
|
308
|
+
"""Extract the budget reading from headers.
|
|
309
|
+
|
|
310
|
+
Recognizes OpenAI/Groq-style names (``x-ratelimit-remaining-tokens``)
|
|
311
|
+
and Anthropic-style names (``anthropic-ratelimit-tokens-remaining``,
|
|
312
|
+
with separate input/output dimensions) automatically.
|
|
313
|
+
"""
|
|
314
|
+
headers = getattr(response, "_hidden_params", {}).get("additional_headers", {})
|
|
315
|
+
tokens: Optional[str] = None
|
|
316
|
+
input_tokens: Optional[str] = None
|
|
317
|
+
output_tokens: Optional[str] = None
|
|
318
|
+
requests_: Optional[str] = None
|
|
319
|
+
reset: Optional[str] = None
|
|
320
|
+
for key, raw in headers.items():
|
|
321
|
+
k = str(key).lower()
|
|
322
|
+
# litellm may prefix provider headers, e.g. "llm_provider-x-ratelimit-..."
|
|
323
|
+
if k.endswith("input-tokens-remaining"):
|
|
324
|
+
input_tokens = raw
|
|
325
|
+
elif k.endswith("output-tokens-remaining"):
|
|
326
|
+
output_tokens = raw
|
|
327
|
+
elif k.endswith(self.remaining_header) or k.endswith("ratelimit-tokens-remaining"):
|
|
328
|
+
tokens = raw
|
|
329
|
+
elif k.endswith(self.requests_header) or k.endswith("requests-remaining"):
|
|
330
|
+
requests_ = raw
|
|
331
|
+
elif k.endswith(self.reset_header) or k.endswith("ratelimit-tokens-reset"):
|
|
332
|
+
reset = raw
|
|
333
|
+
self._read_at = self._clock()
|
|
334
|
+
self._remaining = int(float(tokens)) if tokens is not None else None
|
|
335
|
+
self._remaining_input = int(float(input_tokens)) if input_tokens is not None else None
|
|
336
|
+
self._remaining_output = int(float(output_tokens)) if output_tokens is not None else None
|
|
337
|
+
self._remaining_requests = int(float(requests_)) if requests_ is not None else None
|
|
338
|
+
self._reset_seconds = _parse_reset(reset) if reset is not None else None
|
agentpause/breaker.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Lightweight circuit breaker: stop hammering a failing provider.
|
|
2
|
+
|
|
3
|
+
Retries handle *transient* glitches; the breaker handles *persistent* ones.
|
|
4
|
+
After ``failure_threshold`` consecutive infrastructure failures the circuit
|
|
5
|
+
opens: calls fail fast (raising :class:`CircuitOpenError`) without touching
|
|
6
|
+
the provider, until a cooldown expires and a single probe call is allowed
|
|
7
|
+
through (half-open). A successful probe closes the circuit again.
|
|
8
|
+
|
|
9
|
+
Wrap any backend — it stays a plain ``messages -> (reply, tokens)`` callable:
|
|
10
|
+
|
|
11
|
+
from agentpause import CircuitBreaker
|
|
12
|
+
|
|
13
|
+
guarded = CircuitBreaker(adapter.backend, failure_threshold=5, cooldown_s=300)
|
|
14
|
+
sched = PredictiveScheduler(backend=guarded, telemetry=adapter.budget)
|
|
15
|
+
|
|
16
|
+
Only infrastructure failures count (rate limits, retriable 5xx): a 4xx means
|
|
17
|
+
the request is broken — no amount of cooling down will fix it, so it never
|
|
18
|
+
trips the breaker.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
from .errors import AgentPauseError, BackendError, RateLimitHit
|
|
27
|
+
|
|
28
|
+
__all__ = ["CircuitBreaker", "CircuitOpenError"]
|
|
29
|
+
|
|
30
|
+
Backend = Callable[[List[Dict[str, str]]], Tuple[str, int]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CircuitOpenError(AgentPauseError):
|
|
34
|
+
"""The circuit is open: the provider is presumed down, call not attempted."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CircuitBreaker:
|
|
38
|
+
"""Wrap a backend with a CLOSED → OPEN → HALF_OPEN state machine.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
backend: the callable to protect.
|
|
42
|
+
failure_threshold: consecutive infrastructure failures that open
|
|
43
|
+
the circuit.
|
|
44
|
+
cooldown_s: how long the circuit stays open before allowing a probe.
|
|
45
|
+
clock: time source (injectable for tests).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
backend: Backend,
|
|
51
|
+
failure_threshold: int = 5,
|
|
52
|
+
cooldown_s: float = 300.0,
|
|
53
|
+
clock: Callable[[], float] = time.monotonic,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.backend = backend
|
|
56
|
+
self.failure_threshold = failure_threshold
|
|
57
|
+
self.cooldown_s = cooldown_s
|
|
58
|
+
self._clock = clock
|
|
59
|
+
self._failures = 0
|
|
60
|
+
self._opened_at: Optional[float] = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def state(self) -> str:
|
|
64
|
+
if self._opened_at is None:
|
|
65
|
+
return "closed"
|
|
66
|
+
if self._clock() - self._opened_at >= self.cooldown_s:
|
|
67
|
+
return "half_open"
|
|
68
|
+
return "open"
|
|
69
|
+
|
|
70
|
+
def __call__(self, messages: List[Dict[str, str]]) -> Tuple[str, int]:
|
|
71
|
+
state = self.state
|
|
72
|
+
if state == "open":
|
|
73
|
+
raise CircuitOpenError(
|
|
74
|
+
f"circuit open after {self._failures} consecutive failures; "
|
|
75
|
+
f"retry after the cooldown ({self.cooldown_s:.0f}s)"
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
result = self.backend(messages)
|
|
79
|
+
except (RateLimitHit, BackendError) as exc:
|
|
80
|
+
if isinstance(exc, BackendError) and not exc.retriable:
|
|
81
|
+
raise # request problem: never trips the breaker
|
|
82
|
+
self._failures += 1
|
|
83
|
+
if state == "half_open" or self._failures >= self.failure_threshold:
|
|
84
|
+
self._opened_at = self._clock() # (re)open, cooldown restarts
|
|
85
|
+
raise
|
|
86
|
+
self._failures = 0
|
|
87
|
+
self._opened_at = None # any success closes the circuit
|
|
88
|
+
return result
|