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 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