gateforge-sdk 0.2.3__tar.gz → 0.2.5__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.
Files changed (42) hide show
  1. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/PKG-INFO +2 -2
  2. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/__init__.py +18 -1
  3. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/context.py +29 -9
  4. gateforge_sdk-0.2.5/gateforge/session_manager.py +344 -0
  5. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/pyproject.toml +2 -2
  6. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/tests/test_phase1.py +57 -31
  7. gateforge_sdk-0.2.5/tests/test_phase2.py +528 -0
  8. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/.env.example +0 -0
  9. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/.gitignore +0 -0
  10. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/.pypirc.example +0 -0
  11. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/INSTALL.md +0 -0
  12. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/LICENSE +0 -0
  13. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/MANIFEST.in +0 -0
  14. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/PUBLISHING.md +0 -0
  15. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/PUBLISH_NOW.md +0 -0
  16. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/README.md +0 -0
  17. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/ab/__init__.py +0 -0
  18. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/ab/engine.py +0 -0
  19. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/client.py +0 -0
  20. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/config.py +0 -0
  21. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/features/__init__.py +0 -0
  22. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/guardrails/__init__.py +0 -0
  23. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/guardrails/engine.py +0 -0
  24. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/metrics.py +0 -0
  25. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/options.py +0 -0
  26. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/pii.py +0 -0
  27. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/pricing.py +0 -0
  28. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/prompt.py +0 -0
  29. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/providers/__init__.py +0 -0
  30. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/providers/anthropic.py +0 -0
  31. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/providers/gemini.py +0 -0
  32. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/providers/openai.py +0 -0
  33. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/response.py +0 -0
  34. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/tracing.py +0 -0
  35. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/wrappers/__init__.py +0 -0
  36. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/wrappers/anthropic.py +0 -0
  37. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/wrappers/gemini.py +0 -0
  38. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/gateforge/wrappers/openai.py +0 -0
  39. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/tests/__init__.py +0 -0
  40. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/tests/test_metrics.py +0 -0
  41. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/tests/test_pii.py +0 -0
  42. {gateforge_sdk-0.2.3 → gateforge_sdk-0.2.5}/tests/test_providers.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gateforge-sdk
3
- Version: 0.2.3
4
- Summary: Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session tracing, PII masking, cost tracking
3
+ Version: 0.2.5
4
+ Summary: Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session management, nested conversation support
5
5
  Project-URL: Homepage, https://gateforge.dev
6
6
  Project-URL: Documentation, https://gateforge.dev/docs
7
7
  Project-URL: Repository, https://github.com/gateforge/gateforge-sdk
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.5"
4
4
 
5
5
  # Auto-install spaCy model if missing (required for PII detection)
6
6
  def _ensure_spacy_model():
@@ -26,6 +26,15 @@ from gateforge.response import GatforgeResponse
26
26
  from gateforge.tracing import configure_otel
27
27
  from gateforge.ab.engine import VariantAssignment
28
28
  from gateforge.guardrails.engine import GuardrailViolation, GuardrailBlocked
29
+ from gateforge.session_manager import (
30
+ SessionManager,
31
+ SessionState,
32
+ get_session_manager,
33
+ create_session,
34
+ get_session,
35
+ get_current_conversation_id,
36
+ get_current_trace_info,
37
+ )
29
38
 
30
39
  _client: GatforgeClient | None = None
31
40
 
@@ -426,6 +435,14 @@ __all__ = [
426
435
  "agent",
427
436
  "record_tool_call",
428
437
  "record_tool_result",
438
+ # Session management (Phase 2)
439
+ "SessionManager",
440
+ "SessionState",
441
+ "get_session_manager",
442
+ "create_session",
443
+ "get_session",
444
+ "get_current_conversation_id",
445
+ "get_current_trace_info",
429
446
  # OTel configuration
430
447
  "configure_otel",
431
448
  # Standalone PII helpers
@@ -183,6 +183,9 @@ def agent(
183
183
  """
184
184
  Decorator that creates a trace context for an agent function.
185
185
 
186
+ If there's an active trace (e.g., from gateforge.session()), it uses that
187
+ conversation_id for proper nesting. Otherwise creates a new trace.
188
+
186
189
  All LLM calls and tool calls inside are automatically traced.
187
190
 
188
191
  Usage::
@@ -190,14 +193,27 @@ def agent(
190
193
  def my_agent(message):
191
194
  response = client.chat.completions.create(...)
192
195
  return response
196
+
197
+ # Or inside a session - shares the session's conversation_id:
198
+ with gateforge.session() as sess:
199
+ my_agent("hello") # Uses sess.conversation_id
193
200
  """
194
201
  def decorator(fn: Callable) -> Callable:
195
202
  @functools.wraps(fn)
196
203
  def wrapper(*args: Any, **kwargs: Any) -> Any:
197
204
  from gateforge.tracing import emit_trace_event
198
205
 
199
- # Generate or use provided conversation_id
200
- cid = conversation_id or f"{fn.__name__}-{int(time.time())}"
206
+ # Check if there's an active trace (e.g., from session())
207
+ active_trace = get_active_trace()
208
+
209
+ if active_trace:
210
+ # Use existing conversation_id for nesting
211
+ cid = active_trace.conversation_id
212
+ is_nested = True
213
+ else:
214
+ # Create new conversation_id
215
+ cid = conversation_id or f"{fn.__name__}-{int(time.time())}"
216
+ is_nested = False
201
217
 
202
218
  # Emit agent start event
203
219
  emit_trace_event(
@@ -206,12 +222,15 @@ def agent(
206
222
  conversation_id=cid,
207
223
  event_type="agent_start",
208
224
  step=0,
209
- metadata={"user_id": user_id, "args": _safe_str(args)[:200], "agent_name": name or fn.__name__},
225
+ metadata={"user_id": user_id, "args": _safe_str(args)[:200], "agent_name": name or fn.__name__, "nested": is_nested},
210
226
  )
211
227
 
212
- # Enter trace context
213
- ctx = trace(conversation_id=cid)
214
- ctx.__enter__()
228
+ # Enter trace context (only if not already in one)
229
+ if not is_nested:
230
+ ctx = trace(conversation_id=cid)
231
+ ctx.__enter__()
232
+ else:
233
+ ctx = None # Already in a trace context
215
234
 
216
235
  try:
217
236
  result = fn(*args, **kwargs)
@@ -222,7 +241,7 @@ def agent(
222
241
  base_url=_get_base_url(),
223
242
  conversation_id=cid,
224
243
  event_type="agent_end",
225
- step=ctx.ctx.step,
244
+ step=active_trace.step if active_trace else ctx.ctx.step,
226
245
  metadata={"success": True},
227
246
  )
228
247
 
@@ -234,12 +253,13 @@ def agent(
234
253
  base_url=_get_base_url(),
235
254
  conversation_id=cid,
236
255
  event_type="agent_error",
237
- step=ctx.ctx.step,
256
+ step=active_trace.step if active_trace else ctx.ctx.step,
238
257
  metadata={"error": str(e)[:500]},
239
258
  )
240
259
  raise
241
260
  finally:
242
- ctx.__exit__(None, None, None)
261
+ if ctx:
262
+ ctx.__exit__(None, None, None)
243
263
 
244
264
  return wrapper
245
265
  return decorator
@@ -0,0 +1,344 @@
1
+ """
2
+ Phase 2: Session Management & Enhanced Trace Context
3
+
4
+ Provides:
5
+ - SessionManager for persistent session state
6
+ - Enhanced metadata support (user_id, tags, custom fields)
7
+ - Session persistence (save/load from dict)
8
+ - Conversation helpers
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional, Dict, Any, List
17
+ from datetime import datetime
18
+
19
+
20
+ @dataclass
21
+ class SessionState:
22
+ """
23
+ Represents the state of a conversation session.
24
+
25
+ Attributes:
26
+ conversation_id: Unique identifier for the conversation
27
+ user_id: Optional user identifier
28
+ created_at: Unix timestamp when session was created
29
+ updated_at: Unix timestamp of last activity
30
+ message_count: Number of messages/turns in this conversation
31
+ tags: Optional tags for categorization
32
+ metadata: Custom metadata dictionary
33
+ """
34
+ conversation_id: str
35
+ user_id: Optional[str] = None
36
+ created_at: float = field(default_factory=time.time)
37
+ updated_at: float = field(default_factory=time.time)
38
+ message_count: int = 0
39
+ tags: List[str] = field(default_factory=list)
40
+ metadata: Dict[str, Any] = field(default_factory=dict)
41
+
42
+ def to_dict(self) -> Dict[str, Any]:
43
+ """Serialize session state to dictionary."""
44
+ return {
45
+ "conversation_id": self.conversation_id,
46
+ "user_id": self.user_id,
47
+ "created_at": self.created_at,
48
+ "updated_at": self.updated_at,
49
+ "message_count": self.message_count,
50
+ "tags": self.tags,
51
+ "metadata": self.metadata,
52
+ }
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: Dict[str, Any]) -> SessionState:
56
+ """Deserialize session state from dictionary."""
57
+ return cls(
58
+ conversation_id=data["conversation_id"],
59
+ user_id=data.get("user_id"),
60
+ created_at=data.get("created_at", time.time()),
61
+ updated_at=data.get("updated_at", time.time()),
62
+ message_count=data.get("message_count", 0),
63
+ tags=data.get("tags", []),
64
+ metadata=data.get("metadata", {}),
65
+ )
66
+
67
+ def touch(self) -> None:
68
+ """Update the updated_at timestamp and increment message count."""
69
+ self.updated_at = time.time()
70
+ self.message_count += 1
71
+
72
+ def add_tag(self, tag: str) -> None:
73
+ """Add a tag to the session."""
74
+ if tag not in self.tags:
75
+ self.tags.append(tag)
76
+
77
+ def set_metadata(self, key: str, value: Any) -> None:
78
+ """Set a metadata field."""
79
+ self.metadata[key] = value
80
+
81
+
82
+ class SessionManager:
83
+ """
84
+ Manages conversation sessions with persistence support.
85
+
86
+ Usage::
87
+ # Create manager
88
+ manager = SessionManager()
89
+
90
+ # Create new session
91
+ session = manager.create_session(user_id="user-123")
92
+ print(session.conversation_id)
93
+
94
+ # Get existing session
95
+ session = manager.get_session("conv-123")
96
+
97
+ # Update session activity
98
+ manager.touch("conv-123")
99
+
100
+ # Serialize all sessions
101
+ data = manager.to_dict()
102
+
103
+ # Load from serialized data
104
+ manager2 = SessionManager.from_dict(data)
105
+ """
106
+
107
+ def __init__(self):
108
+ self._sessions: Dict[str, SessionState] = {}
109
+
110
+ def create_session(
111
+ self,
112
+ user_id: Optional[str] = None,
113
+ conversation_id: Optional[str] = None,
114
+ tags: Optional[List[str]] = None,
115
+ metadata: Optional[Dict[str, Any]] = None,
116
+ ) -> SessionState:
117
+ """
118
+ Create a new session.
119
+
120
+ Args:
121
+ user_id: Optional user identifier
122
+ conversation_id: Optional explicit conversation_id (auto-generated if not provided)
123
+ tags: Optional list of tags
124
+ metadata: Optional custom metadata
125
+
126
+ Returns:
127
+ New SessionState object
128
+ """
129
+ cid = conversation_id or str(uuid.uuid4())
130
+
131
+ session = SessionState(
132
+ conversation_id=cid,
133
+ user_id=user_id,
134
+ tags=tags or [],
135
+ metadata=metadata or {},
136
+ )
137
+
138
+ self._sessions[cid] = session
139
+ return session
140
+
141
+ def get_session(self, conversation_id: str) -> Optional[SessionState]:
142
+ """
143
+ Get an existing session by ID.
144
+
145
+ Args:
146
+ conversation_id: The conversation ID to look up
147
+
148
+ Returns:
149
+ SessionState if found, None otherwise
150
+ """
151
+ return self._sessions.get(conversation_id)
152
+
153
+ def get_or_create_session(
154
+ self,
155
+ conversation_id: str,
156
+ user_id: Optional[str] = None,
157
+ ) -> SessionState:
158
+ """
159
+ Get existing session or create new one if not exists.
160
+
161
+ Args:
162
+ conversation_id: The conversation ID
163
+ user_id: Optional user_id for new session
164
+
165
+ Returns:
166
+ Existing or new SessionState
167
+ """
168
+ if conversation_id in self._sessions:
169
+ return self._sessions[conversation_id]
170
+
171
+ return self.create_session(
172
+ conversation_id=conversation_id,
173
+ user_id=user_id,
174
+ )
175
+
176
+ def touch(self, conversation_id: str) -> None:
177
+ """
178
+ Update session activity timestamp and message count.
179
+
180
+ Args:
181
+ conversation_id: The conversation ID to touch
182
+ """
183
+ session = self._sessions.get(conversation_id)
184
+ if session:
185
+ session.touch()
186
+
187
+ def delete_session(self, conversation_id: str) -> bool:
188
+ """
189
+ Delete a session.
190
+
191
+ Args:
192
+ conversation_id: The conversation ID to delete
193
+
194
+ Returns:
195
+ True if deleted, False if not found
196
+ """
197
+ if conversation_id in self._sessions:
198
+ del self._sessions[conversation_id]
199
+ return True
200
+ return False
201
+
202
+ def list_sessions(
203
+ self,
204
+ user_id: Optional[str] = None,
205
+ tags: Optional[List[str]] = None,
206
+ limit: Optional[int] = None,
207
+ ) -> List[SessionState]:
208
+ """
209
+ List sessions with optional filters.
210
+
211
+ Args:
212
+ user_id: Filter by user_id
213
+ tags: Filter by tags (must have all specified tags)
214
+ limit: Maximum number of sessions to return
215
+
216
+ Returns:
217
+ List of matching SessionState objects
218
+ """
219
+ sessions = list(self._sessions.values())
220
+
221
+ if user_id:
222
+ sessions = [s for s in sessions if s.user_id == user_id]
223
+
224
+ if tags:
225
+ sessions = [s for s in sessions if all(tag in s.tags for tag in tags)]
226
+
227
+ # Sort by updated_at (most recent first)
228
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
229
+
230
+ if limit:
231
+ sessions = sessions[:limit]
232
+
233
+ return sessions
234
+
235
+ def to_dict(self) -> Dict[str, Any]:
236
+ """Serialize all sessions to dictionary."""
237
+ return {
238
+ "sessions": {
239
+ cid: session.to_dict()
240
+ for cid, session in self._sessions.items()
241
+ }
242
+ }
243
+
244
+ @classmethod
245
+ def from_dict(cls, data: Dict[str, Any]) -> SessionManager:
246
+ """Deserialize SessionManager from dictionary."""
247
+ manager = cls()
248
+
249
+ sessions_data = data.get("sessions", {})
250
+ for cid, session_data in sessions_data.items():
251
+ manager._sessions[cid] = SessionState.from_dict(session_data)
252
+
253
+ return manager
254
+
255
+ def __len__(self) -> int:
256
+ """Return number of active sessions."""
257
+ return len(self._sessions)
258
+
259
+ def __contains__(self, conversation_id: str) -> bool:
260
+ """Check if conversation_id exists."""
261
+ return conversation_id in self._sessions
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Global session manager (optional convenience)
266
+ # ---------------------------------------------------------------------------
267
+
268
+ _default_manager: Optional[SessionManager] = None
269
+
270
+
271
+ def get_session_manager() -> SessionManager:
272
+ """Get or create the default session manager."""
273
+ global _default_manager
274
+ if _default_manager is None:
275
+ _default_manager = SessionManager()
276
+ return _default_manager
277
+
278
+
279
+ def create_session(
280
+ user_id: Optional[str] = None,
281
+ conversation_id: Optional[str] = None,
282
+ tags: Optional[List[str]] = None,
283
+ ) -> SessionState:
284
+ """
285
+ Create a new session using the default manager.
286
+
287
+ Usage::
288
+ session = gateforge.create_session(user_id="user-123")
289
+ print(session.conversation_id)
290
+ """
291
+ return get_session_manager().create_session(
292
+ user_id=user_id,
293
+ conversation_id=conversation_id,
294
+ tags=tags,
295
+ )
296
+
297
+
298
+ def get_session(conversation_id: str) -> Optional[SessionState]:
299
+ """
300
+ Get an existing session using the default manager.
301
+
302
+ Usage::
303
+ session = gateforge.get_session("conv-123")
304
+ if session:
305
+ print(f"Messages: {session.message_count}")
306
+ """
307
+ return get_session_manager().get_session(conversation_id)
308
+
309
+
310
+ def get_current_conversation_id() -> Optional[str]:
311
+ """
312
+ Get the conversation_id from the active trace context.
313
+
314
+ Usage::
315
+ cid = gateforge.get_current_conversation_id()
316
+ if cid:
317
+ print(f"Current conversation: {cid}")
318
+ """
319
+ from gateforge.context import get_active_trace
320
+ trace = get_active_trace()
321
+ return trace.conversation_id if trace else None
322
+
323
+
324
+ def get_current_trace_info() -> Optional[Dict[str, Any]]:
325
+ """
326
+ Get information about the current trace context.
327
+
328
+ Returns:
329
+ Dictionary with conversation_id, trace_id, step, or None if no active trace
330
+
331
+ Usage::
332
+ info = gateforge.get_current_trace_info()
333
+ if info:
334
+ print(f"Conversation: {info['conversation_id']}, Step: {info['step']}")
335
+ """
336
+ from gateforge.context import get_active_trace
337
+ trace = get_active_trace()
338
+ if trace:
339
+ return {
340
+ "conversation_id": trace.conversation_id,
341
+ "trace_id": trace.trace_id,
342
+ "step": trace.step,
343
+ }
344
+ return None
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "gateforge-sdk"
7
- version = "0.2.3"
8
- description = "Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session tracing, PII masking, cost tracking"
7
+ version = "0.2.5"
8
+ description = "Privacy-first LLMOps SDK — Auto-init, tool/agent decorators, session management, nested conversation support"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
11
  requires-python = ">=3.10"