flightdeck-sensor 0.1.0a1__tar.gz
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.
- flightdeck_sensor-0.1.0a1/PKG-INFO +32 -0
- flightdeck_sensor-0.1.0a1/README.md +3 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/__init__.py +1 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/core/__init__.py +0 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/core/exceptions.py +34 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/core/policy.py +84 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/core/session.py +271 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/core/types.py +96 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/interceptor/__init__.py +0 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/providers/__init__.py +0 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/providers/anthropic.py +96 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/providers/openai.py +124 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/providers/protocol.py +60 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/py.typed +0 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/transport/__init__.py +0 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/transport/client.py +124 -0
- flightdeck_sensor-0.1.0a1/flightdeck_sensor/transport/retry.py +53 -0
- flightdeck_sensor-0.1.0a1/pyproject.toml +60 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flightdeck-sensor
|
|
3
|
+
Version: 0.1.0a1
|
|
4
|
+
Summary: In-process agent observability sensor for Flightdeck
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Provides-Extra: anthropic
|
|
18
|
+
Requires-Dist: anthropic>=0.20; extra == 'anthropic'
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: httpx>=0.25; extra == 'dev'
|
|
21
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.3; extra == 'dev'
|
|
25
|
+
Provides-Extra: openai
|
|
26
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
27
|
+
Requires-Dist: tiktoken>=0.5; extra == 'openai'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# flightdeck-sensor
|
|
31
|
+
|
|
32
|
+
In-process agent observability sensor for [Flightdeck](https://github.com/flightdeckhq/flightdeck).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""flightdeck-sensor: in-process agent observability for Flightdeck."""
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Exceptions raised by flightdeck-sensor."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BudgetExceededError(Exception):
|
|
7
|
+
"""Raised when the token budget is exhausted and policy is BLOCK."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
session_id: str,
|
|
12
|
+
tokens_used: int,
|
|
13
|
+
token_limit: int,
|
|
14
|
+
) -> None:
|
|
15
|
+
self.session_id = session_id
|
|
16
|
+
self.tokens_used = tokens_used
|
|
17
|
+
self.token_limit = token_limit
|
|
18
|
+
super().__init__(
|
|
19
|
+
f"Token budget exceeded: {tokens_used}/{token_limit} "
|
|
20
|
+
f"(session {session_id})"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DirectiveError(Exception):
|
|
25
|
+
"""Raised when a directive requires halting and halt policy is active."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, action: str, reason: str) -> None:
|
|
28
|
+
self.action = action
|
|
29
|
+
self.reason = reason
|
|
30
|
+
super().__init__(f"Directive {action}: {reason}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConfigurationError(Exception):
|
|
34
|
+
"""Raised when init() receives invalid arguments."""
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Local token budget enforcement cache.
|
|
2
|
+
|
|
3
|
+
Holds the current policy thresholds (pulled from the control plane via
|
|
4
|
+
directive envelope) and evaluates them on every LLM call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from flightdeck_sensor.core.types import PolicyDecision
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PolicyCache:
|
|
16
|
+
"""Thread-safe local cache of the token budget policy.
|
|
17
|
+
|
|
18
|
+
``check()`` is called on every LLM call before and after the actual
|
|
19
|
+
provider request. It is deliberately cheap -- all data is in memory,
|
|
20
|
+
no I/O, no locking beyond a fast ``threading.Lock`` for the fire-once
|
|
21
|
+
flag.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
token_limit: int | None = None,
|
|
27
|
+
warn_at_pct: int = 80,
|
|
28
|
+
degrade_at_pct: int = 90,
|
|
29
|
+
block_at_pct: int = 100,
|
|
30
|
+
degrade_to: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.token_limit = token_limit
|
|
33
|
+
self.warn_at_pct = warn_at_pct
|
|
34
|
+
self.degrade_at_pct = degrade_at_pct
|
|
35
|
+
self.block_at_pct = block_at_pct
|
|
36
|
+
self.degrade_to = degrade_to
|
|
37
|
+
|
|
38
|
+
self._warned = False
|
|
39
|
+
self._lock = threading.Lock()
|
|
40
|
+
|
|
41
|
+
def check(self, tokens_used: int, estimated: int) -> PolicyDecision:
|
|
42
|
+
"""Evaluate thresholds against *tokens_used* + *estimated*.
|
|
43
|
+
|
|
44
|
+
Returns the highest-severity decision that applies:
|
|
45
|
+
|
|
46
|
+
* :attr:`PolicyDecision.BLOCK` -- budget exhausted, call must not
|
|
47
|
+
proceed.
|
|
48
|
+
* :attr:`PolicyDecision.DEGRADE` -- budget nearly exhausted, swap
|
|
49
|
+
to a cheaper model.
|
|
50
|
+
* :attr:`PolicyDecision.WARN` -- approaching limit (fires once
|
|
51
|
+
per session).
|
|
52
|
+
* :attr:`PolicyDecision.ALLOW` -- under all thresholds.
|
|
53
|
+
"""
|
|
54
|
+
if self.token_limit is None:
|
|
55
|
+
return PolicyDecision.ALLOW
|
|
56
|
+
|
|
57
|
+
projected = tokens_used + estimated
|
|
58
|
+
pct = (projected * 100) // self.token_limit
|
|
59
|
+
|
|
60
|
+
if pct >= self.block_at_pct:
|
|
61
|
+
return PolicyDecision.BLOCK
|
|
62
|
+
|
|
63
|
+
if pct >= self.degrade_at_pct:
|
|
64
|
+
return PolicyDecision.DEGRADE
|
|
65
|
+
|
|
66
|
+
if pct >= self.warn_at_pct:
|
|
67
|
+
with self._lock:
|
|
68
|
+
if not self._warned:
|
|
69
|
+
self._warned = True
|
|
70
|
+
return PolicyDecision.WARN
|
|
71
|
+
return PolicyDecision.ALLOW
|
|
72
|
+
|
|
73
|
+
return PolicyDecision.ALLOW
|
|
74
|
+
|
|
75
|
+
def update(self, policy_dict: dict[str, Any]) -> None:
|
|
76
|
+
"""Atomically replace all fields from a directive payload."""
|
|
77
|
+
self.token_limit = policy_dict.get("token_limit", self.token_limit)
|
|
78
|
+
self.warn_at_pct = policy_dict.get("warn_at_pct", self.warn_at_pct)
|
|
79
|
+
self.degrade_at_pct = policy_dict.get("degrade_at_pct", self.degrade_at_pct)
|
|
80
|
+
self.block_at_pct = policy_dict.get("block_at_pct", self.block_at_pct)
|
|
81
|
+
self.degrade_to = policy_dict.get("degrade_to", self.degrade_to)
|
|
82
|
+
# Reset warn flag when policy changes
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._warned = False
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Session lifecycle management for flightdeck-sensor.
|
|
2
|
+
|
|
3
|
+
A ``Session`` represents one running instance of an agent. It holds the
|
|
4
|
+
sensor configuration, manages the heartbeat daemon thread, registers
|
|
5
|
+
process-exit handlers, and posts lifecycle events to the control plane.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from flightdeck_sensor.core.policy import PolicyCache
|
|
20
|
+
from flightdeck_sensor.core.types import (
|
|
21
|
+
Directive,
|
|
22
|
+
DirectiveAction,
|
|
23
|
+
EventType,
|
|
24
|
+
SensorConfig,
|
|
25
|
+
SessionState,
|
|
26
|
+
StatusResponse,
|
|
27
|
+
TokenUsage,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from flightdeck_sensor.transport.client import ControlPlaneClient
|
|
32
|
+
|
|
33
|
+
_log = logging.getLogger("flightdeck_sensor.core.session")
|
|
34
|
+
|
|
35
|
+
_HEARTBEAT_INTERVAL_SECS = 30
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Session:
|
|
39
|
+
"""Manages the lifecycle of a single sensor session."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: SensorConfig,
|
|
44
|
+
client: ControlPlaneClient,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.config = config
|
|
47
|
+
self.client = client
|
|
48
|
+
self.policy = PolicyCache()
|
|
49
|
+
|
|
50
|
+
self._state = SessionState.ACTIVE
|
|
51
|
+
self._tokens_used = 0
|
|
52
|
+
self._token_limit: int | None = None
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
self._stopped = threading.Event()
|
|
56
|
+
self._heartbeat_thread: threading.Thread | None = None
|
|
57
|
+
self._host = socket.gethostname()
|
|
58
|
+
self._framework: str | None = None
|
|
59
|
+
self._model: str | None = None
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# Public API
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def start(self) -> None:
|
|
66
|
+
"""Fire SESSION_START and begin the heartbeat daemon thread."""
|
|
67
|
+
self._post_event(EventType.SESSION_START)
|
|
68
|
+
self._start_heartbeat()
|
|
69
|
+
self._register_handlers()
|
|
70
|
+
if not self.config.quiet:
|
|
71
|
+
_log.info(
|
|
72
|
+
"Flightdeck session started: flavor=%s session=%s",
|
|
73
|
+
self.config.agent_flavor,
|
|
74
|
+
self.config.session_id,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def end(self) -> None:
|
|
78
|
+
"""Fire SESSION_END and stop the heartbeat thread.
|
|
79
|
+
|
|
80
|
+
Safe to call multiple times -- second call is a no-op.
|
|
81
|
+
"""
|
|
82
|
+
if self._state == SessionState.CLOSED:
|
|
83
|
+
return
|
|
84
|
+
self._state = SessionState.CLOSED
|
|
85
|
+
self._stopped.set()
|
|
86
|
+
if self._heartbeat_thread is not None:
|
|
87
|
+
self._heartbeat_thread.join(timeout=5)
|
|
88
|
+
self._post_event(EventType.SESSION_END)
|
|
89
|
+
self.client.close()
|
|
90
|
+
if not self.config.quiet:
|
|
91
|
+
_log.info(
|
|
92
|
+
"Flightdeck session ended: session=%s tokens=%d",
|
|
93
|
+
self.config.session_id,
|
|
94
|
+
self._tokens_used,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def record_usage(self, usage: TokenUsage) -> None:
|
|
98
|
+
"""Atomically increment session token counts."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._tokens_used += usage.total
|
|
101
|
+
|
|
102
|
+
def record_model(self, model: str) -> None:
|
|
103
|
+
"""Record the model used in the most recent call."""
|
|
104
|
+
self._model = model
|
|
105
|
+
|
|
106
|
+
def record_framework(self, framework: str) -> None:
|
|
107
|
+
"""Record the framework if detected."""
|
|
108
|
+
self._framework = framework
|
|
109
|
+
|
|
110
|
+
def post_call_event(
|
|
111
|
+
self,
|
|
112
|
+
event_type: EventType,
|
|
113
|
+
usage: TokenUsage,
|
|
114
|
+
model: str,
|
|
115
|
+
latency_ms: int,
|
|
116
|
+
tool_name: str | None = None,
|
|
117
|
+
) -> Directive | None:
|
|
118
|
+
"""Post a call event and return any received directive."""
|
|
119
|
+
self.record_usage(usage)
|
|
120
|
+
self.record_model(model)
|
|
121
|
+
return self._post_event(
|
|
122
|
+
event_type,
|
|
123
|
+
model=model,
|
|
124
|
+
tokens_input=usage.input_tokens,
|
|
125
|
+
tokens_output=usage.output_tokens,
|
|
126
|
+
tokens_total=usage.total,
|
|
127
|
+
latency_ms=latency_ms,
|
|
128
|
+
tool_name=tool_name,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def get_status(self) -> StatusResponse:
|
|
132
|
+
"""Build a status snapshot of the current session."""
|
|
133
|
+
with self._lock:
|
|
134
|
+
tokens = self._tokens_used
|
|
135
|
+
limit = self._token_limit
|
|
136
|
+
pct: float | None = None
|
|
137
|
+
if limit is not None and limit > 0:
|
|
138
|
+
pct = round((tokens / limit) * 100, 1)
|
|
139
|
+
return StatusResponse(
|
|
140
|
+
session_id=self.config.session_id,
|
|
141
|
+
flavor=self.config.agent_flavor,
|
|
142
|
+
agent_type=self.config.agent_type,
|
|
143
|
+
state=self._state,
|
|
144
|
+
tokens_used=tokens,
|
|
145
|
+
token_limit=limit,
|
|
146
|
+
pct_used=pct,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def state(self) -> SessionState:
|
|
151
|
+
return self._state
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def tokens_used(self) -> int:
|
|
155
|
+
with self._lock:
|
|
156
|
+
return self._tokens_used
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def token_limit(self) -> int | None:
|
|
160
|
+
return self._token_limit
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Heartbeat
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def _start_heartbeat(self) -> None:
|
|
167
|
+
self._heartbeat_thread = threading.Thread(
|
|
168
|
+
target=self._heartbeat_loop,
|
|
169
|
+
daemon=True,
|
|
170
|
+
name="flightdeck-heartbeat",
|
|
171
|
+
)
|
|
172
|
+
self._heartbeat_thread.start()
|
|
173
|
+
|
|
174
|
+
def _heartbeat_loop(self) -> None:
|
|
175
|
+
"""Daemon thread: post heartbeat every 30 s until stopped."""
|
|
176
|
+
while not self._stopped.wait(timeout=_HEARTBEAT_INTERVAL_SECS):
|
|
177
|
+
directive = self.client.post_heartbeat(self.config.session_id)
|
|
178
|
+
if directive is not None:
|
|
179
|
+
self._apply_directive(directive)
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Event posting
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _post_event(
|
|
186
|
+
self,
|
|
187
|
+
event_type: EventType,
|
|
188
|
+
**extra: Any,
|
|
189
|
+
) -> Directive | None:
|
|
190
|
+
"""Build the full event payload and POST it to the control plane."""
|
|
191
|
+
payload = self._build_payload(event_type, **extra)
|
|
192
|
+
directive = self.client.post_event(payload)
|
|
193
|
+
if directive is not None:
|
|
194
|
+
self._apply_directive(directive)
|
|
195
|
+
return directive
|
|
196
|
+
|
|
197
|
+
def _build_payload(
|
|
198
|
+
self,
|
|
199
|
+
event_type: EventType,
|
|
200
|
+
**extra: Any,
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
with self._lock:
|
|
203
|
+
tokens_used_session = self._tokens_used
|
|
204
|
+
|
|
205
|
+
payload: dict[str, Any] = {
|
|
206
|
+
"session_id": self.config.session_id,
|
|
207
|
+
"flavor": self.config.agent_flavor,
|
|
208
|
+
"agent_type": self.config.agent_type,
|
|
209
|
+
"event_type": event_type.value,
|
|
210
|
+
"host": self._host,
|
|
211
|
+
"framework": self._framework,
|
|
212
|
+
"model": self._model,
|
|
213
|
+
"tokens_input": None,
|
|
214
|
+
"tokens_output": None,
|
|
215
|
+
"tokens_total": None,
|
|
216
|
+
"tokens_used_session": tokens_used_session,
|
|
217
|
+
"token_limit_session": self._token_limit,
|
|
218
|
+
"latency_ms": None,
|
|
219
|
+
"tool_name": None,
|
|
220
|
+
"tool_input": None,
|
|
221
|
+
"tool_result": None,
|
|
222
|
+
"has_content": False,
|
|
223
|
+
"content": None,
|
|
224
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
225
|
+
}
|
|
226
|
+
payload.update(extra)
|
|
227
|
+
return payload
|
|
228
|
+
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# Directives
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def _apply_directive(self, directive: Directive) -> None:
|
|
234
|
+
"""Handle a directive received from the control plane."""
|
|
235
|
+
if directive.action == DirectiveAction.POLICY_UPDATE:
|
|
236
|
+
_log.info("Policy update received")
|
|
237
|
+
elif directive.action in (
|
|
238
|
+
DirectiveAction.SHUTDOWN,
|
|
239
|
+
DirectiveAction.SHUTDOWN_FLAVOR,
|
|
240
|
+
):
|
|
241
|
+
_log.warning(
|
|
242
|
+
"Shutdown directive received: %s (reason: %s)",
|
|
243
|
+
directive.action.value,
|
|
244
|
+
directive.reason,
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
_log.info("Directive received: %s", directive.action.value)
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Process exit handlers
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def _register_handlers(self) -> None:
|
|
254
|
+
"""Register atexit and signal handlers for clean shutdown."""
|
|
255
|
+
atexit.register(self.end)
|
|
256
|
+
# Only register signal handlers on the main thread
|
|
257
|
+
if threading.current_thread() is threading.main_thread():
|
|
258
|
+
self._register_signal(signal.SIGTERM)
|
|
259
|
+
if os.name != "nt":
|
|
260
|
+
self._register_signal(signal.SIGINT)
|
|
261
|
+
|
|
262
|
+
def _register_signal(self, sig: signal.Signals) -> None:
|
|
263
|
+
"""Install a signal handler that calls end() and re-raises."""
|
|
264
|
+
prev = signal.getsignal(sig)
|
|
265
|
+
|
|
266
|
+
def _handler(signum: int, frame: Any) -> None:
|
|
267
|
+
self.end()
|
|
268
|
+
if callable(prev):
|
|
269
|
+
prev(signum, frame)
|
|
270
|
+
|
|
271
|
+
signal.signal(sig, _handler)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Pure data types for flightdeck-sensor. No external dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionState(enum.Enum):
|
|
11
|
+
"""Lifecycle state of a sensor session."""
|
|
12
|
+
|
|
13
|
+
ACTIVE = "active"
|
|
14
|
+
IDLE = "idle"
|
|
15
|
+
STALE = "stale"
|
|
16
|
+
CLOSED = "closed"
|
|
17
|
+
LOST = "lost"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventType(enum.Enum):
|
|
21
|
+
"""All event types the sensor can emit."""
|
|
22
|
+
|
|
23
|
+
SESSION_START = "session_start"
|
|
24
|
+
SESSION_END = "session_end"
|
|
25
|
+
HEARTBEAT = "heartbeat"
|
|
26
|
+
PRE_CALL = "pre_call"
|
|
27
|
+
POST_CALL = "post_call"
|
|
28
|
+
TOOL_CALL = "tool_call"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DirectiveAction(enum.Enum):
|
|
32
|
+
"""Actions the control plane can instruct the sensor to take."""
|
|
33
|
+
|
|
34
|
+
SHUTDOWN = "shutdown"
|
|
35
|
+
SHUTDOWN_FLAVOR = "shutdown_flavor"
|
|
36
|
+
DEGRADE = "degrade"
|
|
37
|
+
THROTTLE = "throttle"
|
|
38
|
+
POLICY_UPDATE = "policy_update"
|
|
39
|
+
CHECKPOINT = "checkpoint"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PolicyDecision(enum.Enum):
|
|
43
|
+
"""Result of a policy check against current token usage."""
|
|
44
|
+
|
|
45
|
+
ALLOW = "allow"
|
|
46
|
+
WARN = "warn"
|
|
47
|
+
DEGRADE = "degrade"
|
|
48
|
+
BLOCK = "block"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class TokenUsage:
|
|
53
|
+
"""Token counts from a single LLM call."""
|
|
54
|
+
|
|
55
|
+
input_tokens: int = 0
|
|
56
|
+
output_tokens: int = 0
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def total(self) -> int:
|
|
60
|
+
return self.input_tokens + self.output_tokens
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class SensorConfig:
|
|
65
|
+
"""Configuration for a sensor session."""
|
|
66
|
+
|
|
67
|
+
server: str
|
|
68
|
+
token: str
|
|
69
|
+
capture_prompts: bool = False
|
|
70
|
+
unavailable_policy: str = "continue"
|
|
71
|
+
agent_flavor: str = "unknown"
|
|
72
|
+
agent_type: str = "autonomous"
|
|
73
|
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
74
|
+
quiet: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class Directive:
|
|
79
|
+
"""A control-plane directive received in the event response envelope."""
|
|
80
|
+
|
|
81
|
+
action: DirectiveAction
|
|
82
|
+
reason: str
|
|
83
|
+
grace_period_ms: int = 5000
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class StatusResponse:
|
|
88
|
+
"""Current status of the sensor session, returned by get_status()."""
|
|
89
|
+
|
|
90
|
+
session_id: str
|
|
91
|
+
flavor: str
|
|
92
|
+
agent_type: str
|
|
93
|
+
state: SessionState
|
|
94
|
+
tokens_used: int
|
|
95
|
+
token_limit: int | None
|
|
96
|
+
pct_used: float | None
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Anthropic provider: token estimation and usage extraction.
|
|
2
|
+
|
|
3
|
+
Reimplements the patterns from tokencap -- this is a standalone
|
|
4
|
+
implementation with no dependency on tokencap.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from flightdeck_sensor.core.types import TokenUsage
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from flightdeck_sensor.providers.protocol import PromptContent
|
|
17
|
+
|
|
18
|
+
_log = logging.getLogger("flightdeck_sensor.providers.anthropic")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AnthropicProvider:
|
|
22
|
+
"""Provider adapter for the Anthropic Python SDK.
|
|
23
|
+
|
|
24
|
+
All methods are safe to call on the hot path. None of them raise
|
|
25
|
+
exceptions -- failures return zero/empty defaults.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, capture_prompts: bool = False) -> None:
|
|
29
|
+
self._capture_prompts = capture_prompts
|
|
30
|
+
|
|
31
|
+
def estimate_tokens(self, request_kwargs: dict[str, Any]) -> int:
|
|
32
|
+
"""Estimate input tokens using a character-based heuristic.
|
|
33
|
+
|
|
34
|
+
Uses ``len(str(messages)) // 4`` as a conservative approximation.
|
|
35
|
+
The actual token count is reconciled after the call via
|
|
36
|
+
``extract_usage()``.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
messages = request_kwargs.get("messages", [])
|
|
40
|
+
system = request_kwargs.get("system", "")
|
|
41
|
+
tools = request_kwargs.get("tools", [])
|
|
42
|
+
text = str(messages) + str(system) + str(tools)
|
|
43
|
+
return len(text) // 4
|
|
44
|
+
except Exception:
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
def extract_usage(self, response: Any) -> TokenUsage:
|
|
48
|
+
"""Extract actual token counts from an Anthropic response.
|
|
49
|
+
|
|
50
|
+
Handles both sync ``Message`` objects and raw response wrappers.
|
|
51
|
+
Returns ``TokenUsage(0, 0)`` on any failure -- never raises.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
obj = response
|
|
55
|
+
# Handle raw response wrappers
|
|
56
|
+
if hasattr(obj, "parse") and callable(obj.parse):
|
|
57
|
+
with contextlib.suppress(Exception):
|
|
58
|
+
obj = obj.parse()
|
|
59
|
+
|
|
60
|
+
usage = getattr(obj, "usage", None)
|
|
61
|
+
if usage is None:
|
|
62
|
+
return TokenUsage(input_tokens=0, output_tokens=0)
|
|
63
|
+
|
|
64
|
+
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
65
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
66
|
+
|
|
67
|
+
# Include cache tokens in input count for accurate totals
|
|
68
|
+
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
69
|
+
cache_write = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
70
|
+
|
|
71
|
+
return TokenUsage(
|
|
72
|
+
input_tokens=input_tokens + cache_read + cache_write,
|
|
73
|
+
output_tokens=output_tokens,
|
|
74
|
+
)
|
|
75
|
+
except Exception:
|
|
76
|
+
return TokenUsage(input_tokens=0, output_tokens=0)
|
|
77
|
+
|
|
78
|
+
def extract_content(
|
|
79
|
+
self,
|
|
80
|
+
request_kwargs: dict[str, Any],
|
|
81
|
+
response: Any,
|
|
82
|
+
) -> PromptContent | None:
|
|
83
|
+
"""Extract prompt content for storage.
|
|
84
|
+
|
|
85
|
+
Returns ``None`` in Phase 1 -- prompt capture is not implemented
|
|
86
|
+
until Phase 5.
|
|
87
|
+
"""
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def get_model(self, request_kwargs: dict[str, Any]) -> str:
|
|
91
|
+
"""Extract the model name from request kwargs."""
|
|
92
|
+
try:
|
|
93
|
+
model: str = request_kwargs["model"]
|
|
94
|
+
return model
|
|
95
|
+
except (KeyError, TypeError):
|
|
96
|
+
return ""
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""OpenAI provider: token estimation and usage extraction.
|
|
2
|
+
|
|
3
|
+
Reimplements the patterns from tokencap -- this is a standalone
|
|
4
|
+
implementation with no dependency on tokencap.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from flightdeck_sensor.core.types import TokenUsage
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from flightdeck_sensor.providers.protocol import PromptContent
|
|
17
|
+
|
|
18
|
+
_log = logging.getLogger("flightdeck_sensor.providers.openai")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _try_tiktoken_count(messages: list[Any], model: str) -> int | None:
|
|
22
|
+
"""Attempt to count tokens using tiktoken if installed.
|
|
23
|
+
|
|
24
|
+
Returns ``None`` if tiktoken is unavailable or fails.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
import tiktoken
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
enc = tiktoken.encoding_for_model(model)
|
|
31
|
+
except KeyError:
|
|
32
|
+
enc = tiktoken.get_encoding("cl100k_base")
|
|
33
|
+
|
|
34
|
+
total = 0
|
|
35
|
+
for msg in messages:
|
|
36
|
+
# Per-message overhead (role, content separators)
|
|
37
|
+
total += 4
|
|
38
|
+
if isinstance(msg, dict):
|
|
39
|
+
for value in msg.values():
|
|
40
|
+
total += len(enc.encode(str(value)))
|
|
41
|
+
total += 2 # reply priming
|
|
42
|
+
return total
|
|
43
|
+
except Exception:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenAIProvider:
|
|
48
|
+
"""Provider adapter for the OpenAI Python SDK.
|
|
49
|
+
|
|
50
|
+
All methods are safe to call on the hot path. None of them raise
|
|
51
|
+
exceptions -- failures return zero/empty defaults.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, capture_prompts: bool = False) -> None:
|
|
55
|
+
self._capture_prompts = capture_prompts
|
|
56
|
+
|
|
57
|
+
def estimate_tokens(self, request_kwargs: dict[str, Any]) -> int:
|
|
58
|
+
"""Estimate input tokens using tiktoken if available, else char//4.
|
|
59
|
+
|
|
60
|
+
tiktoken gives accurate counts for OpenAI models. The character
|
|
61
|
+
heuristic is a conservative fallback.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
messages = request_kwargs.get("messages", [])
|
|
65
|
+
model = request_kwargs.get("model", "")
|
|
66
|
+
|
|
67
|
+
# Try tiktoken first
|
|
68
|
+
count = _try_tiktoken_count(messages, model)
|
|
69
|
+
if count is not None:
|
|
70
|
+
return count
|
|
71
|
+
|
|
72
|
+
# Fallback: character-based heuristic
|
|
73
|
+
tools = request_kwargs.get("tools", [])
|
|
74
|
+
text = str(messages) + str(tools)
|
|
75
|
+
return len(text) // 4
|
|
76
|
+
except Exception:
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
def extract_usage(self, response: Any) -> TokenUsage:
|
|
80
|
+
"""Extract actual token counts from an OpenAI response.
|
|
81
|
+
|
|
82
|
+
Handles both sync ``ChatCompletion`` objects and raw wrappers.
|
|
83
|
+
Returns ``TokenUsage(0, 0)`` on any failure -- never raises.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
obj = response
|
|
87
|
+
# Handle raw response wrappers
|
|
88
|
+
if hasattr(obj, "parse") and callable(obj.parse):
|
|
89
|
+
with contextlib.suppress(Exception):
|
|
90
|
+
obj = obj.parse()
|
|
91
|
+
|
|
92
|
+
usage = getattr(obj, "usage", None)
|
|
93
|
+
if usage is None:
|
|
94
|
+
return TokenUsage(input_tokens=0, output_tokens=0)
|
|
95
|
+
|
|
96
|
+
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
|
97
|
+
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
|
|
98
|
+
|
|
99
|
+
return TokenUsage(
|
|
100
|
+
input_tokens=prompt_tokens,
|
|
101
|
+
output_tokens=completion_tokens,
|
|
102
|
+
)
|
|
103
|
+
except Exception:
|
|
104
|
+
return TokenUsage(input_tokens=0, output_tokens=0)
|
|
105
|
+
|
|
106
|
+
def extract_content(
|
|
107
|
+
self,
|
|
108
|
+
request_kwargs: dict[str, Any],
|
|
109
|
+
response: Any,
|
|
110
|
+
) -> PromptContent | None:
|
|
111
|
+
"""Extract prompt content for storage.
|
|
112
|
+
|
|
113
|
+
Returns ``None`` in Phase 1 -- prompt capture is not implemented
|
|
114
|
+
until Phase 5.
|
|
115
|
+
"""
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def get_model(self, request_kwargs: dict[str, Any]) -> str:
|
|
119
|
+
"""Extract the model name from request kwargs."""
|
|
120
|
+
try:
|
|
121
|
+
model: str = request_kwargs["model"]
|
|
122
|
+
return model
|
|
123
|
+
except (KeyError, TypeError):
|
|
124
|
+
return ""
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Provider protocol and shared content dataclass."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from flightdeck_sensor.core.types import TokenUsage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Provider(Protocol):
|
|
13
|
+
"""Interface every LLM provider adapter must implement.
|
|
14
|
+
|
|
15
|
+
All methods must be safe to call on the hot path. None of them
|
|
16
|
+
may raise exceptions -- failures return zero/empty defaults.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def estimate_tokens(self, request_kwargs: dict[str, Any]) -> int:
|
|
20
|
+
"""Estimate input token count before the call. Never raises."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def extract_usage(self, response: Any) -> TokenUsage:
|
|
24
|
+
"""Extract actual token counts from the provider response. Never raises."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def extract_content(
|
|
28
|
+
self,
|
|
29
|
+
request_kwargs: dict[str, Any],
|
|
30
|
+
response: Any,
|
|
31
|
+
) -> PromptContent | None:
|
|
32
|
+
"""Extract prompt content when capture_prompts is enabled.
|
|
33
|
+
|
|
34
|
+
Returns None when capture is disabled or on any error.
|
|
35
|
+
Never raises.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def get_model(self, request_kwargs: dict[str, Any]) -> str:
|
|
40
|
+
"""Extract the model name from request kwargs. Returns '' on failure."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PromptContent:
|
|
46
|
+
"""Raw content extracted from a single LLM call.
|
|
47
|
+
|
|
48
|
+
Provider terminology is preserved exactly -- no normalization.
|
|
49
|
+
Anthropic uses 'system' as a separate field; OpenAI embeds it in messages.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
system: str | None
|
|
53
|
+
messages: list[dict[str, Any]]
|
|
54
|
+
tools: list[dict[str, Any]] | None
|
|
55
|
+
response: dict[str, Any]
|
|
56
|
+
provider: str
|
|
57
|
+
model: str
|
|
58
|
+
session_id: str
|
|
59
|
+
event_id: str
|
|
60
|
+
captured_at: str
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""HTTP transport to the Flightdeck control plane.
|
|
2
|
+
|
|
3
|
+
Uses only the Python standard library (``urllib.request``).
|
|
4
|
+
No ``requests``, no ``httpx`` -- zero required dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
|
|
15
|
+
from flightdeck_sensor.core.exceptions import DirectiveError
|
|
16
|
+
from flightdeck_sensor.core.types import Directive, DirectiveAction
|
|
17
|
+
from flightdeck_sensor.transport.retry import with_retry
|
|
18
|
+
|
|
19
|
+
_log = logging.getLogger("flightdeck_sensor.transport.client")
|
|
20
|
+
|
|
21
|
+
_TIMEOUT_SECS = 10
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ControlPlaneClient:
|
|
25
|
+
"""Fire-and-forget HTTP client for sensor → control plane communication.
|
|
26
|
+
|
|
27
|
+
On connectivity failure the behaviour depends on *unavailable_policy*:
|
|
28
|
+
|
|
29
|
+
* ``"continue"`` -- log a warning, return ``None``, agent proceeds.
|
|
30
|
+
* ``"halt"`` -- raise :class:`DirectiveError`, agent must stop.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
server: str,
|
|
36
|
+
token: str,
|
|
37
|
+
unavailable_policy: str = "continue",
|
|
38
|
+
) -> None:
|
|
39
|
+
self._base_url = server.rstrip("/")
|
|
40
|
+
self._token = token
|
|
41
|
+
self._unavailable_policy = unavailable_policy
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Public API
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def post_event(self, payload: dict[str, Any]) -> Directive | None:
|
|
48
|
+
"""POST an event to ``/v1/events`` and return any embedded directive."""
|
|
49
|
+
return self._post("/v1/events", payload)
|
|
50
|
+
|
|
51
|
+
def post_heartbeat(self, session_id: str) -> Directive | None:
|
|
52
|
+
"""POST a heartbeat to ``/v1/heartbeat``."""
|
|
53
|
+
return self._post("/v1/heartbeat", {"session_id": session_id})
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""No-op -- stdlib ``urllib`` has no persistent connection to close."""
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Internals
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _post(self, path: str, body: dict[str, Any]) -> Directive | None:
|
|
63
|
+
"""POST JSON and parse the response envelope for a directive."""
|
|
64
|
+
url = f"{self._base_url}{path}"
|
|
65
|
+
data = json.dumps(body).encode()
|
|
66
|
+
req = urllib.request.Request(
|
|
67
|
+
url,
|
|
68
|
+
data=data,
|
|
69
|
+
headers={
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
"Authorization": f"Bearer {self._token}",
|
|
72
|
+
},
|
|
73
|
+
method="POST",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
raw = with_retry(lambda: self._do_request(req))
|
|
78
|
+
except (URLError, OSError, TimeoutError, ConnectionError) as exc:
|
|
79
|
+
return self._handle_unavailable(exc)
|
|
80
|
+
|
|
81
|
+
return self._parse_directive(raw)
|
|
82
|
+
|
|
83
|
+
def _do_request(self, req: urllib.request.Request) -> dict[str, Any]:
|
|
84
|
+
"""Execute a single HTTP request and return the parsed JSON body.
|
|
85
|
+
|
|
86
|
+
Raises on HTTP 5xx so that :func:`with_retry` can retry.
|
|
87
|
+
HTTP 4xx is a caller bug -- log and return a neutral response.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT_SECS) as resp:
|
|
91
|
+
body: dict[str, Any] = json.loads(resp.read().decode())
|
|
92
|
+
return body
|
|
93
|
+
except HTTPError as exc:
|
|
94
|
+
if exc.code >= 500:
|
|
95
|
+
raise # let retry handle it
|
|
96
|
+
# 4xx -- caller bug, not retryable
|
|
97
|
+
_log.warning("Control plane returned HTTP %d: %s", exc.code, exc.reason)
|
|
98
|
+
return {"status": "ok", "directive": None}
|
|
99
|
+
|
|
100
|
+
def _handle_unavailable(self, exc: BaseException) -> Directive | None:
|
|
101
|
+
"""Apply the unavailability policy after exhausting retries."""
|
|
102
|
+
if self._unavailable_policy == "halt":
|
|
103
|
+
raise DirectiveError(
|
|
104
|
+
action="halt",
|
|
105
|
+
reason=f"Control plane unreachable: {exc}",
|
|
106
|
+
)
|
|
107
|
+
_log.warning("Control plane unreachable (policy=continue): %s", exc)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _parse_directive(body: dict[str, Any]) -> Directive | None:
|
|
112
|
+
"""Extract a :class:`Directive` from the response envelope, if present."""
|
|
113
|
+
raw = body.get("directive")
|
|
114
|
+
if raw is None:
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
return Directive(
|
|
118
|
+
action=DirectiveAction(raw["action"]),
|
|
119
|
+
reason=raw.get("reason", ""),
|
|
120
|
+
grace_period_ms=raw.get("grace_period_ms", 5000),
|
|
121
|
+
)
|
|
122
|
+
except (KeyError, ValueError) as exc:
|
|
123
|
+
_log.warning("Malformed directive in response: %s (%s)", raw, exc)
|
|
124
|
+
return None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Exponential backoff retry for transient HTTP failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Callable, TypeVar
|
|
8
|
+
from urllib.error import URLError
|
|
9
|
+
|
|
10
|
+
_T = TypeVar("_T")
|
|
11
|
+
|
|
12
|
+
_log = logging.getLogger("flightdeck_sensor.transport.retry")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def with_retry(
|
|
16
|
+
fn: Callable[[], _T],
|
|
17
|
+
*,
|
|
18
|
+
max_attempts: int = 3,
|
|
19
|
+
backoff_base: float = 0.5,
|
|
20
|
+
retryable: tuple[type[BaseException], ...] = (
|
|
21
|
+
ConnectionError,
|
|
22
|
+
TimeoutError,
|
|
23
|
+
URLError,
|
|
24
|
+
OSError,
|
|
25
|
+
),
|
|
26
|
+
) -> _T:
|
|
27
|
+
"""Execute *fn* with exponential backoff on transient failures.
|
|
28
|
+
|
|
29
|
+
Retries on connection errors, timeouts, and ``URLError``.
|
|
30
|
+
HTTP 5xx responses must be converted to exceptions by the caller
|
|
31
|
+
before reaching this function.
|
|
32
|
+
|
|
33
|
+
Raises the final exception unchanged after *max_attempts* failures.
|
|
34
|
+
"""
|
|
35
|
+
last_exc: BaseException | None = None
|
|
36
|
+
for attempt in range(max_attempts):
|
|
37
|
+
try:
|
|
38
|
+
return fn()
|
|
39
|
+
except retryable as exc:
|
|
40
|
+
last_exc = exc
|
|
41
|
+
if attempt < max_attempts - 1:
|
|
42
|
+
delay = backoff_base * (2**attempt)
|
|
43
|
+
_log.warning(
|
|
44
|
+
"Transient failure (attempt %d/%d), retrying in %.1fs: %s",
|
|
45
|
+
attempt + 1,
|
|
46
|
+
max_attempts,
|
|
47
|
+
delay,
|
|
48
|
+
exc,
|
|
49
|
+
)
|
|
50
|
+
time.sleep(delay)
|
|
51
|
+
# Should never reach here without last_exc being set, but mypy needs the assert.
|
|
52
|
+
assert last_exc is not None
|
|
53
|
+
raise last_exc
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flightdeck-sensor"
|
|
7
|
+
version = "0.1.0a1"
|
|
8
|
+
description = "In-process agent observability sensor for Flightdeck"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: Apache Software License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
anthropic = ["anthropic>=0.20"]
|
|
27
|
+
openai = ["openai>=1.0", "tiktoken>=0.5"]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=7.0",
|
|
30
|
+
"pytest-asyncio>=0.21",
|
|
31
|
+
"mypy>=1.8",
|
|
32
|
+
"ruff>=0.3",
|
|
33
|
+
"httpx>=0.25",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["flightdeck_sensor"]
|
|
38
|
+
|
|
39
|
+
[tool.mypy]
|
|
40
|
+
strict = true
|
|
41
|
+
warn_return_any = true
|
|
42
|
+
warn_unused_configs = true
|
|
43
|
+
disallow_untyped_defs = true
|
|
44
|
+
disallow_incomplete_defs = true
|
|
45
|
+
check_untyped_defs = true
|
|
46
|
+
disallow_any_generics = true
|
|
47
|
+
no_implicit_optional = true
|
|
48
|
+
warn_redundant_casts = true
|
|
49
|
+
warn_unused_ignores = true
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
target-version = "py39"
|
|
53
|
+
line-length = 100
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH"]
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
asyncio_mode = "auto"
|