backstop-ai 0.6.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.
- backstop/__init__.py +43 -0
- backstop/__main__.py +4 -0
- backstop/_httpcompat.py +119 -0
- backstop/adapters/__init__.py +73 -0
- backstop/admission.py +114 -0
- backstop/agent_guard.py +54 -0
- backstop/aimd.py +61 -0
- backstop/audit.py +79 -0
- backstop/audit_sink.py +212 -0
- backstop/budget.py +79 -0
- backstop/cache.py +157 -0
- backstop/circuit.py +85 -0
- backstop/cli.py +546 -0
- backstop/config.py +313 -0
- backstop/cost.py +36 -0
- backstop/dashboard_app.py +381 -0
- backstop/dashboard_demo.py +386 -0
- backstop/dashboard_ui.py +1356 -0
- backstop/demo.py +347 -0
- backstop/exceptions.py +77 -0
- backstop/extract.py +248 -0
- backstop/forecast.py +62 -0
- backstop/gateway.py +117 -0
- backstop/harness.py +225 -0
- backstop/hierarchical.py +167 -0
- backstop/hooks.py +29 -0
- backstop/latency.py +78 -0
- backstop/ledger.py +112 -0
- backstop/limiter.py +38 -0
- backstop/metrics.py +154 -0
- backstop/notifications.py +188 -0
- backstop/otel.py +179 -0
- backstop/pricing.py +299 -0
- backstop/quotas.py +86 -0
- backstop/real_anthropic.py +170 -0
- backstop/real_openai.py +202 -0
- backstop/retry.py +17 -0
- backstop/rollout.py +106 -0
- backstop/secrets.py +66 -0
- backstop/state.py +73 -0
- backstop/state_backends.py +201 -0
- backstop/streaming.py +196 -0
- backstop/telemetry.py +833 -0
- backstop/transports.py +1092 -0
- backstop/verify.py +705 -0
- backstop/wrapper.py +314 -0
- backstop/wrapper.py.backup +245 -0
- backstop/wrapper.py.backup2 +253 -0
- backstop_ai-0.6.0.dist-info/METADATA +308 -0
- backstop_ai-0.6.0.dist-info/RECORD +58 -0
- backstop_ai-0.6.0.dist-info/WHEEL +4 -0
- backstop_ai-0.6.0.dist-info/entry_points.txt +3 -0
- backstop_ai-0.6.0.dist-info/licenses/LICENSE.txt +21 -0
- wedge/__init__.py +2 -0
- wedge/cli.py +87 -0
- wedge/diff_engine.py +126 -0
- wedge/report.py +80 -0
- wedge/runner.py +563 -0
backstop/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from .config import BackstopConfig, Priority
|
|
2
|
+
from .cost import CostEstimate, estimate as cost_estimate
|
|
3
|
+
|
|
4
|
+
__version__ = "0.6.0"
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
BackstopError,
|
|
7
|
+
BudgetExceededError,
|
|
8
|
+
CircuitBreakerOpenError,
|
|
9
|
+
LatencyBudgetExceededError,
|
|
10
|
+
UnsupportedClientError,
|
|
11
|
+
)
|
|
12
|
+
from .extract import count_tokens
|
|
13
|
+
from .hooks import AfterResponseHook, BeforeRequestHook
|
|
14
|
+
from .latency import BackstopMeta
|
|
15
|
+
from .ledger import BudgetLedger, ReservationTicket, TenantBudget, get_current_tenant, get_ledger, with_budget
|
|
16
|
+
from .wrapper import Backstop
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AfterResponseHook",
|
|
20
|
+
"Backstop",
|
|
21
|
+
"BackstopConfig",
|
|
22
|
+
"BackstopMeta",
|
|
23
|
+
"BeforeRequestHook",
|
|
24
|
+
"BudgetExceededError",
|
|
25
|
+
"BudgetLedger",
|
|
26
|
+
"CircuitBreakerOpenError",
|
|
27
|
+
"CostEstimate",
|
|
28
|
+
"LatencyBudgetExceededError",
|
|
29
|
+
"Priority",
|
|
30
|
+
"ReservationTicket",
|
|
31
|
+
"TenantBudget",
|
|
32
|
+
"UnsupportedClientError",
|
|
33
|
+
"BackstopError",
|
|
34
|
+
"budgets",
|
|
35
|
+
"count_tokens",
|
|
36
|
+
"get_current_tenant",
|
|
37
|
+
"with_budget",
|
|
38
|
+
"cost",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
budgets = get_ledger()
|
|
42
|
+
cost = type("_CostModule", (), {"estimate": staticmethod(cost_estimate)})()
|
|
43
|
+
|
backstop/__main__.py
ADDED
backstop/_httpcompat.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Select the httpx-compatible module each wrapped SDK actually uses.
|
|
2
|
+
|
|
3
|
+
``openai>=3`` and ``anthropic>=1`` moved to ``httpx2``; older SDK majors use
|
|
4
|
+
``httpx``. The two modules expose the same transport API surface but their
|
|
5
|
+
objects are mutually incompatible — an SDK rejects an http client built from
|
|
6
|
+
the other module, so Backstop must build its transports from the module the
|
|
7
|
+
wrapped SDK itself uses.
|
|
8
|
+
|
|
9
|
+
The module is detected from the wrapped client's own internal HTTP client,
|
|
10
|
+
so the choice always matches the SDK in front of us. When ``httpx2`` is not
|
|
11
|
+
installed, only the classic ``httpx`` compatibility is available.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
try: # httpx2 is optional: it arrives as a dependency of modern openai/anthropic SDKs
|
|
20
|
+
import httpx2 # noqa: F401
|
|
21
|
+
|
|
22
|
+
_HAS_HTTPX2 = True
|
|
23
|
+
except ImportError: # pragma: no cover - exercised only on httpx-only installs
|
|
24
|
+
httpx2 = None # type: ignore[assignment]
|
|
25
|
+
_HAS_HTTPX2 = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _HttpCompat:
|
|
29
|
+
"""Namespace exposing one httpx-compatible module's transport surface."""
|
|
30
|
+
|
|
31
|
+
__slots__ = (
|
|
32
|
+
"module",
|
|
33
|
+
"name",
|
|
34
|
+
"Client",
|
|
35
|
+
"AsyncClient",
|
|
36
|
+
"BaseTransport",
|
|
37
|
+
"AsyncBaseTransport",
|
|
38
|
+
"HTTPTransport",
|
|
39
|
+
"AsyncHTTPTransport",
|
|
40
|
+
"Request",
|
|
41
|
+
"Response",
|
|
42
|
+
"URL",
|
|
43
|
+
"Timeout",
|
|
44
|
+
"TimeoutException",
|
|
45
|
+
"TransportError",
|
|
46
|
+
"ConnectError",
|
|
47
|
+
"ReadTimeout",
|
|
48
|
+
"MockTransport",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def __init__(self, module: Any) -> None:
|
|
52
|
+
self.module = module
|
|
53
|
+
self.name = getattr(module, "__name__", "")
|
|
54
|
+
self.Client = module.Client
|
|
55
|
+
self.AsyncClient = module.AsyncClient
|
|
56
|
+
self.BaseTransport = module.BaseTransport
|
|
57
|
+
self.AsyncBaseTransport = module.AsyncBaseTransport
|
|
58
|
+
self.HTTPTransport = module.HTTPTransport
|
|
59
|
+
self.AsyncHTTPTransport = module.AsyncHTTPTransport
|
|
60
|
+
self.Request = module.Request
|
|
61
|
+
self.Response = module.Response
|
|
62
|
+
self.URL = module.URL
|
|
63
|
+
self.Timeout = module.Timeout
|
|
64
|
+
self.TimeoutException = module.TimeoutException
|
|
65
|
+
self.TransportError = module.TransportError
|
|
66
|
+
self.ConnectError = module.ConnectError
|
|
67
|
+
self.ReadTimeout = module.ReadTimeout
|
|
68
|
+
self.MockTransport = module.MockTransport
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
HTTPX = _HttpCompat(httpx)
|
|
72
|
+
HTTPX2 = _HttpCompat(httpx2) if _HAS_HTTPX2 else None
|
|
73
|
+
|
|
74
|
+
# Module-level exports default to the classic httpx family: ``httpx`` is an
|
|
75
|
+
# unconditional dependency while ``httpx2`` is optional, so this is the only
|
|
76
|
+
# default that cannot fail on an httpx-only install. Per-client selection goes
|
|
77
|
+
# through :func:`compat_for` instead.
|
|
78
|
+
Client = HTTPX.Client
|
|
79
|
+
AsyncClient = HTTPX.AsyncClient
|
|
80
|
+
BaseTransport = HTTPX.BaseTransport
|
|
81
|
+
AsyncBaseTransport = HTTPX.AsyncBaseTransport
|
|
82
|
+
HTTPTransport = HTTPX.HTTPTransport
|
|
83
|
+
AsyncHTTPTransport = HTTPX.AsyncHTTPTransport
|
|
84
|
+
Request = HTTPX.Request
|
|
85
|
+
Response = HTTPX.Response
|
|
86
|
+
URL = HTTPX.URL
|
|
87
|
+
Timeout = HTTPX.Timeout
|
|
88
|
+
TimeoutException = HTTPX.TimeoutException
|
|
89
|
+
TransportError = HTTPX.TransportError
|
|
90
|
+
ConnectError = HTTPX.ConnectError
|
|
91
|
+
ReadTimeout = HTTPX.ReadTimeout
|
|
92
|
+
MockTransport = getattr(httpx, "MockTransport")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def compat_for(base_client: Any) -> _HttpCompat:
|
|
96
|
+
"""Return the compat module matching the SDK's own internal client.
|
|
97
|
+
|
|
98
|
+
``base_client`` is the wrapped SDK client's ``_client`` attribute. Its
|
|
99
|
+
class ``__mro__`` names the module family (``httpx`` vs ``httpx2``) that
|
|
100
|
+
built it, which is the module the SDK will accept back.
|
|
101
|
+
"""
|
|
102
|
+
for cls in type(base_client).__mro__:
|
|
103
|
+
root = getattr(cls, "__module__", "").partition(".")[0]
|
|
104
|
+
if root == "httpx2":
|
|
105
|
+
if HTTPX2 is None:
|
|
106
|
+
break
|
|
107
|
+
return HTTPX2
|
|
108
|
+
if root == "httpx":
|
|
109
|
+
return HTTPX
|
|
110
|
+
return HTTPX
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def module_root(obj: Any) -> str:
|
|
114
|
+
"""Return ``httpx`` or ``httpx2`` for an object, whichever built its type."""
|
|
115
|
+
for cls in type(obj).__mro__:
|
|
116
|
+
root = getattr(cls, "__module__", "").partition(".")[0]
|
|
117
|
+
if root in ("httpx", "httpx2"):
|
|
118
|
+
return root
|
|
119
|
+
return ""
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Framework adapters (Deep Research P1#6).
|
|
2
|
+
|
|
3
|
+
Backstop enforces in-process, but agents live inside frameworks (LangChain,
|
|
4
|
+
LlamaIndex, CrewAI, AutoGen). These thin, optional-import adapters bridge the
|
|
5
|
+
framework's callback system to Backstop's hooks/metrics/tenant scoping so
|
|
6
|
+
Backstop becomes *the* guardrail inside the framework instead of a wrapper around
|
|
7
|
+
the raw SDK. Framework imports are deferred to call time, so ``import
|
|
8
|
+
backstop.adapters`` is always safe without the framework installed.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BackstopAdapter:
|
|
16
|
+
"""Framework-agnostic bridge. Subclass or call ``on_llm_start`` /
|
|
17
|
+
``on_llm_end`` from a framework callback to feed Backstop's metrics and
|
|
18
|
+
audit log. No framework dependency."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, config: Any, tenant_id: str | None = None) -> None:
|
|
21
|
+
self.config = config
|
|
22
|
+
self.tenant_id = tenant_id
|
|
23
|
+
|
|
24
|
+
def on_llm_start(self, model: str, estimated_tokens: int = 0) -> dict:
|
|
25
|
+
return {"model": model, "estimated_tokens": estimated_tokens, "tenant_id": self.tenant_id}
|
|
26
|
+
|
|
27
|
+
def on_llm_end(self, model: str, prompt_tokens: int = 0, completion_tokens: int = 0) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"model": model,
|
|
30
|
+
"prompt_tokens": prompt_tokens,
|
|
31
|
+
"completion_tokens": completion_tokens,
|
|
32
|
+
"tenant_id": self.tenant_id,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_langchain_handler(config: Any, tenant_id: str | None = None) -> Any:
|
|
37
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
38
|
+
|
|
39
|
+
adapter = BackstopAdapter(config, tenant_id)
|
|
40
|
+
|
|
41
|
+
class BackstopCallbackHandler(BaseCallbackHandler):
|
|
42
|
+
def on_llm_start(self, serialized, prompts, **kwargs):
|
|
43
|
+
model = (serialized or {}).get("name", "") if isinstance(serialized, dict) else ""
|
|
44
|
+
est = sum(len(p) // 4 for p in prompts) if prompts else 0
|
|
45
|
+
adapter.on_llm_start(model, est)
|
|
46
|
+
|
|
47
|
+
def on_llm_end(self, response, **kwargs):
|
|
48
|
+
model = getattr(response, "llm_output", None) or {}
|
|
49
|
+
usage = (model.get("token_usage") if isinstance(model, dict) else None) or {}
|
|
50
|
+
adapter.on_llm_end(
|
|
51
|
+
model.get("model_name", "") if isinstance(model, dict) else "",
|
|
52
|
+
usage.get("prompt_tokens", 0),
|
|
53
|
+
usage.get("completion_tokens", 0),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return BackstopCallbackHandler()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_llama_index_callback(config: Any, tenant_id: str | None = None) -> Any:
|
|
60
|
+
from llama_index.core.callbacks import BaseCallbackHandler as LlamaBase
|
|
61
|
+
|
|
62
|
+
adapter = BackstopAdapter(config, tenant_id)
|
|
63
|
+
|
|
64
|
+
class BackstopLlamaHandler(LlamaBase):
|
|
65
|
+
def on_event_start(self, event_type, payload=None, event_id="", parent_id="", **kwargs):
|
|
66
|
+
if str(event_type).lower().startswith("llm"):
|
|
67
|
+
adapter.on_llm_start(str(payload.get("model", "")) if isinstance(payload, dict) else "")
|
|
68
|
+
|
|
69
|
+
def on_event_end(self, event_type, payload=None, event_id="", parent_id="", **kwargs):
|
|
70
|
+
if str(event_type).lower().startswith("llm"):
|
|
71
|
+
adapter.on_llm_end(str(payload.get("model", "")) if isinstance(payload, dict) else "")
|
|
72
|
+
|
|
73
|
+
return BackstopLlamaHandler()
|
backstop/admission.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import itertools
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from .aimd import AIMDController
|
|
11
|
+
from .config import BackstopConfig, Priority
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class _Ticket:
|
|
16
|
+
priority: Priority
|
|
17
|
+
sequence: int
|
|
18
|
+
enqueued_at: float
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PriorityGate:
|
|
22
|
+
def __init__(self, config: BackstopConfig, aimd: AIMDController) -> None:
|
|
23
|
+
self._config = config
|
|
24
|
+
self._aimd = aimd
|
|
25
|
+
self._active = 0
|
|
26
|
+
self._counter = itertools.count()
|
|
27
|
+
self._queues: dict[Priority, deque[_Ticket]] = {
|
|
28
|
+
Priority.CRITICAL: deque(),
|
|
29
|
+
Priority.DEFAULT: deque(),
|
|
30
|
+
Priority.BACKGROUND: deque(),
|
|
31
|
+
}
|
|
32
|
+
self._condition = threading.Condition()
|
|
33
|
+
self._async_condition = asyncio.Condition()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def active(self) -> int:
|
|
37
|
+
with self._condition:
|
|
38
|
+
return self._active
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def depth(self) -> int:
|
|
42
|
+
with self._condition:
|
|
43
|
+
return sum(len(queue) for queue in self._queues.values())
|
|
44
|
+
|
|
45
|
+
def acquire(self, priority: Priority, timeout: float | None = None) -> float:
|
|
46
|
+
ticket = _Ticket(priority, next(self._counter), time.monotonic())
|
|
47
|
+
effective = timeout if timeout is not None else self._config.queue_timeout
|
|
48
|
+
with self._condition:
|
|
49
|
+
self._queues[priority].append(ticket)
|
|
50
|
+
self._condition.notify_all()
|
|
51
|
+
while not self._can_admit(ticket):
|
|
52
|
+
if effective is None:
|
|
53
|
+
self._condition.wait()
|
|
54
|
+
else:
|
|
55
|
+
if not self._condition.wait(timeout=effective):
|
|
56
|
+
raise TimeoutError("gate acquire timed out")
|
|
57
|
+
self._queues[priority].popleft()
|
|
58
|
+
self._active += 1
|
|
59
|
+
return time.monotonic() - ticket.enqueued_at
|
|
60
|
+
|
|
61
|
+
def release(self) -> None:
|
|
62
|
+
with self._condition:
|
|
63
|
+
self._active = max(0, self._active - 1)
|
|
64
|
+
self._condition.notify_all()
|
|
65
|
+
|
|
66
|
+
async def aacquire(self, priority: Priority, timeout: float | None = None) -> float:
|
|
67
|
+
ticket = _Ticket(priority, next(self._counter), time.monotonic())
|
|
68
|
+
effective = timeout if timeout is not None else self._config.queue_timeout
|
|
69
|
+
async with self._async_condition:
|
|
70
|
+
self._queues[priority].append(ticket)
|
|
71
|
+
self._async_condition.notify_all()
|
|
72
|
+
while not self._can_admit(ticket):
|
|
73
|
+
try:
|
|
74
|
+
if effective is None:
|
|
75
|
+
await self._async_condition.wait()
|
|
76
|
+
else:
|
|
77
|
+
await asyncio.wait_for(self._async_condition.wait(), effective)
|
|
78
|
+
except asyncio.TimeoutError:
|
|
79
|
+
raise TimeoutError("gate acquire timed out")
|
|
80
|
+
self._queues[priority].popleft()
|
|
81
|
+
self._active += 1
|
|
82
|
+
return time.monotonic() - ticket.enqueued_at
|
|
83
|
+
|
|
84
|
+
async def arelease(self) -> None:
|
|
85
|
+
async with self._async_condition:
|
|
86
|
+
self._active = max(0, self._active - 1)
|
|
87
|
+
self._async_condition.notify_all()
|
|
88
|
+
|
|
89
|
+
def _can_admit(self, ticket: _Ticket) -> bool:
|
|
90
|
+
if self._active >= self._aimd.current_limit:
|
|
91
|
+
return False
|
|
92
|
+
chosen = self._choose_ticket()
|
|
93
|
+
return chosen is ticket
|
|
94
|
+
|
|
95
|
+
def _choose_ticket(self) -> _Ticket | None:
|
|
96
|
+
now = time.monotonic()
|
|
97
|
+
critical = self._queues[Priority.CRITICAL]
|
|
98
|
+
if critical:
|
|
99
|
+
return critical[0]
|
|
100
|
+
|
|
101
|
+
aged = [
|
|
102
|
+
queue[0]
|
|
103
|
+
for queue in self._queues.values()
|
|
104
|
+
if queue and now - queue[0].enqueued_at >= self._config.starvation_after_seconds
|
|
105
|
+
]
|
|
106
|
+
if aged:
|
|
107
|
+
return min(aged, key=lambda item: item.sequence)
|
|
108
|
+
|
|
109
|
+
for priority in (Priority.DEFAULT, Priority.BACKGROUND):
|
|
110
|
+
queue = self._queues[priority]
|
|
111
|
+
if queue:
|
|
112
|
+
return queue[0]
|
|
113
|
+
return None
|
|
114
|
+
|
backstop/agent_guard.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Agent-native guardrails (Deep Research P2#11).
|
|
2
|
+
|
|
3
|
+
Coding/agent loops can run away: repeated tool calls, runaway generation, or
|
|
4
|
+
stalls. ``AgentGuard`` tracks per-agent request counts and token spend within a
|
|
5
|
+
sliding window and blocks (returns False) once a ceiling is hit, so a misbehaving
|
|
6
|
+
agent is fenced by Backstop rather than silently burning the whole budget.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentGuard:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
max_calls: int = 100,
|
|
19
|
+
window_seconds: float = 60.0,
|
|
20
|
+
max_tokens: int | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.max_calls = max_calls
|
|
23
|
+
self.window_seconds = window_seconds
|
|
24
|
+
self.max_tokens = max_tokens
|
|
25
|
+
self._calls: dict[str, deque[float]] = {}
|
|
26
|
+
self._tokens: dict[str, deque[tuple[float, int]]] = {}
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def allow(self, agent_id: str, tokens: int = 0) -> bool:
|
|
30
|
+
now = time.monotonic()
|
|
31
|
+
with self._lock:
|
|
32
|
+
calls = self._calls.setdefault(agent_id, deque())
|
|
33
|
+
self._trim(calls, now)
|
|
34
|
+
if len(calls) >= self.max_calls:
|
|
35
|
+
return False
|
|
36
|
+
calls.append(now)
|
|
37
|
+
|
|
38
|
+
if self.max_tokens is not None and tokens > 0:
|
|
39
|
+
spent = self._tokens.setdefault(agent_id, deque())
|
|
40
|
+
self._trim_tokens(spent, now)
|
|
41
|
+
if sum(t for _, t in spent) + tokens > self.max_tokens:
|
|
42
|
+
return False
|
|
43
|
+
spent.append((now, tokens))
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
def _trim(self, calls: deque[float], now: float) -> None:
|
|
47
|
+
cutoff = now - self.window_seconds
|
|
48
|
+
while calls and calls[0] < cutoff:
|
|
49
|
+
calls.popleft()
|
|
50
|
+
|
|
51
|
+
def _trim_tokens(self, spent: deque[tuple[float, int]], now: float) -> None:
|
|
52
|
+
cutoff = now - self.window_seconds
|
|
53
|
+
while spent and spent[0][0] < cutoff:
|
|
54
|
+
spent.popleft()
|
backstop/aimd.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from .config import BackstopConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AIMDController:
|
|
10
|
+
def __init__(self, config: BackstopConfig) -> None:
|
|
11
|
+
self._config = config
|
|
12
|
+
self._limit = config.initial_concurrency
|
|
13
|
+
# Initialize so the first adjustment is always allowed regardless
|
|
14
|
+
# of how long this system has been running (monotonic clock).
|
|
15
|
+
self._last_adjustment = -self._config.aimd_adjustment_interval
|
|
16
|
+
self._lock = threading.Lock()
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def current_limit(self) -> int:
|
|
20
|
+
with self._lock:
|
|
21
|
+
return self._limit
|
|
22
|
+
|
|
23
|
+
def record_success(self) -> bool:
|
|
24
|
+
now = time.monotonic()
|
|
25
|
+
with self._lock:
|
|
26
|
+
if now - self._last_adjustment < self._config.aimd_adjustment_interval:
|
|
27
|
+
return False
|
|
28
|
+
if self._limit >= self._config.max_concurrency:
|
|
29
|
+
return False
|
|
30
|
+
self._limit = min(self._config.max_concurrency, self._limit + self._config.aimd_increase)
|
|
31
|
+
self._last_adjustment = now
|
|
32
|
+
return True
|
|
33
|
+
|
|
34
|
+
def record_pressure(self) -> bool:
|
|
35
|
+
now = time.monotonic()
|
|
36
|
+
with self._lock:
|
|
37
|
+
if now - self._last_adjustment < self._config.aimd_adjustment_interval:
|
|
38
|
+
return False
|
|
39
|
+
new_limit = max(
|
|
40
|
+
self._config.min_concurrency,
|
|
41
|
+
int(self._limit * self._config.aimd_decrease_factor),
|
|
42
|
+
)
|
|
43
|
+
if new_limit == self._limit:
|
|
44
|
+
return False
|
|
45
|
+
self._limit = new_limit
|
|
46
|
+
self._last_adjustment = now
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def apply_external_decrease(self, pressure: float = 1.0) -> bool:
|
|
50
|
+
"""Proactively clamp the limit from an out-of-band signal (e.g. provider
|
|
51
|
+
quota pressure). Bypasses the adjustment-interval throttle so a quota
|
|
52
|
+
warning takes effect on the very next request."""
|
|
53
|
+
with self._lock:
|
|
54
|
+
factor = max(self._config.aimd_decrease_factor, min(1.0, pressure))
|
|
55
|
+
new_limit = max(self._config.min_concurrency, int(self._limit * factor))
|
|
56
|
+
if new_limit == self._limit:
|
|
57
|
+
return False
|
|
58
|
+
self._limit = new_limit
|
|
59
|
+
self._last_adjustment = time.monotonic()
|
|
60
|
+
return True
|
|
61
|
+
|
backstop/audit.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tamper-evident audit log (Deep Research P2#8).
|
|
2
|
+
|
|
3
|
+
Every enforcement decision (deny / fallback / downgrade / cache_hit) is appended
|
|
4
|
+
as a JSON record whose integrity is chained: each record carries an HMAC of its
|
|
5
|
+
own payload plus the previous record's chain hash. `verify()` replays the chain
|
|
6
|
+
and detects any tampering. This is the enterprise "escape hatch" that makes
|
|
7
|
+
in-process enforcement audit-ready and closes the supply-chain / audit gap the
|
|
8
|
+
research flagged (e.g. the March-2026 LiteLLM incident class).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _chain_hash(prev: bytes, payload: bytes, key: bytes) -> str:
|
|
21
|
+
mac = hmac.new(key, prev + payload, hashlib.sha256).hexdigest()
|
|
22
|
+
return hashlib.sha256((prev.hex() + mac).encode()).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AuditLog:
|
|
26
|
+
def __init__(self, sink: str | Callable[[str], None], hmac_key: str | None = None) -> None:
|
|
27
|
+
self._key = (hmac_key or "").encode("utf-8")
|
|
28
|
+
self._prev = b""
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
self._file = open(sink, "a", encoding="utf-8") if isinstance(sink, str) else None
|
|
31
|
+
self._callable = sink if callable(sink) else None
|
|
32
|
+
|
|
33
|
+
def record(self, decision: str, reason: str, **fields: Any) -> dict:
|
|
34
|
+
rec = {"ts": time.time(), "decision": decision, "reason": reason}
|
|
35
|
+
if fields:
|
|
36
|
+
rec.update(fields)
|
|
37
|
+
payload = json.dumps(rec, sort_keys=True, default=str).encode("utf-8")
|
|
38
|
+
with self._lock:
|
|
39
|
+
chain = _chain_hash(self._prev, payload, self._key)
|
|
40
|
+
rec["_chain"] = chain
|
|
41
|
+
self._prev = bytes.fromhex(chain)
|
|
42
|
+
line = json.dumps(rec, sort_keys=True, default=str)
|
|
43
|
+
with self._lock:
|
|
44
|
+
if self._file is not None:
|
|
45
|
+
self._file.write(line + "\n")
|
|
46
|
+
self._file.flush()
|
|
47
|
+
elif self._callable is not None:
|
|
48
|
+
self._callable(line)
|
|
49
|
+
return rec
|
|
50
|
+
|
|
51
|
+
def verify(self, lines: list[str] | None = None) -> bool:
|
|
52
|
+
prev = b""
|
|
53
|
+
source = lines if lines is not None else self._read_lines()
|
|
54
|
+
for line in source:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
rec = json.loads(line)
|
|
59
|
+
payload = json.dumps(
|
|
60
|
+
{k: v for k, v in rec.items() if k != "_chain"}, sort_keys=True, default=str
|
|
61
|
+
).encode("utf-8")
|
|
62
|
+
chain = _chain_hash(prev, payload, self._key)
|
|
63
|
+
if chain != rec.get("_chain"):
|
|
64
|
+
return False
|
|
65
|
+
prev = bytes.fromhex(rec["_chain"])
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def _read_lines(self) -> list[str]:
|
|
69
|
+
if self._file is None:
|
|
70
|
+
return []
|
|
71
|
+
path = self._file.name
|
|
72
|
+
self._file.flush()
|
|
73
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
74
|
+
return fh.readlines()
|
|
75
|
+
|
|
76
|
+
def close(self) -> None:
|
|
77
|
+
if self._file is not None:
|
|
78
|
+
self._file.close()
|
|
79
|
+
self._file = None
|