proxilion 0.0.1__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.
- proxilion/__init__.py +136 -0
- proxilion/audit/__init__.py +133 -0
- proxilion/audit/base_exporters.py +527 -0
- proxilion/audit/compliance/__init__.py +130 -0
- proxilion/audit/compliance/base.py +457 -0
- proxilion/audit/compliance/eu_ai_act.py +603 -0
- proxilion/audit/compliance/iso27001.py +544 -0
- proxilion/audit/compliance/soc2.py +491 -0
- proxilion/audit/events.py +493 -0
- proxilion/audit/explainability.py +1173 -0
- proxilion/audit/exporters/__init__.py +58 -0
- proxilion/audit/exporters/aws_s3.py +636 -0
- proxilion/audit/exporters/azure_storage.py +608 -0
- proxilion/audit/exporters/cloud_base.py +468 -0
- proxilion/audit/exporters/gcp_storage.py +570 -0
- proxilion/audit/exporters/multi_exporter.py +498 -0
- proxilion/audit/hash_chain.py +652 -0
- proxilion/audit/logger.py +543 -0
- proxilion/caching/__init__.py +49 -0
- proxilion/caching/tool_cache.py +633 -0
- proxilion/context/__init__.py +73 -0
- proxilion/context/context_window.py +556 -0
- proxilion/context/message_history.py +505 -0
- proxilion/context/session.py +735 -0
- proxilion/contrib/__init__.py +51 -0
- proxilion/contrib/anthropic.py +609 -0
- proxilion/contrib/google.py +1012 -0
- proxilion/contrib/langchain.py +641 -0
- proxilion/contrib/mcp.py +893 -0
- proxilion/contrib/openai.py +646 -0
- proxilion/core.py +3058 -0
- proxilion/decorators.py +966 -0
- proxilion/engines/__init__.py +287 -0
- proxilion/engines/base.py +266 -0
- proxilion/engines/casbin_engine.py +412 -0
- proxilion/engines/opa_engine.py +493 -0
- proxilion/engines/simple.py +437 -0
- proxilion/exceptions.py +887 -0
- proxilion/guards/__init__.py +54 -0
- proxilion/guards/input_guard.py +522 -0
- proxilion/guards/output_guard.py +634 -0
- proxilion/observability/__init__.py +198 -0
- proxilion/observability/cost_tracker.py +866 -0
- proxilion/observability/hooks.py +683 -0
- proxilion/observability/metrics.py +798 -0
- proxilion/observability/session_cost_tracker.py +1063 -0
- proxilion/policies/__init__.py +67 -0
- proxilion/policies/base.py +304 -0
- proxilion/policies/builtin.py +486 -0
- proxilion/policies/registry.py +376 -0
- proxilion/providers/__init__.py +201 -0
- proxilion/providers/adapter.py +468 -0
- proxilion/providers/anthropic_adapter.py +330 -0
- proxilion/providers/gemini_adapter.py +391 -0
- proxilion/providers/openai_adapter.py +294 -0
- proxilion/py.typed +0 -0
- proxilion/resilience/__init__.py +81 -0
- proxilion/resilience/degradation.py +615 -0
- proxilion/resilience/fallback.py +555 -0
- proxilion/resilience/retry.py +554 -0
- proxilion/scheduling/__init__.py +57 -0
- proxilion/scheduling/priority_queue.py +419 -0
- proxilion/scheduling/scheduler.py +459 -0
- proxilion/security/__init__.py +244 -0
- proxilion/security/agent_trust.py +968 -0
- proxilion/security/behavioral_drift.py +794 -0
- proxilion/security/cascade_protection.py +869 -0
- proxilion/security/circuit_breaker.py +428 -0
- proxilion/security/cost_limiter.py +690 -0
- proxilion/security/idor_protection.py +460 -0
- proxilion/security/intent_capsule.py +849 -0
- proxilion/security/intent_validator.py +495 -0
- proxilion/security/memory_integrity.py +767 -0
- proxilion/security/rate_limiter.py +509 -0
- proxilion/security/scope_enforcer.py +680 -0
- proxilion/security/sequence_validator.py +636 -0
- proxilion/security/trust_boundaries.py +784 -0
- proxilion/streaming/__init__.py +70 -0
- proxilion/streaming/detector.py +761 -0
- proxilion/streaming/transformer.py +674 -0
- proxilion/timeouts/__init__.py +55 -0
- proxilion/timeouts/decorators.py +477 -0
- proxilion/timeouts/manager.py +545 -0
- proxilion/tools/__init__.py +69 -0
- proxilion/tools/decorators.py +493 -0
- proxilion/tools/registry.py +732 -0
- proxilion/types.py +339 -0
- proxilion/validation/__init__.py +93 -0
- proxilion/validation/pydantic_schema.py +351 -0
- proxilion/validation/schema.py +651 -0
- proxilion-0.0.1.dist-info/METADATA +872 -0
- proxilion-0.0.1.dist-info/RECORD +94 -0
- proxilion-0.0.1.dist-info/WHEEL +4 -0
- proxilion-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session tracking for AI agent conversations.
|
|
3
|
+
|
|
4
|
+
Provides session management with metadata, expiration, and
|
|
5
|
+
multi-turn conversation state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from proxilion.context.message_history import Message, MessageHistory, MessageRole
|
|
18
|
+
from proxilion.types import UserContext
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SessionState(Enum):
|
|
22
|
+
"""State of a session."""
|
|
23
|
+
|
|
24
|
+
ACTIVE = "active"
|
|
25
|
+
IDLE = "idle"
|
|
26
|
+
EXPIRED = "expired"
|
|
27
|
+
TERMINATED = "terminated"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class SessionConfig:
|
|
32
|
+
"""
|
|
33
|
+
Configuration for session management.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
max_duration: Maximum session duration in seconds. None for no limit.
|
|
37
|
+
max_idle_time: Maximum idle time before expiration in seconds.
|
|
38
|
+
max_messages: Maximum messages per session.
|
|
39
|
+
max_tokens: Maximum total tokens per session.
|
|
40
|
+
auto_cleanup: Whether to automatically cleanup expired sessions.
|
|
41
|
+
metadata_schema: Optional schema for validating session metadata.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
max_duration: int | None = 3600 # 1 hour default
|
|
45
|
+
max_idle_time: int | None = 900 # 15 minutes default
|
|
46
|
+
max_messages: int | None = 100
|
|
47
|
+
max_tokens: int | None = None
|
|
48
|
+
auto_cleanup: bool = True
|
|
49
|
+
metadata_schema: dict[str, Any] | None = None
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict[str, Any]:
|
|
52
|
+
"""Convert config to dictionary."""
|
|
53
|
+
return {
|
|
54
|
+
"max_duration": self.max_duration,
|
|
55
|
+
"max_idle_time": self.max_idle_time,
|
|
56
|
+
"max_messages": self.max_messages,
|
|
57
|
+
"max_tokens": self.max_tokens,
|
|
58
|
+
"auto_cleanup": self.auto_cleanup,
|
|
59
|
+
"metadata_schema": self.metadata_schema,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_dict(cls, data: dict[str, Any]) -> SessionConfig:
|
|
64
|
+
"""Create config from dictionary."""
|
|
65
|
+
return cls(
|
|
66
|
+
max_duration=data.get("max_duration", 3600),
|
|
67
|
+
max_idle_time=data.get("max_idle_time", 900),
|
|
68
|
+
max_messages=data.get("max_messages", 100),
|
|
69
|
+
max_tokens=data.get("max_tokens"),
|
|
70
|
+
auto_cleanup=data.get("auto_cleanup", True),
|
|
71
|
+
metadata_schema=data.get("metadata_schema"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Session:
|
|
77
|
+
"""
|
|
78
|
+
Individual session with state and message history.
|
|
79
|
+
|
|
80
|
+
Attributes:
|
|
81
|
+
session_id: Unique identifier for this session.
|
|
82
|
+
user: The user context for this session.
|
|
83
|
+
config: Session configuration.
|
|
84
|
+
created_at: When the session was created.
|
|
85
|
+
last_activity: Last activity timestamp.
|
|
86
|
+
state: Current session state.
|
|
87
|
+
metadata: Session metadata (e.g., agent info, preferences).
|
|
88
|
+
history: Message history for this session.
|
|
89
|
+
termination_reason: Reason for termination if terminated.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
session_id: str
|
|
93
|
+
user: UserContext
|
|
94
|
+
config: SessionConfig
|
|
95
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
96
|
+
last_activity: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
97
|
+
state: SessionState = SessionState.ACTIVE
|
|
98
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
99
|
+
history: MessageHistory = field(default=None) # type: ignore
|
|
100
|
+
termination_reason: str | None = None
|
|
101
|
+
_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
|
|
102
|
+
|
|
103
|
+
def __post_init__(self) -> None:
|
|
104
|
+
"""Initialize message history if not provided."""
|
|
105
|
+
if self.history is None:
|
|
106
|
+
self.history = MessageHistory(
|
|
107
|
+
max_messages=self.config.max_messages,
|
|
108
|
+
max_tokens=self.config.max_tokens,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def add_message(
|
|
112
|
+
self,
|
|
113
|
+
role: MessageRole,
|
|
114
|
+
content: str,
|
|
115
|
+
metadata: dict[str, Any] | None = None,
|
|
116
|
+
) -> Message:
|
|
117
|
+
"""
|
|
118
|
+
Add a message to the session history.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
role: The role of the message sender.
|
|
122
|
+
content: The message content.
|
|
123
|
+
metadata: Optional metadata for the message.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The created message.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
ValueError: If session is not active.
|
|
130
|
+
"""
|
|
131
|
+
with self._lock:
|
|
132
|
+
if self.state not in (SessionState.ACTIVE, SessionState.IDLE):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Cannot add message to session in state {self.state.value}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
message = Message(
|
|
138
|
+
role=role,
|
|
139
|
+
content=content,
|
|
140
|
+
metadata=metadata or {},
|
|
141
|
+
)
|
|
142
|
+
self.history.append(message)
|
|
143
|
+
self.touch()
|
|
144
|
+
return message
|
|
145
|
+
|
|
146
|
+
def get_messages(self, limit: int | None = None) -> list[Message]:
|
|
147
|
+
"""
|
|
148
|
+
Get messages from the session history.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
limit: Maximum number of messages to return (most recent).
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
List of messages.
|
|
155
|
+
"""
|
|
156
|
+
with self._lock:
|
|
157
|
+
if limit is not None:
|
|
158
|
+
return self.history.get_recent(limit)
|
|
159
|
+
return self.history.get_messages()
|
|
160
|
+
|
|
161
|
+
def get_context_for_llm(
|
|
162
|
+
self,
|
|
163
|
+
max_tokens: int | None = None,
|
|
164
|
+
provider: str = "openai",
|
|
165
|
+
) -> list[dict[str, Any]]:
|
|
166
|
+
"""
|
|
167
|
+
Get message history formatted for LLM API calls.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
max_tokens: Maximum tokens to include (truncates from start).
|
|
171
|
+
provider: The LLM provider format ("openai", "anthropic", "google").
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
List of message dictionaries for the LLM API.
|
|
175
|
+
"""
|
|
176
|
+
with self._lock:
|
|
177
|
+
if max_tokens is not None:
|
|
178
|
+
# Create a temporary history to apply truncation
|
|
179
|
+
temp_history = MessageHistory()
|
|
180
|
+
for msg in self.history.get_messages():
|
|
181
|
+
temp_history.append(
|
|
182
|
+
Message(
|
|
183
|
+
role=msg.role,
|
|
184
|
+
content=msg.content,
|
|
185
|
+
timestamp=msg.timestamp,
|
|
186
|
+
metadata=msg.metadata,
|
|
187
|
+
token_count=msg.token_count,
|
|
188
|
+
message_id=msg.message_id,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
temp_history.truncate_to_token_limit(max_tokens)
|
|
192
|
+
return temp_history.to_llm_format(provider)
|
|
193
|
+
|
|
194
|
+
return self.history.to_llm_format(provider)
|
|
195
|
+
|
|
196
|
+
def set_metadata(self, key: str, value: Any) -> None:
|
|
197
|
+
"""
|
|
198
|
+
Set a metadata value.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
key: The metadata key.
|
|
202
|
+
value: The metadata value.
|
|
203
|
+
"""
|
|
204
|
+
with self._lock:
|
|
205
|
+
self.metadata[key] = value
|
|
206
|
+
|
|
207
|
+
def get_metadata(self, key: str, default: Any = None) -> Any:
|
|
208
|
+
"""
|
|
209
|
+
Get a metadata value.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
key: The metadata key.
|
|
213
|
+
default: Default value if key not found.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
The metadata value or default.
|
|
217
|
+
"""
|
|
218
|
+
with self._lock:
|
|
219
|
+
return self.metadata.get(key, default)
|
|
220
|
+
|
|
221
|
+
def touch(self) -> None:
|
|
222
|
+
"""Update last activity time and set state to active."""
|
|
223
|
+
with self._lock:
|
|
224
|
+
self.last_activity = datetime.now(timezone.utc)
|
|
225
|
+
if self.state == SessionState.IDLE:
|
|
226
|
+
self.state = SessionState.ACTIVE
|
|
227
|
+
|
|
228
|
+
def is_expired(self) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
Check if the session has expired.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if session is expired, False otherwise.
|
|
234
|
+
"""
|
|
235
|
+
with self._lock:
|
|
236
|
+
if self.state in (SessionState.EXPIRED, SessionState.TERMINATED):
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
now = datetime.now(timezone.utc)
|
|
240
|
+
|
|
241
|
+
# Check max duration
|
|
242
|
+
if self.config.max_duration is not None:
|
|
243
|
+
duration = (now - self.created_at).total_seconds()
|
|
244
|
+
if duration > self.config.max_duration:
|
|
245
|
+
self.state = SessionState.EXPIRED
|
|
246
|
+
return True
|
|
247
|
+
|
|
248
|
+
# Check idle time
|
|
249
|
+
if self.config.max_idle_time is not None:
|
|
250
|
+
idle_time = (now - self.last_activity).total_seconds()
|
|
251
|
+
if idle_time > self.config.max_idle_time:
|
|
252
|
+
self.state = SessionState.EXPIRED
|
|
253
|
+
return True
|
|
254
|
+
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
def check_idle(self) -> bool:
|
|
258
|
+
"""
|
|
259
|
+
Check if session should be marked as idle.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
True if session is idle, False otherwise.
|
|
263
|
+
"""
|
|
264
|
+
with self._lock:
|
|
265
|
+
if self.state != SessionState.ACTIVE:
|
|
266
|
+
return self.state == SessionState.IDLE
|
|
267
|
+
|
|
268
|
+
if self.config.max_idle_time is not None:
|
|
269
|
+
now = datetime.now(timezone.utc)
|
|
270
|
+
idle_time = (now - self.last_activity).total_seconds()
|
|
271
|
+
# Mark as idle after half the max idle time
|
|
272
|
+
if idle_time > self.config.max_idle_time / 2:
|
|
273
|
+
self.state = SessionState.IDLE
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
def terminate(self, reason: str | None = None) -> None:
|
|
279
|
+
"""
|
|
280
|
+
Terminate the session.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
reason: Optional reason for termination.
|
|
284
|
+
"""
|
|
285
|
+
with self._lock:
|
|
286
|
+
self.state = SessionState.TERMINATED
|
|
287
|
+
self.termination_reason = reason
|
|
288
|
+
|
|
289
|
+
def get_duration(self) -> float:
|
|
290
|
+
"""
|
|
291
|
+
Get session duration in seconds.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Duration in seconds.
|
|
295
|
+
"""
|
|
296
|
+
with self._lock:
|
|
297
|
+
now = datetime.now(timezone.utc)
|
|
298
|
+
return (now - self.created_at).total_seconds()
|
|
299
|
+
|
|
300
|
+
def get_idle_time(self) -> float:
|
|
301
|
+
"""
|
|
302
|
+
Get time since last activity in seconds.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Idle time in seconds.
|
|
306
|
+
"""
|
|
307
|
+
with self._lock:
|
|
308
|
+
now = datetime.now(timezone.utc)
|
|
309
|
+
return (now - self.last_activity).total_seconds()
|
|
310
|
+
|
|
311
|
+
def get_remaining_duration(self) -> float | None:
|
|
312
|
+
"""
|
|
313
|
+
Get remaining session duration in seconds.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Remaining duration in seconds, or None if no limit.
|
|
317
|
+
"""
|
|
318
|
+
with self._lock:
|
|
319
|
+
if self.config.max_duration is None:
|
|
320
|
+
return None
|
|
321
|
+
elapsed = self.get_duration()
|
|
322
|
+
return max(0, self.config.max_duration - elapsed)
|
|
323
|
+
|
|
324
|
+
def to_dict(self) -> dict[str, Any]:
|
|
325
|
+
"""
|
|
326
|
+
Serialize session to dictionary.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Dictionary representation of the session.
|
|
330
|
+
"""
|
|
331
|
+
with self._lock:
|
|
332
|
+
return {
|
|
333
|
+
"session_id": self.session_id,
|
|
334
|
+
"user_id": self.user.user_id,
|
|
335
|
+
"user_roles": self.user.roles,
|
|
336
|
+
"config": self.config.to_dict(),
|
|
337
|
+
"created_at": self.created_at.isoformat(),
|
|
338
|
+
"last_activity": self.last_activity.isoformat(),
|
|
339
|
+
"state": self.state.value,
|
|
340
|
+
"metadata": self.metadata,
|
|
341
|
+
"history": self.history.to_dict(),
|
|
342
|
+
"termination_reason": self.termination_reason,
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def from_dict(cls, data: dict[str, Any]) -> Session:
|
|
347
|
+
"""
|
|
348
|
+
Deserialize session from dictionary.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
data: Dictionary with session data.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Session instance.
|
|
355
|
+
"""
|
|
356
|
+
user = UserContext(
|
|
357
|
+
user_id=data["user_id"],
|
|
358
|
+
roles=data.get("user_roles", []),
|
|
359
|
+
)
|
|
360
|
+
config = SessionConfig.from_dict(data.get("config", {}))
|
|
361
|
+
history = MessageHistory.from_dict(data.get("history", {}))
|
|
362
|
+
|
|
363
|
+
session = cls(
|
|
364
|
+
session_id=data["session_id"],
|
|
365
|
+
user=user,
|
|
366
|
+
config=config,
|
|
367
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
368
|
+
last_activity=datetime.fromisoformat(data["last_activity"]),
|
|
369
|
+
state=SessionState(data.get("state", "active")),
|
|
370
|
+
metadata=data.get("metadata", {}),
|
|
371
|
+
history=history,
|
|
372
|
+
termination_reason=data.get("termination_reason"),
|
|
373
|
+
)
|
|
374
|
+
return session
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class SessionManager:
|
|
378
|
+
"""
|
|
379
|
+
Manages multiple sessions with lifecycle management.
|
|
380
|
+
|
|
381
|
+
Provides session creation, retrieval, and cleanup for
|
|
382
|
+
multi-user agent applications.
|
|
383
|
+
|
|
384
|
+
Attributes:
|
|
385
|
+
config: Default configuration for new sessions.
|
|
386
|
+
cleanup_interval: Interval in seconds for automatic cleanup.
|
|
387
|
+
|
|
388
|
+
Example:
|
|
389
|
+
>>> from proxilion.types import UserContext
|
|
390
|
+
>>> config = SessionConfig(max_duration=3600, max_messages=100)
|
|
391
|
+
>>> manager = SessionManager(config)
|
|
392
|
+
>>> user = UserContext(user_id="user_123", roles=["user"])
|
|
393
|
+
>>> session = manager.create_session(user)
|
|
394
|
+
>>> session.add_message(MessageRole.USER, "Hello!")
|
|
395
|
+
>>> manager.get_session(session.session_id)
|
|
396
|
+
<Session ...>
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def __init__(
|
|
400
|
+
self,
|
|
401
|
+
config: SessionConfig | None = None,
|
|
402
|
+
cleanup_interval: int = 300,
|
|
403
|
+
) -> None:
|
|
404
|
+
"""
|
|
405
|
+
Initialize session manager.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
config: Default configuration for new sessions.
|
|
409
|
+
cleanup_interval: Interval in seconds for automatic cleanup.
|
|
410
|
+
"""
|
|
411
|
+
self.config = config or SessionConfig()
|
|
412
|
+
self.cleanup_interval = cleanup_interval
|
|
413
|
+
self._sessions: dict[str, Session] = {}
|
|
414
|
+
self._user_sessions: dict[str, list[str]] = {} # user_id -> session_ids
|
|
415
|
+
self._lock = threading.RLock()
|
|
416
|
+
self._last_cleanup = datetime.now(timezone.utc)
|
|
417
|
+
|
|
418
|
+
def create_session(
|
|
419
|
+
self,
|
|
420
|
+
user: UserContext,
|
|
421
|
+
session_id: str | None = None,
|
|
422
|
+
config: SessionConfig | None = None,
|
|
423
|
+
metadata: dict[str, Any] | None = None,
|
|
424
|
+
) -> Session:
|
|
425
|
+
"""
|
|
426
|
+
Create a new session for a user.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
user: The user context.
|
|
430
|
+
session_id: Optional session ID. Auto-generated if not provided.
|
|
431
|
+
config: Optional session-specific configuration.
|
|
432
|
+
metadata: Optional initial metadata.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
The created session.
|
|
436
|
+
"""
|
|
437
|
+
with self._lock:
|
|
438
|
+
if session_id is None:
|
|
439
|
+
session_id = str(uuid.uuid4())
|
|
440
|
+
|
|
441
|
+
# Use provided config or fall back to default
|
|
442
|
+
session_config = config or self.config
|
|
443
|
+
|
|
444
|
+
session = Session(
|
|
445
|
+
session_id=session_id,
|
|
446
|
+
user=user,
|
|
447
|
+
config=session_config,
|
|
448
|
+
metadata=metadata or {},
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
self._sessions[session_id] = session
|
|
452
|
+
|
|
453
|
+
# Track sessions by user
|
|
454
|
+
if user.user_id not in self._user_sessions:
|
|
455
|
+
self._user_sessions[user.user_id] = []
|
|
456
|
+
self._user_sessions[user.user_id].append(session_id)
|
|
457
|
+
|
|
458
|
+
# Run cleanup if needed
|
|
459
|
+
if self.config.auto_cleanup:
|
|
460
|
+
self._maybe_cleanup()
|
|
461
|
+
|
|
462
|
+
return session
|
|
463
|
+
|
|
464
|
+
def get_session(self, session_id: str) -> Session | None:
|
|
465
|
+
"""
|
|
466
|
+
Get a session by ID.
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
session_id: The session ID.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
The session if found and not expired, None otherwise.
|
|
473
|
+
"""
|
|
474
|
+
with self._lock:
|
|
475
|
+
session = self._sessions.get(session_id)
|
|
476
|
+
if session is None:
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
# Check expiration
|
|
480
|
+
if session.is_expired():
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
return session
|
|
484
|
+
|
|
485
|
+
def get_user_sessions(
|
|
486
|
+
self,
|
|
487
|
+
user_id: str,
|
|
488
|
+
include_expired: bool = False,
|
|
489
|
+
) -> list[Session]:
|
|
490
|
+
"""
|
|
491
|
+
Get all sessions for a user.
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
user_id: The user ID.
|
|
495
|
+
include_expired: Whether to include expired sessions.
|
|
496
|
+
|
|
497
|
+
Returns:
|
|
498
|
+
List of sessions for the user.
|
|
499
|
+
"""
|
|
500
|
+
with self._lock:
|
|
501
|
+
session_ids = self._user_sessions.get(user_id, [])
|
|
502
|
+
sessions: list[Session] = []
|
|
503
|
+
|
|
504
|
+
for sid in session_ids:
|
|
505
|
+
session = self._sessions.get(sid)
|
|
506
|
+
if session is None:
|
|
507
|
+
continue
|
|
508
|
+
if not include_expired and session.is_expired():
|
|
509
|
+
continue
|
|
510
|
+
sessions.append(session)
|
|
511
|
+
|
|
512
|
+
return sessions
|
|
513
|
+
|
|
514
|
+
def get_or_create_session(
|
|
515
|
+
self,
|
|
516
|
+
user: UserContext,
|
|
517
|
+
session_id: str | None = None,
|
|
518
|
+
config: SessionConfig | None = None,
|
|
519
|
+
) -> tuple[Session, bool]:
|
|
520
|
+
"""
|
|
521
|
+
Get an existing session or create a new one.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
user: The user context.
|
|
525
|
+
session_id: Optional session ID to look up.
|
|
526
|
+
config: Optional session configuration for creation.
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
Tuple of (session, created) where created is True if new.
|
|
530
|
+
"""
|
|
531
|
+
with self._lock:
|
|
532
|
+
if session_id is not None:
|
|
533
|
+
existing = self.get_session(session_id)
|
|
534
|
+
if existing is not None:
|
|
535
|
+
return existing, False
|
|
536
|
+
|
|
537
|
+
session = self.create_session(user, session_id, config)
|
|
538
|
+
return session, True
|
|
539
|
+
|
|
540
|
+
def terminate_session(
|
|
541
|
+
self,
|
|
542
|
+
session_id: str,
|
|
543
|
+
reason: str | None = None,
|
|
544
|
+
) -> bool:
|
|
545
|
+
"""
|
|
546
|
+
Terminate a session.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
session_id: The session ID.
|
|
550
|
+
reason: Optional reason for termination.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
True if session was found and terminated.
|
|
554
|
+
"""
|
|
555
|
+
with self._lock:
|
|
556
|
+
session = self._sessions.get(session_id)
|
|
557
|
+
if session is None:
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
session.terminate(reason)
|
|
561
|
+
return True
|
|
562
|
+
|
|
563
|
+
def terminate_user_sessions(
|
|
564
|
+
self,
|
|
565
|
+
user_id: str,
|
|
566
|
+
reason: str | None = None,
|
|
567
|
+
) -> int:
|
|
568
|
+
"""
|
|
569
|
+
Terminate all sessions for a user.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
user_id: The user ID.
|
|
573
|
+
reason: Optional reason for termination.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Number of sessions terminated.
|
|
577
|
+
"""
|
|
578
|
+
with self._lock:
|
|
579
|
+
sessions = self.get_user_sessions(user_id, include_expired=False)
|
|
580
|
+
count = 0
|
|
581
|
+
for session in sessions:
|
|
582
|
+
session.terminate(reason)
|
|
583
|
+
count += 1
|
|
584
|
+
return count
|
|
585
|
+
|
|
586
|
+
def cleanup_expired(self) -> int:
|
|
587
|
+
"""
|
|
588
|
+
Remove expired and terminated sessions.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Number of sessions removed.
|
|
592
|
+
"""
|
|
593
|
+
with self._lock:
|
|
594
|
+
to_remove: list[str] = []
|
|
595
|
+
|
|
596
|
+
for session_id, session in self._sessions.items():
|
|
597
|
+
if session.is_expired() or session.state == SessionState.TERMINATED:
|
|
598
|
+
to_remove.append(session_id)
|
|
599
|
+
|
|
600
|
+
for session_id in to_remove:
|
|
601
|
+
session = self._sessions.pop(session_id, None)
|
|
602
|
+
if session is not None:
|
|
603
|
+
# Remove from user sessions list
|
|
604
|
+
user_id = session.user.user_id
|
|
605
|
+
if user_id in self._user_sessions:
|
|
606
|
+
if session_id in self._user_sessions[user_id]:
|
|
607
|
+
self._user_sessions[user_id].remove(session_id)
|
|
608
|
+
|
|
609
|
+
self._last_cleanup = datetime.now(timezone.utc)
|
|
610
|
+
return len(to_remove)
|
|
611
|
+
|
|
612
|
+
def _maybe_cleanup(self) -> None:
|
|
613
|
+
"""Run cleanup if interval has passed."""
|
|
614
|
+
now = datetime.now(timezone.utc)
|
|
615
|
+
elapsed = (now - self._last_cleanup).total_seconds()
|
|
616
|
+
if elapsed >= self.cleanup_interval:
|
|
617
|
+
self.cleanup_expired()
|
|
618
|
+
|
|
619
|
+
def get_active_count(self) -> int:
|
|
620
|
+
"""
|
|
621
|
+
Get count of active (non-expired) sessions.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
Number of active sessions.
|
|
625
|
+
"""
|
|
626
|
+
with self._lock:
|
|
627
|
+
count = 0
|
|
628
|
+
for session in self._sessions.values():
|
|
629
|
+
if not session.is_expired() and session.state != SessionState.TERMINATED:
|
|
630
|
+
count += 1
|
|
631
|
+
return count
|
|
632
|
+
|
|
633
|
+
def get_total_count(self) -> int:
|
|
634
|
+
"""
|
|
635
|
+
Get total count of sessions (including expired).
|
|
636
|
+
|
|
637
|
+
Returns:
|
|
638
|
+
Total number of sessions.
|
|
639
|
+
"""
|
|
640
|
+
with self._lock:
|
|
641
|
+
return len(self._sessions)
|
|
642
|
+
|
|
643
|
+
def get_user_count(self) -> int:
|
|
644
|
+
"""
|
|
645
|
+
Get count of unique users with sessions.
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
Number of unique users.
|
|
649
|
+
"""
|
|
650
|
+
with self._lock:
|
|
651
|
+
return len(self._user_sessions)
|
|
652
|
+
|
|
653
|
+
def get_stats(self) -> dict[str, Any]:
|
|
654
|
+
"""
|
|
655
|
+
Get session statistics.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
Dictionary with session statistics.
|
|
659
|
+
"""
|
|
660
|
+
with self._lock:
|
|
661
|
+
active = 0
|
|
662
|
+
idle = 0
|
|
663
|
+
expired = 0
|
|
664
|
+
terminated = 0
|
|
665
|
+
|
|
666
|
+
for session in self._sessions.values():
|
|
667
|
+
session.is_expired() # Update state
|
|
668
|
+
if session.state == SessionState.ACTIVE:
|
|
669
|
+
active += 1
|
|
670
|
+
elif session.state == SessionState.IDLE:
|
|
671
|
+
idle += 1
|
|
672
|
+
elif session.state == SessionState.EXPIRED:
|
|
673
|
+
expired += 1
|
|
674
|
+
elif session.state == SessionState.TERMINATED:
|
|
675
|
+
terminated += 1
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
"total": len(self._sessions),
|
|
679
|
+
"active": active,
|
|
680
|
+
"idle": idle,
|
|
681
|
+
"expired": expired,
|
|
682
|
+
"terminated": terminated,
|
|
683
|
+
"unique_users": len(self._user_sessions),
|
|
684
|
+
"last_cleanup": self._last_cleanup.isoformat(),
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
def to_dict(self) -> dict[str, Any]:
|
|
688
|
+
"""
|
|
689
|
+
Serialize manager state to dictionary.
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
Dictionary representation of the manager.
|
|
693
|
+
"""
|
|
694
|
+
with self._lock:
|
|
695
|
+
return {
|
|
696
|
+
"config": self.config.to_dict(),
|
|
697
|
+
"cleanup_interval": self.cleanup_interval,
|
|
698
|
+
"sessions": {
|
|
699
|
+
sid: session.to_dict()
|
|
700
|
+
for sid, session in self._sessions.items()
|
|
701
|
+
},
|
|
702
|
+
"user_sessions": dict(self._user_sessions),
|
|
703
|
+
"last_cleanup": self._last_cleanup.isoformat(),
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
@classmethod
|
|
707
|
+
def from_dict(cls, data: dict[str, Any]) -> SessionManager:
|
|
708
|
+
"""
|
|
709
|
+
Deserialize manager from dictionary.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
data: Dictionary with manager data.
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
SessionManager instance.
|
|
716
|
+
"""
|
|
717
|
+
config = SessionConfig.from_dict(data.get("config", {}))
|
|
718
|
+
manager = cls(
|
|
719
|
+
config=config,
|
|
720
|
+
cleanup_interval=data.get("cleanup_interval", 300),
|
|
721
|
+
)
|
|
722
|
+
default_cleanup = datetime.now(timezone.utc).isoformat()
|
|
723
|
+
manager._last_cleanup = datetime.fromisoformat(
|
|
724
|
+
data.get("last_cleanup", default_cleanup)
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# Restore sessions
|
|
728
|
+
for session_data in data.get("sessions", {}).values():
|
|
729
|
+
session = Session.from_dict(session_data)
|
|
730
|
+
manager._sessions[session.session_id] = session
|
|
731
|
+
|
|
732
|
+
# Restore user sessions mapping
|
|
733
|
+
manager._user_sessions = data.get("user_sessions", {})
|
|
734
|
+
|
|
735
|
+
return manager
|