morphsdk 0.2.5__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.
- morphsdk/__init__.py +54 -0
- morphsdk/_agent/__init__.py +64 -0
- morphsdk/_agent/config.py +52 -0
- morphsdk/_agent/explore.py +276 -0
- morphsdk/_agent/github.py +57 -0
- morphsdk/_agent/helpers.py +133 -0
- morphsdk/_agent/parser.py +163 -0
- morphsdk/_agent/runner.py +524 -0
- morphsdk/_agent/tools.py +171 -0
- morphsdk/_agent/types.py +126 -0
- morphsdk/_base.py +309 -0
- morphsdk/_client.py +245 -0
- morphsdk/_config.py +37 -0
- morphsdk/_constants.py +53 -0
- morphsdk/_errors.py +111 -0
- morphsdk/_providers/__init__.py +36 -0
- morphsdk/_providers/_filter.py +92 -0
- morphsdk/_providers/base.py +94 -0
- morphsdk/_providers/code_storage_http.py +104 -0
- morphsdk/_providers/local.py +270 -0
- morphsdk/_providers/remote.py +161 -0
- morphsdk/_version.py +1 -0
- morphsdk/adapters/__init__.py +1 -0
- morphsdk/adapters/anthropic.py +360 -0
- morphsdk/adapters/langchain.py +120 -0
- morphsdk/adapters/openai.py +500 -0
- morphsdk/py.typed +0 -0
- morphsdk/resources/__init__.py +0 -0
- morphsdk/resources/browser.py +919 -0
- morphsdk/resources/compact.py +133 -0
- morphsdk/resources/edit.py +506 -0
- morphsdk/resources/explore.py +333 -0
- morphsdk/resources/git.py +861 -0
- morphsdk/resources/github.py +1214 -0
- morphsdk/resources/grep.py +583 -0
- morphsdk/resources/mobile.py +134 -0
- morphsdk/resources/reflex.py +414 -0
- morphsdk/resources/router.py +124 -0
- morphsdk/resources/search.py +110 -0
- morphsdk/tracing/__init__.py +70 -0
- morphsdk/tracing/_otel.py +101 -0
- morphsdk/tracing/core.py +249 -0
- morphsdk/tracing/interaction.py +284 -0
- morphsdk/tracing/otel.py +75 -0
- morphsdk/tracing/reflex.py +58 -0
- morphsdk/tracing/types.py +163 -0
- morphsdk/types/__init__.py +140 -0
- morphsdk/types/browser.py +118 -0
- morphsdk/types/compact.py +41 -0
- morphsdk/types/edit.py +31 -0
- morphsdk/types/explore.py +42 -0
- morphsdk/types/git.py +25 -0
- morphsdk/types/github.py +111 -0
- morphsdk/types/grep.py +41 -0
- morphsdk/types/mobile.py +25 -0
- morphsdk/types/reflex.py +137 -0
- morphsdk/types/router.py +21 -0
- morphsdk/types/search.py +33 -0
- morphsdk-0.2.5.dist-info/METADATA +226 -0
- morphsdk-0.2.5.dist-info/RECORD +61 -0
- morphsdk-0.2.5.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Morph Tracing -- interactions, tools, and manual spans.
|
|
2
|
+
|
|
3
|
+
An :class:`Interaction` is one user turn / agent run. It threads association
|
|
4
|
+
properties (``user_id`` / ``convo_id`` / ``event_id``) onto every span created
|
|
5
|
+
inside it -- including the auto-instrumented LLM spans -- so a whole conversation
|
|
6
|
+
stitches together in the Morph UI. Built on Traceloop's association properties
|
|
7
|
+
and the OpenTelemetry tracer for manual workflow/tool spans.
|
|
8
|
+
|
|
9
|
+
Attribute keys and ``traceloop.span.kind`` values are kept byte-for-byte
|
|
10
|
+
identical to the TypeScript SDK so both emit the same wire payload.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import uuid as _uuid
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from typing import Any, Callable, TypeVar
|
|
20
|
+
|
|
21
|
+
from . import _otel
|
|
22
|
+
from .otel import metadata as build_metadata
|
|
23
|
+
from .reflex import REFLEX_RUN_ATTRIBUTE, serialize_evals
|
|
24
|
+
from .types import (
|
|
25
|
+
FinishOptions,
|
|
26
|
+
MetadataOptions,
|
|
27
|
+
SpanParams,
|
|
28
|
+
ToolParams,
|
|
29
|
+
TraceContext,
|
|
30
|
+
TrackToolParams,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
# Traceloop semantic-convention attribute keys.
|
|
36
|
+
_ASSOC = "traceloop.association.properties."
|
|
37
|
+
_ENTITY_INPUT = "traceloop.entity.input"
|
|
38
|
+
_ENTITY_OUTPUT = "traceloop.entity.output"
|
|
39
|
+
_ENTITY_NAME = "traceloop.entity.name"
|
|
40
|
+
_SPAN_KIND = "traceloop.span.kind"
|
|
41
|
+
|
|
42
|
+
_TRACER_NAME = "morphsdk.tracing"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _uuid4() -> str:
|
|
46
|
+
return str(_uuid.uuid4())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _as_string(value: Any) -> str:
|
|
50
|
+
if value is None:
|
|
51
|
+
return ""
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
return value
|
|
54
|
+
return json.dumps(value, separators=(",", ":"), default=str)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _association_props(ctx: TraceContext, extra: dict[str, str]) -> dict[str, str]:
|
|
58
|
+
"""Build the association-property bag propagated onto child spans."""
|
|
59
|
+
props: dict[str, str] = dict(extra)
|
|
60
|
+
if ctx.user_id:
|
|
61
|
+
props["user_id"] = ctx.user_id
|
|
62
|
+
if ctx.convo_id:
|
|
63
|
+
props["convo_id"] = ctx.convo_id
|
|
64
|
+
if ctx.event_id:
|
|
65
|
+
props["event_id"] = ctx.event_id
|
|
66
|
+
if ctx.event:
|
|
67
|
+
props["event_name"] = ctx.event
|
|
68
|
+
return props
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ToolSpan:
|
|
72
|
+
"""A manual tool span the caller drives explicitly."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, span: Any, trace_content: bool) -> None:
|
|
75
|
+
self._span = span
|
|
76
|
+
self._trace_content = trace_content
|
|
77
|
+
|
|
78
|
+
def set_input(self, value: Any) -> None:
|
|
79
|
+
if self._trace_content:
|
|
80
|
+
self._span.set_attribute(_ENTITY_INPUT, _as_string(value))
|
|
81
|
+
|
|
82
|
+
def set_output(self, value: Any) -> None:
|
|
83
|
+
if self._trace_content:
|
|
84
|
+
self._span.set_attribute(_ENTITY_OUTPUT, _as_string(value))
|
|
85
|
+
|
|
86
|
+
def set_error(self, error: str | BaseException) -> None:
|
|
87
|
+
exc = error if isinstance(error, BaseException) else Exception(error)
|
|
88
|
+
status = _otel.status_codes()
|
|
89
|
+
self._span.record_exception(exc)
|
|
90
|
+
self._span.set_status(status.Status(status.StatusCode.ERROR, str(exc)))
|
|
91
|
+
|
|
92
|
+
def end(self) -> None:
|
|
93
|
+
self._span.end()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Interaction:
|
|
97
|
+
"""A single traced AI interaction (one user turn / agent run)."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, initial: TraceContext, trace_content: bool) -> None:
|
|
100
|
+
self._ctx = initial.model_copy(deep=True)
|
|
101
|
+
if self._ctx.event_id is None:
|
|
102
|
+
self._ctx.event_id = _uuid4()
|
|
103
|
+
self._properties: dict[str, str] = dict(initial.properties or {})
|
|
104
|
+
self._input = initial.input
|
|
105
|
+
self._trace_content = trace_content
|
|
106
|
+
self._workflow_span: Any = None
|
|
107
|
+
# Serialized once. Set as a raw attribute on the workflow span only -- NOT
|
|
108
|
+
# an association property, so it is not copied onto child LLM spans. Ingest
|
|
109
|
+
# reads `morph.reflex.run` off the workflow-span to enqueue the evals.
|
|
110
|
+
self._reflex_run = serialize_evals(self._ctx.evals)
|
|
111
|
+
|
|
112
|
+
# -- accessors ---------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def get_event_id(self) -> str | None:
|
|
115
|
+
return self._ctx.event_id
|
|
116
|
+
|
|
117
|
+
def set_input(self, value: str) -> None:
|
|
118
|
+
self._input = value
|
|
119
|
+
|
|
120
|
+
def set_property(self, key: str, value: str) -> None:
|
|
121
|
+
self._properties[key] = value
|
|
122
|
+
|
|
123
|
+
def set_properties(self, props: dict[str, str]) -> None:
|
|
124
|
+
self._properties.update(props)
|
|
125
|
+
|
|
126
|
+
def vercel_ai_sdk_metadata(self) -> dict[str, str]:
|
|
127
|
+
"""Attribution metadata for OTel-emitting SDKs (e.g. Vercel AI SDK)."""
|
|
128
|
+
return build_metadata(
|
|
129
|
+
MetadataOptions(
|
|
130
|
+
user_id=self._ctx.user_id or "unknown",
|
|
131
|
+
convo_id=self._ctx.convo_id,
|
|
132
|
+
event_name=self._ctx.event,
|
|
133
|
+
event_id=self._ctx.event_id,
|
|
134
|
+
properties=self._properties,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# -- internals ---------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _workflow_name(self) -> str:
|
|
141
|
+
return self._ctx.event or "interaction"
|
|
142
|
+
|
|
143
|
+
@contextmanager
|
|
144
|
+
def _assoc(self) -> Iterator[None]:
|
|
145
|
+
"""Activate this interaction's association properties for the block.
|
|
146
|
+
|
|
147
|
+
Traceloop copies the current association properties onto every span
|
|
148
|
+
created while they are set, so auto-instrumented LLM spans inherit
|
|
149
|
+
attribution. Restored afterwards to avoid leaking across interactions.
|
|
150
|
+
"""
|
|
151
|
+
traceloop = _otel.traceloop()
|
|
152
|
+
props = _association_props(self._ctx, self._properties)
|
|
153
|
+
traceloop.set_association_properties(props)
|
|
154
|
+
try:
|
|
155
|
+
yield
|
|
156
|
+
finally:
|
|
157
|
+
traceloop.set_association_properties({})
|
|
158
|
+
|
|
159
|
+
def _ensure_workflow_span(self) -> Any:
|
|
160
|
+
"""Open the interaction workflow span once; stays active until finish()."""
|
|
161
|
+
if self._workflow_span is not None:
|
|
162
|
+
return self._workflow_span
|
|
163
|
+
trace = _otel.trace_api()
|
|
164
|
+
tracer = trace.get_tracer(_TRACER_NAME)
|
|
165
|
+
span = tracer.start_span(self._workflow_name())
|
|
166
|
+
span.set_attribute(_SPAN_KIND, "workflow")
|
|
167
|
+
for key, value in _association_props(self._ctx, self._properties).items():
|
|
168
|
+
span.set_attribute(_ASSOC + key, value)
|
|
169
|
+
if self._trace_content and self._input:
|
|
170
|
+
span.set_attribute(_ENTITY_INPUT, self._input)
|
|
171
|
+
if self._reflex_run:
|
|
172
|
+
span.set_attribute(REFLEX_RUN_ATTRIBUTE, self._reflex_run)
|
|
173
|
+
self._workflow_span = span
|
|
174
|
+
return span
|
|
175
|
+
|
|
176
|
+
@contextmanager
|
|
177
|
+
def _workflow_context(self) -> Iterator[None]:
|
|
178
|
+
"""Run a block with association props + the workflow span as parent."""
|
|
179
|
+
trace = _otel.trace_api()
|
|
180
|
+
with self._assoc():
|
|
181
|
+
span = self._ensure_workflow_span()
|
|
182
|
+
with trace.use_span(span, end_on_exit=False):
|
|
183
|
+
yield
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _as_tool_params(params: ToolParams | str | dict[str, Any]) -> ToolParams:
|
|
187
|
+
if isinstance(params, ToolParams):
|
|
188
|
+
return params
|
|
189
|
+
if isinstance(params, str):
|
|
190
|
+
return ToolParams(name=params)
|
|
191
|
+
return ToolParams.model_validate(params)
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _as_span_params(params: SpanParams | str | dict[str, Any]) -> SpanParams:
|
|
195
|
+
if isinstance(params, SpanParams):
|
|
196
|
+
return params
|
|
197
|
+
if isinstance(params, str):
|
|
198
|
+
return SpanParams(name=params)
|
|
199
|
+
return SpanParams.model_validate(params)
|
|
200
|
+
|
|
201
|
+
# -- spans -------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def start_tool_span(self, params: ToolParams | str | dict[str, Any]) -> ToolSpan:
|
|
204
|
+
"""Start a tool span you end manually via :meth:`ToolSpan.end`."""
|
|
205
|
+
trace = _otel.trace_api()
|
|
206
|
+
tool = self._as_tool_params(params)
|
|
207
|
+
tracer = trace.get_tracer(_TRACER_NAME)
|
|
208
|
+
span = tracer.start_span(tool.name)
|
|
209
|
+
span.set_attribute(_SPAN_KIND, "tool")
|
|
210
|
+
span.set_attribute(_ENTITY_NAME, tool.name)
|
|
211
|
+
for key, value in _association_props(self._ctx, self._properties).items():
|
|
212
|
+
span.set_attribute(_ASSOC + key, value)
|
|
213
|
+
if tool.properties:
|
|
214
|
+
for key, value in tool.properties.items():
|
|
215
|
+
span.set_attribute(key, value)
|
|
216
|
+
return ToolSpan(span, self._trace_content)
|
|
217
|
+
|
|
218
|
+
def run(self, fn: Callable[[], T]) -> T:
|
|
219
|
+
"""Run *fn* with this interaction's association properties active.
|
|
220
|
+
|
|
221
|
+
Required so auto-instrumented OpenAI/Anthropic spans inherit
|
|
222
|
+
``user_id`` / ``convo_id`` / tags.
|
|
223
|
+
"""
|
|
224
|
+
with self._workflow_context():
|
|
225
|
+
return fn()
|
|
226
|
+
|
|
227
|
+
def with_span(self, params: SpanParams | str | dict[str, Any], fn: Callable[[], T]) -> T:
|
|
228
|
+
"""Run *fn* inside a traced task span; LLM calls within inherit attribution."""
|
|
229
|
+
trace = _otel.trace_api()
|
|
230
|
+
task = self._as_span_params(params)
|
|
231
|
+
with self._workflow_context():
|
|
232
|
+
tracer = trace.get_tracer(_TRACER_NAME)
|
|
233
|
+
with tracer.start_as_current_span(task.name) as span:
|
|
234
|
+
span.set_attribute(_SPAN_KIND, "task")
|
|
235
|
+
span.set_attribute(_ENTITY_NAME, task.name)
|
|
236
|
+
if task.properties:
|
|
237
|
+
for key, value in task.properties.items():
|
|
238
|
+
span.set_attribute(key, value)
|
|
239
|
+
return fn()
|
|
240
|
+
|
|
241
|
+
def with_tool(self, params: ToolParams | str | dict[str, Any], fn: Callable[[], T]) -> T:
|
|
242
|
+
"""Run *fn* inside a traced tool span."""
|
|
243
|
+
trace = _otel.trace_api()
|
|
244
|
+
tool = self._as_tool_params(params)
|
|
245
|
+
with self._workflow_context():
|
|
246
|
+
tracer = trace.get_tracer(_TRACER_NAME)
|
|
247
|
+
with tracer.start_as_current_span(tool.name) as span:
|
|
248
|
+
span.set_attribute(_SPAN_KIND, "tool")
|
|
249
|
+
span.set_attribute(_ENTITY_NAME, tool.name)
|
|
250
|
+
if tool.properties:
|
|
251
|
+
for key, value in tool.properties.items():
|
|
252
|
+
span.set_attribute(key, value)
|
|
253
|
+
return fn()
|
|
254
|
+
|
|
255
|
+
def track_tool(self, params: TrackToolParams | dict[str, Any]) -> None:
|
|
256
|
+
"""Record an already-completed tool invocation as a single span."""
|
|
257
|
+
if isinstance(params, TrackToolParams):
|
|
258
|
+
track = params
|
|
259
|
+
else:
|
|
260
|
+
track = TrackToolParams.model_validate(params)
|
|
261
|
+
span = self.start_tool_span(ToolParams(name=track.name, properties=track.properties))
|
|
262
|
+
if track.input is not None:
|
|
263
|
+
span.set_input(track.input)
|
|
264
|
+
if track.output is not None:
|
|
265
|
+
span.set_output(track.output)
|
|
266
|
+
if track.error:
|
|
267
|
+
span.set_error(track.error)
|
|
268
|
+
span.end()
|
|
269
|
+
|
|
270
|
+
def finish(self, opts: FinishOptions | str | dict[str, Any]) -> None:
|
|
271
|
+
"""End the interaction with its final output."""
|
|
272
|
+
if isinstance(opts, str):
|
|
273
|
+
output = opts
|
|
274
|
+
else:
|
|
275
|
+
finish = opts if isinstance(opts, FinishOptions) else FinishOptions.model_validate(opts)
|
|
276
|
+
output = finish.output
|
|
277
|
+
if finish.properties:
|
|
278
|
+
self._properties.update(finish.properties)
|
|
279
|
+
with self._assoc():
|
|
280
|
+
span = self._ensure_workflow_span()
|
|
281
|
+
if self._trace_content:
|
|
282
|
+
span.set_attribute(_ENTITY_OUTPUT, output)
|
|
283
|
+
span.end()
|
|
284
|
+
self._workflow_span = None
|
morphsdk/tracing/otel.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Morph Tracing -- OpenTelemetry attribution helper.
|
|
2
|
+
|
|
3
|
+
LLM SDKs that emit their own OpenTelemetry spans (e.g. the Vercel AI SDK) only
|
|
4
|
+
need each call tagged with attribution metadata so Morph can stitch the spans to
|
|
5
|
+
a user / conversation / event. :func:`metadata` builds that tag bag.
|
|
6
|
+
|
|
7
|
+
This module has **no** OpenTelemetry dependency -- it returns a plain ``dict`` of
|
|
8
|
+
snake_case keys, byte-for-byte identical to the TypeScript ``otel.ts`` helper::
|
|
9
|
+
|
|
10
|
+
from morphsdk.tracing import metadata
|
|
11
|
+
|
|
12
|
+
tags = metadata(user_id="user-123", convo_id="convo-456")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import uuid as _uuid
|
|
18
|
+
|
|
19
|
+
from .types import MetadataOptions
|
|
20
|
+
|
|
21
|
+
# Reserved keys Morph owns. Custom ``properties`` cannot overwrite these so
|
|
22
|
+
# attribution (user_id / convo_id / event_id / event_name) stays intact.
|
|
23
|
+
_RESERVED_KEYS = frozenset({"user_id", "convo_id", "event_id", "event_name"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _uuid4() -> str:
|
|
27
|
+
return str(_uuid.uuid4())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def metadata(
|
|
31
|
+
opts: MetadataOptions | dict[str, object] | None = None,
|
|
32
|
+
*,
|
|
33
|
+
user_id: str | None = None,
|
|
34
|
+
convo_id: str | None = None,
|
|
35
|
+
event_name: str | None = None,
|
|
36
|
+
event_id: str | None = None,
|
|
37
|
+
properties: dict[str, str] | None = None,
|
|
38
|
+
) -> dict[str, str]:
|
|
39
|
+
"""Build the attribution metadata bag for OTel-emitting LLM SDKs.
|
|
40
|
+
|
|
41
|
+
Accepts either a :class:`MetadataOptions` (or plain dict) positionally, or
|
|
42
|
+
keyword arguments. A fresh ``event_id`` is generated per call when omitted,
|
|
43
|
+
matching the TypeScript helper.
|
|
44
|
+
|
|
45
|
+
The returned keys are snake_case (``user_id``, ``convo_id``, ...) -- exactly
|
|
46
|
+
what Morph's ClickHouse views read.
|
|
47
|
+
"""
|
|
48
|
+
if opts is not None:
|
|
49
|
+
resolved = (
|
|
50
|
+
opts if isinstance(opts, MetadataOptions) else MetadataOptions.model_validate(opts)
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
if user_id is None:
|
|
54
|
+
raise TypeError("metadata() requires user_id")
|
|
55
|
+
resolved = MetadataOptions(
|
|
56
|
+
user_id=user_id,
|
|
57
|
+
convo_id=convo_id,
|
|
58
|
+
event_name=event_name,
|
|
59
|
+
event_id=event_id,
|
|
60
|
+
properties=properties,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
result: dict[str, str] = {
|
|
64
|
+
"user_id": resolved.user_id,
|
|
65
|
+
"event_id": resolved.event_id or _uuid4(),
|
|
66
|
+
}
|
|
67
|
+
if resolved.convo_id:
|
|
68
|
+
result["convo_id"] = resolved.convo_id
|
|
69
|
+
if resolved.event_name:
|
|
70
|
+
result["event_name"] = resolved.event_name
|
|
71
|
+
if resolved.properties:
|
|
72
|
+
for key, value in resolved.properties.items():
|
|
73
|
+
if key not in _RESERVED_KEYS:
|
|
74
|
+
result[key] = value
|
|
75
|
+
return result
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Morph Tracing -- eval selection (which Reflexes to run on a trace).
|
|
2
|
+
|
|
3
|
+
Mirror of the TypeScript ``morphsdk/tracing/reflex.ts``. The public API is ``evals`` (see
|
|
4
|
+
:data:`EvalSelection`): ``{"user": [...], "assistant": [...]}`` choosing which role each model
|
|
5
|
+
classifies. The selection is serialized onto a single ``morph.reflex.run`` attribute on the
|
|
6
|
+
interaction's workflow span (NOT an association property, so it is not copied onto child LLM
|
|
7
|
+
spans); Morph's ingest reads it and runs the classifications async. Plain nouns in, wire detail
|
|
8
|
+
hidden.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from .types import EvalSelection
|
|
16
|
+
|
|
17
|
+
#: Raw span attribute key carrying the serialized selection (internal wire detail).
|
|
18
|
+
REFLEX_RUN_ATTRIBUTE = "morph.reflex.run"
|
|
19
|
+
|
|
20
|
+
# Public role -> backend transform id. user = the user's message, assistant = the agent's output.
|
|
21
|
+
_USER_TRANSFORM = "user_message"
|
|
22
|
+
_ASSISTANT_TRANSFORM = "assistant_message"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_evals(evals: EvalSelection | None) -> list[dict[str, str]]:
|
|
26
|
+
"""Normalize a public ``evals`` selection into deduped ``{"model","transform"}`` wire entries.
|
|
27
|
+
``user`` models run on the user turn, ``assistant`` models on the agent turn. Drops blank
|
|
28
|
+
models; first-seen order."""
|
|
29
|
+
if not evals:
|
|
30
|
+
return []
|
|
31
|
+
out: list[dict[str, str]] = []
|
|
32
|
+
seen: set[str] = set()
|
|
33
|
+
|
|
34
|
+
def add(models: list[str] | None, transform: str) -> None:
|
|
35
|
+
for raw in models or []:
|
|
36
|
+
model = raw.strip() if isinstance(raw, str) else ""
|
|
37
|
+
if not model:
|
|
38
|
+
continue
|
|
39
|
+
key = f"{model} {transform}"
|
|
40
|
+
if key in seen:
|
|
41
|
+
continue
|
|
42
|
+
seen.add(key)
|
|
43
|
+
out.append({"model": model, "transform": transform})
|
|
44
|
+
|
|
45
|
+
add(evals.get("user"), _USER_TRANSFORM)
|
|
46
|
+
add(evals.get("assistant"), _ASSISTANT_TRANSFORM)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def serialize_evals(evals: EvalSelection | None) -> str | None:
|
|
51
|
+
"""Serialize an ``evals`` selection to the ``morph.reflex.run`` string, or ``None`` when there
|
|
52
|
+
is nothing to run (so the attribute is omitted)."""
|
|
53
|
+
entries = normalize_evals(evals)
|
|
54
|
+
if not entries:
|
|
55
|
+
return None
|
|
56
|
+
# ensure_ascii=False to match TS JSON.stringify (raw UTF-8) byte-for-byte for non-ASCII model
|
|
57
|
+
# names; without it Python emits \uXXXX escapes and the wire string diverges from the TS SDK.
|
|
58
|
+
return json.dumps(entries, separators=(",", ":"), ensure_ascii=False)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Morph Tracing -- public types.
|
|
2
|
+
|
|
3
|
+
Morph Tracing instruments the top AI SDKs (OpenAI, Anthropic, LangChain,
|
|
4
|
+
Bedrock, Vertex, Cohere, Together, LlamaIndex, ...) and ships OpenTelemetry
|
|
5
|
+
spans to Morph. It is a thin, Morph-branded layer over OpenLLMetry / Traceloop,
|
|
6
|
+
mirroring the TypeScript ``morphsdk/tracing`` module byte-for-byte on the wire.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Eval selection
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
# Always explicit per role — no implicit default, since a model run on the wrong role gives a
|
|
20
|
+
# meaningless label. Keys: "user" (the user's message), "assistant" (the agent's output).
|
|
21
|
+
EvalSelection = dict[str, list[str]]
|
|
22
|
+
"""Reflexes (evals) to run automatically on a trace, keyed by which role each model classifies::
|
|
23
|
+
|
|
24
|
+
evals={"user": ["jailbreak", "guardrail"]}
|
|
25
|
+
evals={"user": ["jailbreak"], "assistant": ["leaked-thinking"]}
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Configuration
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MorphTracingConfig(BaseModel):
|
|
35
|
+
"""Configuration for :func:`morphsdk.tracing.morph_tracing`."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(extra="forbid")
|
|
38
|
+
|
|
39
|
+
api_key: str | None = None
|
|
40
|
+
"""Morph API key. Defaults to ``MORPH_API_KEY``. Sent as
|
|
41
|
+
``Authorization: Bearer <api_key>`` to the trace ingest endpoint."""
|
|
42
|
+
|
|
43
|
+
base_url: str | None = None
|
|
44
|
+
"""Base URL for the Morph ingest API. Traces are POSTed to
|
|
45
|
+
``{base_url}/v1/traces``. Defaults to ``MORPH_TRACES_URL`` then
|
|
46
|
+
``https://api.morphllm.com``."""
|
|
47
|
+
|
|
48
|
+
app_name: str | None = None
|
|
49
|
+
"""Service/app name attached to every span. Defaults to ``"morph-app"``."""
|
|
50
|
+
|
|
51
|
+
disabled: bool = False
|
|
52
|
+
"""When true, the SDK initializes nothing and ships nothing."""
|
|
53
|
+
|
|
54
|
+
disable_batching: bool | None = None
|
|
55
|
+
"""Send spans immediately instead of batching. Defaults to true outside
|
|
56
|
+
production (``MORPH_ENVIRONMENT``/``NODE_ENV`` != ``production``)."""
|
|
57
|
+
|
|
58
|
+
trace_content: bool = True
|
|
59
|
+
"""Capture prompt/response content on spans. Set false for
|
|
60
|
+
zero-data-retention."""
|
|
61
|
+
|
|
62
|
+
instrument_modules: set[str] | None = None
|
|
63
|
+
"""Explicit set of instrument names to enable. Omit to auto-instrument all
|
|
64
|
+
supported libraries."""
|
|
65
|
+
|
|
66
|
+
use_external_otel: bool = False
|
|
67
|
+
"""Set true when you run your own OpenTelemetry pipeline. Morph will not
|
|
68
|
+
start its own; use :meth:`MorphTracing.span_processor` instead."""
|
|
69
|
+
|
|
70
|
+
headers: dict[str, str] | None = None
|
|
71
|
+
"""Extra headers merged onto the OTLP exporter request."""
|
|
72
|
+
|
|
73
|
+
debug: bool | None = None
|
|
74
|
+
"""Verbose ``[morph-tracing]`` logging. Defaults to
|
|
75
|
+
``MORPH_TRACING_DEBUG=1``."""
|
|
76
|
+
|
|
77
|
+
evals: EvalSelection | None = None
|
|
78
|
+
"""Default evals to run on every interaction's trace. Overridable per-interaction via
|
|
79
|
+
``begin({"evals": ...})``. Requires ``begin()`` so the trace carries an event id.
|
|
80
|
+
Omit for none."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TraceContext(BaseModel):
|
|
84
|
+
"""Identity + content for a single traced AI interaction."""
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(extra="forbid")
|
|
87
|
+
|
|
88
|
+
event_id: str | None = None
|
|
89
|
+
"""Stable identifier for the interaction; auto-generated if omitted."""
|
|
90
|
+
|
|
91
|
+
user_id: str | None = None
|
|
92
|
+
"""End-user identifier."""
|
|
93
|
+
|
|
94
|
+
convo_id: str | None = None
|
|
95
|
+
"""Conversation/session identifier grouping related interactions."""
|
|
96
|
+
|
|
97
|
+
event: str | None = None
|
|
98
|
+
"""Event name (defaults to ``"ai_generation"`` on the backend)."""
|
|
99
|
+
|
|
100
|
+
input: str | None = None
|
|
101
|
+
"""User input for this interaction."""
|
|
102
|
+
|
|
103
|
+
properties: dict[str, str] | None = None
|
|
104
|
+
"""Arbitrary string metadata propagated onto spans."""
|
|
105
|
+
|
|
106
|
+
evals: EvalSelection | None = None
|
|
107
|
+
"""Evals to run automatically on this interaction's trace. Overrides any default set in
|
|
108
|
+
``morph_tracing({"evals": ...})``. Omit to inherit the default (or run none)."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SpanParams(BaseModel):
|
|
112
|
+
"""Parameters for a manual task span."""
|
|
113
|
+
|
|
114
|
+
model_config = ConfigDict(extra="forbid")
|
|
115
|
+
|
|
116
|
+
name: str
|
|
117
|
+
properties: dict[str, str] | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ToolParams(BaseModel):
|
|
121
|
+
"""Parameters for a tool span."""
|
|
122
|
+
|
|
123
|
+
model_config = ConfigDict(extra="forbid")
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
version: int | None = None
|
|
127
|
+
properties: dict[str, str] | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TrackToolParams(BaseModel):
|
|
131
|
+
"""Record a tool invocation that has already completed."""
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
|
134
|
+
|
|
135
|
+
name: str
|
|
136
|
+
input: Any | None = None
|
|
137
|
+
output: Any | None = None
|
|
138
|
+
duration_ms: int | None = None
|
|
139
|
+
error: str | BaseException | None = None
|
|
140
|
+
properties: dict[str, str] | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class FinishOptions(BaseModel):
|
|
144
|
+
"""Options for ending an interaction."""
|
|
145
|
+
|
|
146
|
+
model_config = ConfigDict(extra="forbid")
|
|
147
|
+
|
|
148
|
+
output: str
|
|
149
|
+
properties: dict[str, str] | None = None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MetadataOptions(BaseModel):
|
|
153
|
+
"""Metadata payload for the Vercel AI SDK / OTel attribution."""
|
|
154
|
+
|
|
155
|
+
model_config = ConfigDict(extra="forbid")
|
|
156
|
+
|
|
157
|
+
user_id: str
|
|
158
|
+
convo_id: str | None = None
|
|
159
|
+
event_name: str | None = None
|
|
160
|
+
event_id: str | None = None
|
|
161
|
+
properties: dict[str, str] | None = None
|
|
162
|
+
"""Custom tags, e.g. ``{"source": "support-bot"}`` ->
|
|
163
|
+
``association.properties.source``."""
|