playagent 0.1.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.
- playagent/__init__.py +61 -0
- playagent/adapters/anthropic.py +293 -0
- playagent/adapters/openai.py +282 -0
- playagent/assertions.py +195 -0
- playagent/classifier.py +272 -0
- playagent/cli.py +375 -0
- playagent/core.py +179 -0
- playagent/models.py +98 -0
- playagent/storage.py +535 -0
- playagent-0.1.0.dist-info/METADATA +114 -0
- playagent-0.1.0.dist-info/RECORD +15 -0
- playagent-0.1.0.dist-info/WHEEL +5 -0
- playagent-0.1.0.dist-info/entry_points.txt +2 -0
- playagent-0.1.0.dist-info/licenses/LICENSE +21 -0
- playagent-0.1.0.dist-info/top_level.txt +1 -0
playagent/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Public API surface for PlayAgent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _maybe_print_welcome() -> None:
|
|
9
|
+
"""Print a one-time welcome message and initialize DB on first import."""
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
playagent_dir = Path.home() / ".playagent"
|
|
13
|
+
if playagent_dir.exists() or os.environ.get("PLAYAGENT_NO_WELCOME"):
|
|
14
|
+
return
|
|
15
|
+
try:
|
|
16
|
+
playagent_dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
print( # noqa: T201
|
|
18
|
+
f"PlayAgent v0.1.0 — creating local database at {playagent_dir / 'db.sqlite'}"
|
|
19
|
+
)
|
|
20
|
+
from playagent.storage import get_db_path, init_db
|
|
21
|
+
|
|
22
|
+
init_db(get_db_path())
|
|
23
|
+
except Exception: # noqa: BLE001
|
|
24
|
+
pass # Never crash on welcome message
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_maybe_print_welcome()
|
|
28
|
+
|
|
29
|
+
from playagent.assertions import ( # noqa: E402
|
|
30
|
+
AssertionFailedError,
|
|
31
|
+
assert_trace,
|
|
32
|
+
contains,
|
|
33
|
+
equals,
|
|
34
|
+
matches,
|
|
35
|
+
starts_with,
|
|
36
|
+
)
|
|
37
|
+
from playagent.classifier import classify_trace, evaluate, score_goal # noqa: E402
|
|
38
|
+
from playagent.core import get_current_session_id, record, trace, validate_environment # noqa: E402
|
|
39
|
+
from playagent.models import ClassificationResult, Session, ToolCall, TraceEvent # noqa: E402
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"record",
|
|
45
|
+
"trace",
|
|
46
|
+
"get_current_session_id",
|
|
47
|
+
"validate_environment",
|
|
48
|
+
"assert_trace",
|
|
49
|
+
"contains",
|
|
50
|
+
"starts_with",
|
|
51
|
+
"matches",
|
|
52
|
+
"equals",
|
|
53
|
+
"AssertionFailedError",
|
|
54
|
+
"classify_trace",
|
|
55
|
+
"score_goal",
|
|
56
|
+
"evaluate",
|
|
57
|
+
"Session",
|
|
58
|
+
"TraceEvent",
|
|
59
|
+
"ToolCall",
|
|
60
|
+
"ClassificationResult",
|
|
61
|
+
]
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Anthropic drop-in adapters that capture request/response traces into PlayAgent storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import anthropic
|
|
12
|
+
except ImportError as exc: # pragma: no cover
|
|
13
|
+
# Scenario: user did `pip install playagent` without the anthropic extra.
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"Anthropic adapter requires the anthropic package. "
|
|
16
|
+
"Install it with: pip install playagent[anthropic]"
|
|
17
|
+
) from exc
|
|
18
|
+
|
|
19
|
+
from playagent.core import get_current_session_id
|
|
20
|
+
from playagent.models import Session, ToolCall, TraceEvent, generate_id, now_utc
|
|
21
|
+
from playagent.storage import get_turn_count, save_session, save_trace_event, update_session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _create_standalone_session() -> Session | None:
|
|
25
|
+
"""Create and persist a standalone session when no active trace session exists."""
|
|
26
|
+
try:
|
|
27
|
+
started_at = now_utc()
|
|
28
|
+
session = Session(
|
|
29
|
+
id=generate_id("sess"),
|
|
30
|
+
agent_name="standalone",
|
|
31
|
+
status="running",
|
|
32
|
+
started_at=started_at,
|
|
33
|
+
ended_at=None,
|
|
34
|
+
metadata={},
|
|
35
|
+
error=None,
|
|
36
|
+
classification=None,
|
|
37
|
+
)
|
|
38
|
+
save_session(session)
|
|
39
|
+
return session
|
|
40
|
+
except Exception as exc: # noqa: BLE001
|
|
41
|
+
logging.warning("PlayAgent failed to create standalone session: %s", exc)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _finalize_standalone_session(
|
|
46
|
+
session: Session | None, status: str, error: str | None = None
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Mark standalone sessions complete so lifecycle mirrors decorator-based sessions."""
|
|
49
|
+
if session is None:
|
|
50
|
+
return
|
|
51
|
+
try:
|
|
52
|
+
session.status = status
|
|
53
|
+
session.error = error
|
|
54
|
+
session.ended_at = now_utc()
|
|
55
|
+
update_session(session)
|
|
56
|
+
except Exception as exc: # noqa: BLE001
|
|
57
|
+
logging.warning("PlayAgent failed to finalize standalone session: %s", exc)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_session() -> tuple[str | None, Session | None]:
|
|
61
|
+
"""Return active session id, creating standalone session if needed."""
|
|
62
|
+
session_id = get_current_session_id()
|
|
63
|
+
if session_id is not None:
|
|
64
|
+
return session_id, None
|
|
65
|
+
|
|
66
|
+
standalone = _create_standalone_session()
|
|
67
|
+
if standalone is None:
|
|
68
|
+
return None, None
|
|
69
|
+
return standalone.id, standalone
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_response_content(content_blocks: Any) -> str:
|
|
73
|
+
"""Extract the first text block from Anthropic content blocks."""
|
|
74
|
+
if not content_blocks:
|
|
75
|
+
return ""
|
|
76
|
+
for block in content_blocks:
|
|
77
|
+
if getattr(block, "type", None) == "text":
|
|
78
|
+
return str(getattr(block, "text", "") or "")
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_tool_calls(content_blocks: Any) -> list[ToolCall]:
|
|
83
|
+
"""Extract tool_use blocks from Anthropic content blocks."""
|
|
84
|
+
calls: list[ToolCall] = []
|
|
85
|
+
if not content_blocks:
|
|
86
|
+
return calls
|
|
87
|
+
|
|
88
|
+
for block in content_blocks:
|
|
89
|
+
if getattr(block, "type", None) != "tool_use":
|
|
90
|
+
continue
|
|
91
|
+
block_input = getattr(block, "input", None)
|
|
92
|
+
args = block_input if isinstance(block_input, dict) else {}
|
|
93
|
+
calls.append(
|
|
94
|
+
ToolCall(
|
|
95
|
+
name=str(getattr(block, "name", "") or ""),
|
|
96
|
+
arguments=args,
|
|
97
|
+
result=None,
|
|
98
|
+
call_id=getattr(block, "id", None),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return calls
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _map_finish_reason(stop_reason: str | None) -> str:
|
|
105
|
+
"""Map Anthropic stop reasons into unified TraceEvent finish reasons."""
|
|
106
|
+
if stop_reason == "end_turn":
|
|
107
|
+
return "stop"
|
|
108
|
+
if stop_reason == "tool_use":
|
|
109
|
+
return "tool_calls"
|
|
110
|
+
return str(stop_reason or "stop")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _safe_save_event(event: TraceEvent) -> None:
|
|
114
|
+
"""Persist trace event without surfacing instrumentation failures."""
|
|
115
|
+
try:
|
|
116
|
+
save_trace_event(event)
|
|
117
|
+
except Exception as exc: # noqa: BLE001
|
|
118
|
+
logging.warning("PlayAgent failed to save Anthropic trace event %s: %s", event.id, exc)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_model_and_messages(kwargs: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
|
|
122
|
+
model = str(kwargs.get("model") or "")
|
|
123
|
+
messages_raw = kwargs.get("messages") or []
|
|
124
|
+
if isinstance(messages_raw, list):
|
|
125
|
+
messages = messages_raw
|
|
126
|
+
else:
|
|
127
|
+
messages = []
|
|
128
|
+
return model, messages
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_success_event(
|
|
132
|
+
*,
|
|
133
|
+
session_id: str,
|
|
134
|
+
model: str,
|
|
135
|
+
messages: list[dict[str, Any]],
|
|
136
|
+
response: Any,
|
|
137
|
+
latency_ms: int,
|
|
138
|
+
) -> TraceEvent:
|
|
139
|
+
content = getattr(response, "content", None)
|
|
140
|
+
usage = getattr(response, "usage", None)
|
|
141
|
+
|
|
142
|
+
response_content = _extract_response_content(content)
|
|
143
|
+
tool_calls = _extract_tool_calls(content)
|
|
144
|
+
prompt_tokens = int(getattr(usage, "input_tokens", 0) or 0)
|
|
145
|
+
completion_tokens = int(getattr(usage, "output_tokens", 0) or 0)
|
|
146
|
+
finish_reason = _map_finish_reason(getattr(response, "stop_reason", None))
|
|
147
|
+
|
|
148
|
+
turn = get_turn_count(session_id) + 1
|
|
149
|
+
return TraceEvent(
|
|
150
|
+
id=generate_id("evt"),
|
|
151
|
+
session_id=session_id,
|
|
152
|
+
turn=turn,
|
|
153
|
+
model=model,
|
|
154
|
+
provider="anthropic",
|
|
155
|
+
messages=messages,
|
|
156
|
+
response_content=response_content,
|
|
157
|
+
tool_calls=tool_calls,
|
|
158
|
+
prompt_tokens=prompt_tokens,
|
|
159
|
+
completion_tokens=completion_tokens,
|
|
160
|
+
finish_reason=finish_reason,
|
|
161
|
+
latency_ms=latency_ms,
|
|
162
|
+
error=None,
|
|
163
|
+
created_at=now_utc(),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _build_error_event(
|
|
168
|
+
*,
|
|
169
|
+
session_id: str,
|
|
170
|
+
model: str,
|
|
171
|
+
messages: list[dict[str, Any]],
|
|
172
|
+
error: Exception,
|
|
173
|
+
latency_ms: int,
|
|
174
|
+
) -> TraceEvent:
|
|
175
|
+
turn = get_turn_count(session_id) + 1
|
|
176
|
+
return TraceEvent(
|
|
177
|
+
id=generate_id("evt"),
|
|
178
|
+
session_id=session_id,
|
|
179
|
+
turn=turn,
|
|
180
|
+
model=model,
|
|
181
|
+
provider="anthropic",
|
|
182
|
+
messages=messages,
|
|
183
|
+
response_content="",
|
|
184
|
+
tool_calls=[],
|
|
185
|
+
prompt_tokens=0,
|
|
186
|
+
completion_tokens=0,
|
|
187
|
+
finish_reason="error",
|
|
188
|
+
latency_ms=latency_ms,
|
|
189
|
+
error=str(error),
|
|
190
|
+
created_at=now_utc(),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _instrument_sync_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
|
|
195
|
+
def wrapped_create(*args: Any, **kwargs: Any) -> Any:
|
|
196
|
+
start = time.monotonic()
|
|
197
|
+
model, messages = _extract_model_and_messages(kwargs)
|
|
198
|
+
session_id, standalone_session = _resolve_session()
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
response = original_create(*args, **kwargs)
|
|
202
|
+
except Exception as error:
|
|
203
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
204
|
+
if session_id is not None:
|
|
205
|
+
error_event = _build_error_event(
|
|
206
|
+
session_id=session_id,
|
|
207
|
+
model=model,
|
|
208
|
+
messages=messages,
|
|
209
|
+
error=error,
|
|
210
|
+
latency_ms=latency_ms,
|
|
211
|
+
)
|
|
212
|
+
_safe_save_event(error_event)
|
|
213
|
+
_finalize_standalone_session(standalone_session, status="failed", error=str(error))
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
217
|
+
if session_id is not None:
|
|
218
|
+
try:
|
|
219
|
+
response_model = str(getattr(response, "model", None) or model)
|
|
220
|
+
event = _build_success_event(
|
|
221
|
+
session_id=session_id,
|
|
222
|
+
model=response_model,
|
|
223
|
+
messages=messages,
|
|
224
|
+
response=response,
|
|
225
|
+
latency_ms=latency_ms,
|
|
226
|
+
)
|
|
227
|
+
_safe_save_event(event)
|
|
228
|
+
except Exception as exc: # noqa: BLE001
|
|
229
|
+
logging.warning("PlayAgent failed to instrument Anthropic response: %s", exc)
|
|
230
|
+
|
|
231
|
+
_finalize_standalone_session(standalone_session, status="passed", error=None)
|
|
232
|
+
return response
|
|
233
|
+
|
|
234
|
+
return wrapped_create
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _instrument_async_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
|
|
238
|
+
async def wrapped_create(*args: Any, **kwargs: Any) -> Any:
|
|
239
|
+
start = time.monotonic()
|
|
240
|
+
model, messages = _extract_model_and_messages(kwargs)
|
|
241
|
+
session_id, standalone_session = _resolve_session()
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
response = await original_create(*args, **kwargs)
|
|
245
|
+
except Exception as error:
|
|
246
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
247
|
+
if session_id is not None:
|
|
248
|
+
error_event = _build_error_event(
|
|
249
|
+
session_id=session_id,
|
|
250
|
+
model=model,
|
|
251
|
+
messages=messages,
|
|
252
|
+
error=error,
|
|
253
|
+
latency_ms=latency_ms,
|
|
254
|
+
)
|
|
255
|
+
_safe_save_event(error_event)
|
|
256
|
+
_finalize_standalone_session(standalone_session, status="failed", error=str(error))
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
260
|
+
if session_id is not None:
|
|
261
|
+
try:
|
|
262
|
+
response_model = str(getattr(response, "model", None) or model)
|
|
263
|
+
event = _build_success_event(
|
|
264
|
+
session_id=session_id,
|
|
265
|
+
model=response_model,
|
|
266
|
+
messages=messages,
|
|
267
|
+
response=response,
|
|
268
|
+
latency_ms=latency_ms,
|
|
269
|
+
)
|
|
270
|
+
_safe_save_event(event)
|
|
271
|
+
except Exception as exc: # noqa: BLE001
|
|
272
|
+
logging.warning("PlayAgent failed to instrument Anthropic async response: %s", exc)
|
|
273
|
+
|
|
274
|
+
_finalize_standalone_session(standalone_session, status="passed", error=None)
|
|
275
|
+
return response
|
|
276
|
+
|
|
277
|
+
return wrapped_create
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class Anthropic(anthropic.Anthropic):
|
|
281
|
+
"""Drop-in Anthropic client with automatic trace persistence."""
|
|
282
|
+
|
|
283
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
284
|
+
super().__init__(*args, **kwargs)
|
|
285
|
+
self.messages.create = _instrument_sync_create(self.messages.create)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class AsyncAnthropic(anthropic.AsyncAnthropic):
|
|
289
|
+
"""Async drop-in Anthropic client with automatic trace persistence."""
|
|
290
|
+
|
|
291
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
292
|
+
super().__init__(*args, **kwargs)
|
|
293
|
+
self.messages.create = _instrument_async_create(self.messages.create)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""OpenAI drop-in adapters that capture request/response traces into PlayAgent storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import openai
|
|
13
|
+
except ImportError as exc: # pragma: no cover
|
|
14
|
+
# Scenario: user did `pip install playagent` without the openai extra.
|
|
15
|
+
raise ImportError(
|
|
16
|
+
"OpenAI adapter requires the openai package. Install it with: pip install playagent[openai]"
|
|
17
|
+
) from exc
|
|
18
|
+
|
|
19
|
+
from playagent.core import get_current_session_id
|
|
20
|
+
from playagent.models import Session, ToolCall, TraceEvent, generate_id, now_utc
|
|
21
|
+
from playagent.storage import get_turn_count, save_session, save_trace_event, update_session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _create_standalone_session() -> Session | None:
|
|
25
|
+
"""Create and persist a standalone session when no active trace session exists."""
|
|
26
|
+
try:
|
|
27
|
+
started_at = now_utc()
|
|
28
|
+
session = Session(
|
|
29
|
+
id=generate_id("sess"),
|
|
30
|
+
agent_name="standalone",
|
|
31
|
+
status="running",
|
|
32
|
+
started_at=started_at,
|
|
33
|
+
ended_at=None,
|
|
34
|
+
metadata={},
|
|
35
|
+
error=None,
|
|
36
|
+
classification=None,
|
|
37
|
+
)
|
|
38
|
+
save_session(session)
|
|
39
|
+
return session
|
|
40
|
+
except Exception as exc: # noqa: BLE001
|
|
41
|
+
logging.warning("PlayAgent failed to create standalone session: %s", exc)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_tool_calls(raw_tool_calls: Any) -> list[ToolCall]:
|
|
46
|
+
"""Convert OpenAI tool call payloads into PlayAgent ToolCall models."""
|
|
47
|
+
parsed: list[ToolCall] = []
|
|
48
|
+
if not raw_tool_calls:
|
|
49
|
+
return parsed
|
|
50
|
+
|
|
51
|
+
for call in raw_tool_calls:
|
|
52
|
+
try:
|
|
53
|
+
args = json.loads(call.function.arguments) if call.function.arguments else {}
|
|
54
|
+
except Exception: # noqa: BLE001
|
|
55
|
+
args = {}
|
|
56
|
+
parsed.append(
|
|
57
|
+
ToolCall(
|
|
58
|
+
name=getattr(call.function, "name", ""),
|
|
59
|
+
arguments=args,
|
|
60
|
+
result=None,
|
|
61
|
+
call_id=getattr(call, "id", None),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return parsed
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _finalize_standalone_session(
|
|
68
|
+
session: Session | None, status: str, error: str | None = None
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Mark standalone sessions complete so lifecycle mirrors decorator-based sessions."""
|
|
71
|
+
if session is None:
|
|
72
|
+
return
|
|
73
|
+
try:
|
|
74
|
+
session.status = status
|
|
75
|
+
session.error = error
|
|
76
|
+
session.ended_at = now_utc()
|
|
77
|
+
update_session(session)
|
|
78
|
+
except Exception as exc: # noqa: BLE001
|
|
79
|
+
logging.warning("PlayAgent failed to finalize standalone session: %s", exc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_session() -> tuple[str | None, Session | None]:
|
|
83
|
+
"""Return active session id, creating standalone session if needed."""
|
|
84
|
+
session_id = get_current_session_id()
|
|
85
|
+
if session_id is not None:
|
|
86
|
+
return session_id, None
|
|
87
|
+
|
|
88
|
+
standalone = _create_standalone_session()
|
|
89
|
+
if standalone is None:
|
|
90
|
+
return None, None
|
|
91
|
+
return standalone.id, standalone
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build_event_from_response(
|
|
95
|
+
*,
|
|
96
|
+
session_id: str,
|
|
97
|
+
model: str,
|
|
98
|
+
messages: list[dict[str, Any]],
|
|
99
|
+
response: Any,
|
|
100
|
+
latency_ms: int,
|
|
101
|
+
) -> TraceEvent:
|
|
102
|
+
"""Build TraceEvent from successful OpenAI API response."""
|
|
103
|
+
choice0 = response.choices[0] if getattr(response, "choices", None) else None
|
|
104
|
+
message = choice0.message if choice0 is not None else None
|
|
105
|
+
usage = getattr(response, "usage", None)
|
|
106
|
+
|
|
107
|
+
response_content = getattr(message, "content", "") if message is not None else ""
|
|
108
|
+
parsed_tool_calls = _parse_tool_calls(getattr(message, "tool_calls", None) if message else None)
|
|
109
|
+
prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
110
|
+
completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
|
|
111
|
+
finish_reason = getattr(choice0, "finish_reason", "stop") if choice0 is not None else "stop"
|
|
112
|
+
|
|
113
|
+
turn = get_turn_count(session_id) + 1
|
|
114
|
+
|
|
115
|
+
return TraceEvent(
|
|
116
|
+
id=generate_id("evt"),
|
|
117
|
+
session_id=session_id,
|
|
118
|
+
turn=turn,
|
|
119
|
+
model=model,
|
|
120
|
+
provider="openai",
|
|
121
|
+
messages=messages,
|
|
122
|
+
response_content=response_content or "",
|
|
123
|
+
tool_calls=parsed_tool_calls,
|
|
124
|
+
prompt_tokens=prompt_tokens,
|
|
125
|
+
completion_tokens=completion_tokens,
|
|
126
|
+
finish_reason=finish_reason or "stop",
|
|
127
|
+
latency_ms=latency_ms,
|
|
128
|
+
error=None,
|
|
129
|
+
created_at=now_utc(),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_event_from_error(
|
|
134
|
+
*,
|
|
135
|
+
session_id: str,
|
|
136
|
+
model: str,
|
|
137
|
+
messages: list[dict[str, Any]],
|
|
138
|
+
error: Exception,
|
|
139
|
+
latency_ms: int,
|
|
140
|
+
) -> TraceEvent:
|
|
141
|
+
"""Build TraceEvent from failed OpenAI API call."""
|
|
142
|
+
turn = get_turn_count(session_id) + 1
|
|
143
|
+
return TraceEvent(
|
|
144
|
+
id=generate_id("evt"),
|
|
145
|
+
session_id=session_id,
|
|
146
|
+
turn=turn,
|
|
147
|
+
model=model,
|
|
148
|
+
provider="openai",
|
|
149
|
+
messages=messages,
|
|
150
|
+
response_content="",
|
|
151
|
+
tool_calls=[],
|
|
152
|
+
prompt_tokens=0,
|
|
153
|
+
completion_tokens=0,
|
|
154
|
+
finish_reason="error",
|
|
155
|
+
latency_ms=latency_ms,
|
|
156
|
+
error=str(error),
|
|
157
|
+
created_at=now_utc(),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _safe_save_event(event: TraceEvent) -> None:
|
|
162
|
+
"""Persist trace event without surfacing instrumentation failures."""
|
|
163
|
+
try:
|
|
164
|
+
save_trace_event(event)
|
|
165
|
+
except Exception as exc: # noqa: BLE001
|
|
166
|
+
logging.warning("PlayAgent failed to save OpenAI trace event %s: %s", event.id, exc)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _extract_model_and_messages(kwargs: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
|
|
170
|
+
model = str(kwargs.get("model") or "")
|
|
171
|
+
messages_raw = kwargs.get("messages") or []
|
|
172
|
+
if isinstance(messages_raw, list):
|
|
173
|
+
messages = messages_raw
|
|
174
|
+
else:
|
|
175
|
+
messages = []
|
|
176
|
+
return model, messages
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _instrument_sync_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
|
|
180
|
+
def wrapped_create(*args: Any, **kwargs: Any) -> Any:
|
|
181
|
+
start = time.monotonic()
|
|
182
|
+
model, messages = _extract_model_and_messages(kwargs)
|
|
183
|
+
session_id, standalone_session = _resolve_session()
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
response = original_create(*args, **kwargs)
|
|
187
|
+
except Exception as error:
|
|
188
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
189
|
+
if session_id is not None:
|
|
190
|
+
error_event = _build_event_from_error(
|
|
191
|
+
session_id=session_id,
|
|
192
|
+
model=model,
|
|
193
|
+
messages=messages,
|
|
194
|
+
error=error,
|
|
195
|
+
latency_ms=latency_ms,
|
|
196
|
+
)
|
|
197
|
+
_safe_save_event(error_event)
|
|
198
|
+
_finalize_standalone_session(standalone_session, status="failed", error=str(error))
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
202
|
+
if session_id is not None:
|
|
203
|
+
try:
|
|
204
|
+
response_model = str(getattr(response, "model", None) or model)
|
|
205
|
+
success_event = _build_event_from_response(
|
|
206
|
+
session_id=session_id,
|
|
207
|
+
model=response_model,
|
|
208
|
+
messages=messages,
|
|
209
|
+
response=response,
|
|
210
|
+
latency_ms=latency_ms,
|
|
211
|
+
)
|
|
212
|
+
_safe_save_event(success_event)
|
|
213
|
+
except Exception as exc: # noqa: BLE001
|
|
214
|
+
logging.warning("PlayAgent failed to instrument OpenAI response: %s", exc)
|
|
215
|
+
|
|
216
|
+
_finalize_standalone_session(standalone_session, status="passed", error=None)
|
|
217
|
+
return response
|
|
218
|
+
|
|
219
|
+
return wrapped_create
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _instrument_async_create(original_create: Callable[..., Any]) -> Callable[..., Any]:
|
|
223
|
+
async def wrapped_create(*args: Any, **kwargs: Any) -> Any:
|
|
224
|
+
start = time.monotonic()
|
|
225
|
+
model, messages = _extract_model_and_messages(kwargs)
|
|
226
|
+
session_id, standalone_session = _resolve_session()
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
response = await original_create(*args, **kwargs)
|
|
230
|
+
except Exception as error:
|
|
231
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
232
|
+
if session_id is not None:
|
|
233
|
+
error_event = _build_event_from_error(
|
|
234
|
+
session_id=session_id,
|
|
235
|
+
model=model,
|
|
236
|
+
messages=messages,
|
|
237
|
+
error=error,
|
|
238
|
+
latency_ms=latency_ms,
|
|
239
|
+
)
|
|
240
|
+
_safe_save_event(error_event)
|
|
241
|
+
_finalize_standalone_session(standalone_session, status="failed", error=str(error))
|
|
242
|
+
raise
|
|
243
|
+
|
|
244
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
245
|
+
if session_id is not None:
|
|
246
|
+
try:
|
|
247
|
+
response_model = str(getattr(response, "model", None) or model)
|
|
248
|
+
success_event = _build_event_from_response(
|
|
249
|
+
session_id=session_id,
|
|
250
|
+
model=response_model,
|
|
251
|
+
messages=messages,
|
|
252
|
+
response=response,
|
|
253
|
+
latency_ms=latency_ms,
|
|
254
|
+
)
|
|
255
|
+
_safe_save_event(success_event)
|
|
256
|
+
except Exception as exc: # noqa: BLE001
|
|
257
|
+
logging.warning("PlayAgent failed to instrument OpenAI async response: %s", exc)
|
|
258
|
+
|
|
259
|
+
_finalize_standalone_session(standalone_session, status="passed", error=None)
|
|
260
|
+
return response
|
|
261
|
+
|
|
262
|
+
return wrapped_create
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class OpenAI(openai.OpenAI):
|
|
266
|
+
"""Drop-in OpenAI client with automatic trace persistence."""
|
|
267
|
+
|
|
268
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
269
|
+
super().__init__(*args, **kwargs)
|
|
270
|
+
self.chat.completions.create = _instrument_sync_create(self.chat.completions.create)
|
|
271
|
+
if hasattr(self.chat.completions, "acreate"):
|
|
272
|
+
self.chat.completions.acreate = _instrument_async_create(self.chat.completions.acreate)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class AsyncOpenAI(openai.AsyncOpenAI):
|
|
276
|
+
"""Async drop-in OpenAI client with automatic trace persistence."""
|
|
277
|
+
|
|
278
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
279
|
+
super().__init__(*args, **kwargs)
|
|
280
|
+
self.chat.completions.create = _instrument_async_create(self.chat.completions.create)
|
|
281
|
+
if hasattr(self.chat.completions, "acreate"):
|
|
282
|
+
self.chat.completions.acreate = _instrument_async_create(self.chat.completions.acreate)
|