softprobe 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.
softprobe/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """Softprobe Python instrumentation SDK."""
2
+
3
+ from typing import Any
4
+
5
+ from softprobe.client import SoftprobeClient
6
+ from softprobe.observation import Generation, Observation
7
+ from softprobe.redaction import default_redact_keys, redact_value
8
+ from softprobe.scores import build_score_request
9
+ from softprobe.tools import (
10
+ accumulate_tool_call_deltas,
11
+ finalize_tool_call_deltas,
12
+ normalize_tool_calls,
13
+ normalize_tool_definitions,
14
+ record_tool_calls,
15
+ record_tool_definitions,
16
+ tool_result_event_payload,
17
+ )
18
+ from softprobe.types import OBSERVATION_TYPES
19
+
20
+ __all__ = [
21
+ "OBSERVATION_TYPES",
22
+ "Generation",
23
+ "Observation",
24
+ "SoftprobeClient",
25
+ "accumulate_tool_call_deltas",
26
+ "build_score_request",
27
+ "default_redact_keys",
28
+ "finalize_tool_call_deltas",
29
+ "normalize_tool_calls",
30
+ "normalize_tool_definitions",
31
+ "propagate_attributes",
32
+ "record_tool_calls",
33
+ "record_tool_definitions",
34
+ "redact_value",
35
+ "tool_result_event_payload",
36
+ "observe_openai",
37
+ "CallbackHandler",
38
+ ]
39
+
40
+
41
+ def __getattr__(name: str) -> Any:
42
+ if name == "observe_openai":
43
+ from softprobe.openai import observe_openai
44
+
45
+ return observe_openai
46
+ if name == "propagate_attributes":
47
+ from softprobe.propagation import propagate_attributes
48
+
49
+ return propagate_attributes
50
+ if name == "CallbackHandler":
51
+ from softprobe.langchain import CallbackHandler
52
+
53
+ return CallbackHandler
54
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ from softprobe.redaction import serialize_captured
6
+ from softprobe.types import Attributes
7
+
8
+
9
+ def apply_metadata(attrs: Attributes, metadata: Mapping[str, Any] | None) -> None:
10
+ if not metadata:
11
+ return
12
+ for key, value in metadata.items():
13
+ if value is None:
14
+ continue
15
+ if isinstance(value, (str, int, float, bool)):
16
+ attrs[f"sp.metadata.{key}"] = value
17
+ else:
18
+ serialized = serialize_captured(value, [])
19
+ if serialized is not None:
20
+ attrs[f"sp.metadata.{key}"] = serialized
21
+
22
+
23
+ def build_observation_attributes(
24
+ *,
25
+ observation_type: str,
26
+ attributes: Mapping[str, Any] | None = None,
27
+ input: Any | None = None,
28
+ output: Any | None = None,
29
+ session_id: str | None = None,
30
+ user_id: str | None = None,
31
+ release: str | None = None,
32
+ tags: list[str] | None = None,
33
+ metadata: Mapping[str, Any] | None = None,
34
+ version: str | None = None,
35
+ trace_name: str | None = None,
36
+ redact_keys: list[str],
37
+ ) -> Attributes:
38
+ attrs: Attributes = dict(attributes or {})
39
+ attrs["sp.observation.type"] = observation_type
40
+ if session_id:
41
+ attrs["sp.session.id"] = session_id
42
+ attrs["gen_ai.conversation.id"] = session_id
43
+ if user_id:
44
+ attrs["sp.user.id"] = user_id
45
+ if release:
46
+ attrs["sp.release"] = release
47
+ if tags is not None:
48
+ attrs["sp.tags"] = list(tags)
49
+ if version:
50
+ attrs["sp.version"] = version
51
+ if trace_name:
52
+ attrs["sp.trace.name"] = trace_name
53
+ apply_metadata(attrs, metadata)
54
+ serialized_input = serialize_captured(input, redact_keys)
55
+ serialized_output = serialize_captured(output, redact_keys)
56
+ if serialized_input is not None:
57
+ attrs["sp.input"] = serialized_input
58
+ if serialized_output is not None:
59
+ attrs["sp.output"] = serialized_output
60
+ return attrs
61
+
62
+
63
+ def build_generation_attributes(
64
+ *,
65
+ attributes: Mapping[str, Any] | None = None,
66
+ input: Any | None = None,
67
+ output: Any | None = None,
68
+ session_id: str | None = None,
69
+ user_id: str | None = None,
70
+ release: str | None = None,
71
+ tags: list[str] | None = None,
72
+ metadata: Mapping[str, Any] | None = None,
73
+ version: str | None = None,
74
+ trace_name: str | None = None,
75
+ redact_keys: list[str],
76
+ model: str | None = None,
77
+ response_model: str | None = None,
78
+ provider: str | None = None,
79
+ operation_name: str | None = None,
80
+ temperature: float | None = None,
81
+ max_tokens: int | None = None,
82
+ usage: Mapping[str, int] | None = None,
83
+ cost: Mapping[str, float] | None = None,
84
+ completion_start_time: str | None = None,
85
+ response_id: str | None = None,
86
+ finish_reasons: list[str] | None = None,
87
+ prompt: Mapping[str, Any] | None = None,
88
+ ) -> Attributes:
89
+ attrs = build_observation_attributes(
90
+ observation_type="generation",
91
+ attributes=attributes,
92
+ input=input,
93
+ output=output,
94
+ session_id=session_id,
95
+ user_id=user_id,
96
+ release=release,
97
+ tags=tags,
98
+ metadata=metadata,
99
+ version=version,
100
+ trace_name=trace_name,
101
+ redact_keys=redact_keys,
102
+ )
103
+ if operation_name:
104
+ attrs["gen_ai.operation.name"] = operation_name
105
+ if provider:
106
+ attrs["gen_ai.provider.name"] = provider
107
+ if model:
108
+ attrs["gen_ai.request.model"] = model
109
+ if response_model:
110
+ attrs["gen_ai.response.model"] = response_model
111
+ if temperature is not None:
112
+ attrs["gen_ai.request.temperature"] = temperature
113
+ if max_tokens is not None:
114
+ attrs["gen_ai.request.max_tokens"] = max_tokens
115
+ if usage:
116
+ if "input_tokens" in usage:
117
+ attrs["gen_ai.usage.input_tokens"] = usage["input_tokens"]
118
+ if "output_tokens" in usage:
119
+ attrs["gen_ai.usage.output_tokens"] = usage["output_tokens"]
120
+ if "total_tokens" in usage:
121
+ attrs["gen_ai.usage.total_tokens"] = usage["total_tokens"]
122
+ elif "input_tokens" in usage or "output_tokens" in usage:
123
+ attrs["gen_ai.usage.total_tokens"] = int(usage.get("input_tokens", 0)) + int(
124
+ usage.get("output_tokens", 0)
125
+ )
126
+ if cost:
127
+ if "input" in cost:
128
+ attrs["sp.cost.input"] = cost["input"]
129
+ if "output" in cost:
130
+ attrs["sp.cost.output"] = cost["output"]
131
+ if "total" in cost:
132
+ attrs["sp.cost.total"] = cost["total"]
133
+ if completion_start_time:
134
+ attrs["sp.generation.completion_start_time"] = completion_start_time
135
+ if response_id:
136
+ attrs["gen_ai.response.id"] = response_id
137
+ if finish_reasons is not None:
138
+ attrs["gen_ai.response.finish_reasons"] = list(finish_reasons)
139
+ if prompt:
140
+ if prompt.get("id"):
141
+ attrs["sp.prompt.id"] = str(prompt["id"])
142
+ if prompt.get("name"):
143
+ attrs["sp.prompt.name"] = str(prompt["name"])
144
+ if prompt.get("version") is not None:
145
+ attrs["sp.prompt.version"] = int(prompt["version"])
146
+ return attrs
softprobe/client.py ADDED
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager, contextmanager
4
+ from datetime import datetime
5
+ from typing import Any, AsyncIterator, Iterator, Mapping
6
+
7
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
8
+ from opentelemetry.sdk.resources import Resource
9
+ from opentelemetry.sdk.trace import TracerProvider
10
+ from opentelemetry.sdk.trace.export import (
11
+ BatchSpanProcessor,
12
+ SimpleSpanProcessor,
13
+ SpanExporter,
14
+ )
15
+ from opentelemetry.semconv.resource import ResourceAttributes
16
+
17
+ from softprobe.config import (
18
+ MissingSoftprobeCredentialsError,
19
+ derive_otlp_endpoint,
20
+ resolve_softprobe_config_from_env,
21
+ )
22
+ from softprobe.observation import (
23
+ Generation,
24
+ Observation,
25
+ ObservationRuntime,
26
+ observation_scope,
27
+ start_generation,
28
+ start_observation,
29
+ )
30
+ from softprobe.propagation import propagate_attributes
31
+ from softprobe.redaction import default_redact_keys
32
+ from softprobe.scores import HttpScoreTransport, build_score_request
33
+ from softprobe.tools import tool_span_attributes
34
+ from softprobe.types import ObservationType, ScoreDataType, ScoreSource, ScoreTransport
35
+
36
+
37
+ class SoftprobeClient:
38
+ def __init__(
39
+ self,
40
+ *,
41
+ public_key: str,
42
+ base_url: str,
43
+ otlp_endpoint: str | None = None,
44
+ service_name: str = "softprobe-app",
45
+ service_version: str | None = None,
46
+ environment: str | None = None,
47
+ release: str | None = None,
48
+ session_id: str | None = None,
49
+ user_id: str | None = None,
50
+ tags: list[str] | None = None,
51
+ redact_keys: list[str] | None = None,
52
+ timeout_ms: int = 10_000,
53
+ headers: Mapping[str, str] | None = None,
54
+ span_exporter: SpanExporter | None = None,
55
+ score_transport: ScoreTransport | None = None,
56
+ use_simple_processor: bool = False,
57
+ ) -> None:
58
+ if not public_key or not public_key.strip():
59
+ raise ValueError("public_key is required")
60
+ if not base_url or not base_url.strip():
61
+ raise ValueError("base_url is required")
62
+ base_url = base_url.strip()
63
+ resolved_otlp = (
64
+ otlp_endpoint.strip()
65
+ if otlp_endpoint and otlp_endpoint.strip()
66
+ else derive_otlp_endpoint(base_url)
67
+ )
68
+ if span_exporter is None and not resolved_otlp:
69
+ raise ValueError("otlp_endpoint is required")
70
+
71
+ resource_attrs: dict[str, str] = {
72
+ ResourceAttributes.SERVICE_NAME: service_name,
73
+ "telemetry.sdk.name": "softprobe",
74
+ }
75
+ if service_version:
76
+ resource_attrs[ResourceAttributes.SERVICE_VERSION] = service_version
77
+ if environment:
78
+ resource_attrs["deployment.environment.name"] = environment
79
+
80
+ exporter = span_exporter or OTLPSpanExporter(
81
+ endpoint=resolved_otlp,
82
+ headers={
83
+ "Authorization": f"Bearer {public_key}",
84
+ **dict(headers or {}),
85
+ },
86
+ timeout=timeout_ms / 1000.0,
87
+ )
88
+ processor = (
89
+ SimpleSpanProcessor(exporter)
90
+ if use_simple_processor
91
+ else BatchSpanProcessor(exporter)
92
+ )
93
+ self._provider = TracerProvider(resource=Resource.create(resource_attrs))
94
+ self._provider.add_span_processor(processor)
95
+ # Avoid set_tracer_provider so multiple clients/tests can coexist.
96
+ # Parent/child linking uses explicit context attach on observations.
97
+
98
+ self._runtime = ObservationRuntime(
99
+ tracer=self._provider.get_tracer("softprobe", "0.1.0"),
100
+ redact_keys=redact_keys or default_redact_keys(),
101
+ session_id=session_id,
102
+ user_id=user_id,
103
+ release=release,
104
+ tags=tags,
105
+ )
106
+ self._score_transport = score_transport or HttpScoreTransport(
107
+ base_url,
108
+ public_key.strip(),
109
+ headers or {},
110
+ timeout_ms,
111
+ )
112
+ self._shut_down = False
113
+
114
+ @classmethod
115
+ def from_env(cls, **overrides: Any) -> SoftprobeClient:
116
+ """Build a client from ``SOFTPROBE_*`` environment variables.
117
+
118
+ Requires ``SOFTPROBE_PUBLIC_KEY`` and ``SOFTPROBE_BASE_URL``.
119
+ Keyword overrides are merged on top of the resolved env config.
120
+ """
121
+ cfg = resolve_softprobe_config_from_env()
122
+ if cfg is None:
123
+ raise MissingSoftprobeCredentialsError(
124
+ "Set SOFTPROBE_PUBLIC_KEY and SOFTPROBE_BASE_URL"
125
+ )
126
+ kwargs: dict[str, Any] = {
127
+ "public_key": cfg["public_key"],
128
+ "base_url": cfg["base_url"],
129
+ "otlp_endpoint": cfg.get("otlp_endpoint"),
130
+ }
131
+ if cfg.get("environment"):
132
+ kwargs["environment"] = cfg["environment"]
133
+ if cfg.get("session_id"):
134
+ kwargs["session_id"] = cfg["session_id"]
135
+ if cfg.get("user_id"):
136
+ kwargs["user_id"] = cfg["user_id"]
137
+ if cfg.get("service_name"):
138
+ kwargs["service_name"] = cfg["service_name"]
139
+ kwargs.update(overrides)
140
+ return cls(**kwargs)
141
+
142
+ def start_observation(
143
+ self,
144
+ *,
145
+ name: str,
146
+ as_type: ObservationType = "span",
147
+ attributes: Mapping[str, Any] | None = None,
148
+ input: Any | None = None,
149
+ output: Any | None = None,
150
+ session_id: str | None = None,
151
+ user_id: str | None = None,
152
+ release: str | None = None,
153
+ tags: list[str] | None = None,
154
+ metadata: Mapping[str, Any] | None = None,
155
+ version: str | None = None,
156
+ trace_name: str | None = None,
157
+ parent: Observation | None = None,
158
+ trace_context: Mapping[str, str] | None = None,
159
+ start_time: datetime | float | int | None = None,
160
+ ) -> Observation:
161
+ self._assert_active()
162
+ return start_observation(
163
+ self._runtime,
164
+ name=name,
165
+ as_type=as_type,
166
+ attributes=attributes,
167
+ input=input,
168
+ output=output,
169
+ session_id=session_id,
170
+ user_id=user_id,
171
+ release=release,
172
+ tags=tags,
173
+ metadata=metadata,
174
+ version=version,
175
+ trace_name=trace_name,
176
+ parent=parent,
177
+ trace_context=trace_context,
178
+ start_time=start_time,
179
+ )
180
+
181
+ def start_generation(self, *, name: str, **kwargs: Any) -> Generation:
182
+ self._assert_active()
183
+ return start_generation(self._runtime, name=name, **kwargs)
184
+
185
+ def propagate_attributes(self, **kwargs: Any) -> Any:
186
+ """Context manager that scopes session/user/tags/metadata for nested work."""
187
+ return propagate_attributes(**kwargs)
188
+
189
+ @property
190
+ def tracer(self) -> Any:
191
+ return self._runtime.tracer
192
+
193
+ def start_agent(self, *, name: str, **kwargs: Any) -> Observation:
194
+ return self.start_observation(name=name, as_type="agent", **kwargs)
195
+
196
+ def start_tool(
197
+ self,
198
+ *,
199
+ name: str,
200
+ tool_name: str | None = None,
201
+ tool_call_id: str | None = None,
202
+ kind: str | None = None,
203
+ status: str | None = None,
204
+ index: int | None = None,
205
+ mcp_server: str | None = None,
206
+ mcp_tool: str | None = None,
207
+ attributes: Mapping[str, Any] | None = None,
208
+ **kwargs: Any,
209
+ ) -> Observation:
210
+ merged = dict(attributes or {})
211
+ merged.update(
212
+ tool_span_attributes(
213
+ tool_name=tool_name or name,
214
+ tool_call_id=tool_call_id,
215
+ kind=kind or "function",
216
+ status=status or "ok",
217
+ index=index,
218
+ mcp_server=mcp_server,
219
+ mcp_tool=mcp_tool,
220
+ )
221
+ )
222
+ return self.start_observation(
223
+ name=name, as_type="tool", attributes=merged, **kwargs
224
+ )
225
+
226
+ def start_chain(self, *, name: str, **kwargs: Any) -> Observation:
227
+ return self.start_observation(name=name, as_type="chain", **kwargs)
228
+
229
+ def start_retriever(self, *, name: str, **kwargs: Any) -> Observation:
230
+ return self.start_observation(name=name, as_type="retriever", **kwargs)
231
+
232
+ def start_evaluator(self, *, name: str, **kwargs: Any) -> Observation:
233
+ return self.start_observation(name=name, as_type="evaluator", **kwargs)
234
+
235
+ def start_embedding(self, *, name: str, **kwargs: Any) -> Observation:
236
+ return self.start_observation(name=name, as_type="embedding", **kwargs)
237
+
238
+ def start_guardrail(self, *, name: str, **kwargs: Any) -> Observation:
239
+ return self.start_observation(name=name, as_type="guardrail", **kwargs)
240
+
241
+ def start_event(self, *, name: str, **kwargs: Any) -> Observation:
242
+ return self.start_observation(name=name, as_type="event", **kwargs)
243
+
244
+ @contextmanager
245
+ def observation(self, *, name: str, **kwargs: Any) -> Iterator[Observation]:
246
+ obs = self.start_observation(name=name, **kwargs)
247
+ with observation_scope(obs) as scoped:
248
+ yield scoped
249
+
250
+ @contextmanager
251
+ def generation(self, *, name: str, **kwargs: Any) -> Iterator[Generation]:
252
+ gen = self.start_generation(name=name, **kwargs)
253
+ with observation_scope(gen) as scoped:
254
+ yield scoped # type: ignore[misc]
255
+
256
+ @asynccontextmanager
257
+ async def async_observation(
258
+ self, *, name: str, **kwargs: Any
259
+ ) -> AsyncIterator[Observation]:
260
+ with self.observation(name=name, **kwargs) as obs:
261
+ yield obs
262
+
263
+ @asynccontextmanager
264
+ async def async_generation(
265
+ self, *, name: str, **kwargs: Any
266
+ ) -> AsyncIterator[Generation]:
267
+ with self.generation(name=name, **kwargs) as gen:
268
+ yield gen
269
+
270
+ def create_score(
271
+ self,
272
+ *,
273
+ score_id: str,
274
+ name: str,
275
+ data_type: ScoreDataType,
276
+ source: ScoreSource,
277
+ timestamp: str | datetime | None = None,
278
+ trace_id: str | None = None,
279
+ span_id: str | None = None,
280
+ session_id: str | None = None,
281
+ numeric_value: float | None = None,
282
+ string_value: str | None = None,
283
+ boolean_value: bool | None = None,
284
+ comment: str | None = None,
285
+ config_id: str | None = None,
286
+ author_id: str | None = None,
287
+ metadata: Mapping[str, str] | None = None,
288
+ ) -> None:
289
+ self._assert_active()
290
+ request = build_score_request(
291
+ score_id=score_id,
292
+ name=name,
293
+ data_type=data_type,
294
+ source=source,
295
+ timestamp=timestamp,
296
+ trace_id=trace_id,
297
+ span_id=span_id,
298
+ session_id=session_id,
299
+ numeric_value=numeric_value,
300
+ string_value=string_value,
301
+ boolean_value=boolean_value,
302
+ comment=comment,
303
+ config_id=config_id,
304
+ author_id=author_id,
305
+ metadata=metadata,
306
+ )
307
+ self._score_transport.create_score(request)
308
+
309
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
310
+ return bool(self._provider.force_flush(timeout_millis))
311
+
312
+ def flush(self, timeout_millis: int = 30_000) -> bool:
313
+ """Alias for :meth:`force_flush`."""
314
+ return self.force_flush(timeout_millis)
315
+
316
+ def shutdown(self) -> None:
317
+ if self._shut_down:
318
+ return
319
+ self._shut_down = True
320
+ self._provider.shutdown()
321
+
322
+ def _assert_active(self) -> None:
323
+ if self._shut_down:
324
+ raise RuntimeError("SoftprobeClient has been shut down")
softprobe/config.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any, Mapping, TypedDict
5
+
6
+
7
+ class ResolvedSoftprobeConfig(TypedDict, total=False):
8
+ public_key: str
9
+ base_url: str
10
+ otlp_endpoint: str
11
+ environment: str
12
+ session_id: str
13
+ user_id: str
14
+ service_name: str
15
+
16
+
17
+ class MissingSoftprobeCredentialsError(ValueError):
18
+ """Raised when Softprobe credentials cannot be resolved."""
19
+
20
+
21
+ def as_non_empty_string(value: Any) -> str | None:
22
+ if not isinstance(value, str):
23
+ return None
24
+ trimmed = value.strip()
25
+ return trimmed if trimmed else None
26
+
27
+
28
+ def derive_otlp_endpoint(base_url: str, explicit: str | None = None) -> str:
29
+ if explicit and explicit.strip():
30
+ return explicit.strip()
31
+ return f"{base_url.rstrip('/')}/v1/traces"
32
+
33
+
34
+ def resolve_softprobe_config_from_env(
35
+ env: Mapping[str, str | None] | None = None,
36
+ ) -> ResolvedSoftprobeConfig | None:
37
+ source = env if env is not None else os.environ
38
+ public_key = as_non_empty_string(source.get("SOFTPROBE_PUBLIC_KEY"))
39
+ base_url = as_non_empty_string(source.get("SOFTPROBE_BASE_URL"))
40
+ if not public_key or not base_url:
41
+ return None
42
+ return ResolvedSoftprobeConfig(
43
+ public_key=public_key,
44
+ base_url=base_url,
45
+ otlp_endpoint=derive_otlp_endpoint(
46
+ base_url,
47
+ as_non_empty_string(source.get("SOFTPROBE_OTLP_ENDPOINT")),
48
+ ),
49
+ environment=as_non_empty_string(source.get("SOFTPROBE_ENVIRONMENT")),
50
+ session_id=as_non_empty_string(source.get("SOFTPROBE_SESSION_ID")),
51
+ user_id=as_non_empty_string(source.get("SOFTPROBE_USER_ID")),
52
+ service_name=as_non_empty_string(source.get("SOFTPROBE_SERVICE_NAME")),
53
+ )
54
+
55
+
56
+ def resolve_softprobe_config_from_mapping(raw: Mapping[str, Any]) -> ResolvedSoftprobeConfig:
57
+ public_key = as_non_empty_string(raw.get("public_key") or raw.get("publicKey"))
58
+ base_url = as_non_empty_string(raw.get("base_url") or raw.get("baseUrl"))
59
+ if not public_key or not base_url:
60
+ raise MissingSoftprobeCredentialsError("public_key and base_url are required")
61
+ explicit = as_non_empty_string(raw.get("otlp_endpoint") or raw.get("otlpEndpoint"))
62
+ return ResolvedSoftprobeConfig(
63
+ public_key=public_key,
64
+ base_url=base_url,
65
+ otlp_endpoint=derive_otlp_endpoint(base_url, explicit),
66
+ environment=as_non_empty_string(raw.get("environment")),
67
+ session_id=as_non_empty_string(raw.get("session_id") or raw.get("sessionId")),
68
+ user_id=as_non_empty_string(raw.get("user_id") or raw.get("userId")),
69
+ service_name=as_non_empty_string(raw.get("service_name") or raw.get("serviceName")),
70
+ )
softprobe/identity.py ADDED
@@ -0,0 +1,75 @@
1
+ """Resolve product conversation / user identity from the builder's world.
2
+
3
+ Priority: LangChain/LangGraph run metadata (incl. configurable) →
4
+ explicit Softprobe fallbacks → SOFTPROBE_* env.
5
+ Softprobe never invents a session id.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Mapping
12
+
13
+ SESSION_KEYS = (
14
+ "thread_id",
15
+ "threadId",
16
+ "session_id",
17
+ "sessionId",
18
+ "conversation_id",
19
+ "conversationId",
20
+ "chat_id",
21
+ "chatId",
22
+ )
23
+
24
+ USER_KEYS = ("user_id", "userId", "user")
25
+
26
+
27
+ def _as_non_empty(value: Any) -> str | None:
28
+ if not isinstance(value, str):
29
+ return None
30
+ trimmed = value.strip()
31
+ return trimmed or None
32
+
33
+
34
+ def _first_key(sources: list[Mapping[str, Any] | None], keys: tuple[str, ...]) -> str | None:
35
+ for source in sources:
36
+ if not source:
37
+ continue
38
+ for key in keys:
39
+ found = _as_non_empty(source.get(key))
40
+ if found:
41
+ return found
42
+ return None
43
+
44
+
45
+ def identity_sources_from_metadata(
46
+ metadata: Mapping[str, Any] | None,
47
+ ) -> list[Mapping[str, Any] | None]:
48
+ if not metadata:
49
+ return []
50
+ configurable = metadata.get("configurable")
51
+ if isinstance(configurable, Mapping):
52
+ return [configurable, metadata]
53
+ return [metadata]
54
+
55
+
56
+ def resolve_run_identity(
57
+ *,
58
+ metadata: Mapping[str, Any] | None = None,
59
+ fallback_session_id: str | None = None,
60
+ fallback_user_id: str | None = None,
61
+ env: Mapping[str, str | None] | None = None,
62
+ ) -> tuple[str | None, str | None]:
63
+ source_env = env if env is not None else os.environ
64
+ from_meta = identity_sources_from_metadata(metadata)
65
+ session_id = (
66
+ _first_key(from_meta, SESSION_KEYS)
67
+ or _as_non_empty(fallback_session_id)
68
+ or _as_non_empty(source_env.get("SOFTPROBE_SESSION_ID"))
69
+ )
70
+ user_id = (
71
+ _first_key(from_meta, USER_KEYS)
72
+ or _as_non_empty(fallback_user_id)
73
+ or _as_non_empty(source_env.get("SOFTPROBE_USER_ID"))
74
+ )
75
+ return session_id, user_id