mingx 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.
mingx/genai/io.py ADDED
@@ -0,0 +1,439 @@
1
+ """
2
+ GenAI 输入/输出记录:优先使用 Span Event,符合 OTEL GenAI 规范。
3
+
4
+ 与 Traceloop 等 SDK 一致:模型的输入/输出优先通过 Event 记录,避免大 payload
5
+ 撑大 Span、便于后端对 Event 单独采样或脱敏。也可选记录为 Span 属性。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, Dict, List, Literal, Optional
12
+
13
+ from mingx.genai.attributes import (
14
+ GEN_AI_INPUT_MESSAGES,
15
+ GEN_AI_OUTPUT_MESSAGES,
16
+ GEN_AI_SYSTEM_INSTRUCTIONS,
17
+ )
18
+
19
+ RecordInputOutputAs = Literal["events", "attributes", "none"]
20
+
21
+ # Event 名称(与 OTEL GenAI 语义一致,便于后端识别)
22
+ GEN_AI_EVENT_INPUT = "gen_ai.input"
23
+ GEN_AI_EVENT_OUTPUT = "gen_ai.output"
24
+ GEN_AI_EVENT_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
25
+
26
+
27
+ def _coerce_attribute_value(value: Any) -> Any:
28
+ """OTEL 属性值:str / int / float / bool / Sequence 或转 str;None 转为空串。"""
29
+ if value is None:
30
+ return ""
31
+ if isinstance(value, (str, int, float, bool)):
32
+ return value
33
+ if isinstance(value, (list, tuple)):
34
+ return [_coerce_attribute_value(x) for x in value]
35
+ if isinstance(value, dict):
36
+ return json.dumps(value, ensure_ascii=False)
37
+ return str(value)
38
+
39
+
40
+ def record_llm_input_output(
41
+ span: Any,
42
+ input_messages: List[Dict[str, Any]],
43
+ output_messages: List[Dict[str, Any]],
44
+ *,
45
+ record_as: RecordInputOutputAs = "events",
46
+ system_instructions: Optional[List[Dict[str, Any]]] = None,
47
+ max_length: Optional[int] = None,
48
+ ) -> None:
49
+ """
50
+ 在 Span 上记录 LLM 输入/输出,优先使用 Event(推荐)。
51
+
52
+ 与 OpenTelemetry GenAI 语义一致:gen_ai.input.messages、gen_ai.output.messages。
53
+ 使用 Event 时:Span 本体保持精简,后端可对 Event 单独采样、脱敏或存储。
54
+
55
+ Args:
56
+ span: OpenTelemetry Span(需支持 add_event / set_attribute)。
57
+ input_messages: 输入消息列表,每项建议 { "role": "user"|"assistant"|"system", "content": "..." }。
58
+ output_messages: 输出消息列表,结构同上。
59
+ record_as: "events"(默认,推荐)| "attributes" | "none"。
60
+ system_instructions: 可选,系统指令(单独一条 Event 或属性)。
61
+ max_length: 可选,对单条 content 截断长度(字符),避免过大。
62
+ """
63
+ if record_as == "none":
64
+ return
65
+ if not span.is_recording():
66
+ return
67
+
68
+ def _truncate(obj: Any) -> Any:
69
+ if max_length is None:
70
+ return obj
71
+ if isinstance(obj, dict):
72
+ if "content" in obj and isinstance(obj["content"], str) and len(obj["content"]) > max_length:
73
+ return {**obj, "content": obj["content"][:max_length] + "..."}
74
+ return {k: _truncate(v) for k, v in obj.items()}
75
+ if isinstance(obj, list):
76
+ return [_truncate(x) for x in obj]
77
+ return obj
78
+
79
+ input_messages = _truncate(input_messages)
80
+ output_messages = _truncate(output_messages)
81
+ payload_input = json.dumps(input_messages, ensure_ascii=False)
82
+ payload_output = json.dumps(output_messages, ensure_ascii=False)
83
+
84
+ if record_as == "events":
85
+ span.add_event(GEN_AI_EVENT_INPUT, attributes={GEN_AI_INPUT_MESSAGES: payload_input})
86
+ span.add_event(GEN_AI_EVENT_OUTPUT, attributes={GEN_AI_OUTPUT_MESSAGES: payload_output})
87
+ if system_instructions is not None:
88
+ payload_sys = json.dumps(system_instructions, ensure_ascii=False)
89
+ span.add_event(GEN_AI_EVENT_SYSTEM_INSTRUCTIONS, attributes={GEN_AI_SYSTEM_INSTRUCTIONS: payload_sys})
90
+ elif record_as == "attributes":
91
+ span.set_attribute(GEN_AI_INPUT_MESSAGES, payload_input)
92
+ span.set_attribute(GEN_AI_OUTPUT_MESSAGES, payload_output)
93
+ if system_instructions is not None:
94
+ span.set_attribute(GEN_AI_SYSTEM_INSTRUCTIONS, json.dumps(system_instructions, ensure_ascii=False))
95
+
96
+
97
+ def _is_langchain_message(obj: Any) -> bool:
98
+ """Duck-typing: LangChain BaseMessage 有 type 与 content。"""
99
+ return (
100
+ obj is not None
101
+ and hasattr(obj, "type")
102
+ and hasattr(obj, "content")
103
+ and isinstance(getattr(obj, "type", None), str)
104
+ )
105
+
106
+
107
+ def _message_type_to_role(msg_type: str) -> str:
108
+ """LangChain message type -> GenAI role."""
109
+ t = (msg_type or "").strip().lower()
110
+ if t == "system":
111
+ return "system"
112
+ if t == "human":
113
+ return "user"
114
+ if t == "ai":
115
+ return "assistant"
116
+ return msg_type or "user"
117
+
118
+
119
+ def _message_content_to_serializable(content: Any, truncate_fn: Any) -> Any:
120
+ """将 Message.content(str 或 content blocks 列表)转为可 JSON 的格式。"""
121
+ if content is None:
122
+ return ""
123
+ if isinstance(content, str):
124
+ return truncate_fn(content)
125
+ if isinstance(content, (list, tuple)):
126
+ out: List[Any] = []
127
+ for block in content:
128
+ if isinstance(block, dict):
129
+ out.append(block)
130
+ elif hasattr(block, "model_dump"):
131
+ out.append(block.model_dump())
132
+ elif isinstance(block, str):
133
+ out.append({"type": "text", "text": truncate_fn(block)})
134
+ else:
135
+ out.append({"type": "unknown", "value": str(block)})
136
+ return out
137
+ return str(content)
138
+
139
+
140
+ def _langchain_messages_to_messages_json(
141
+ messages: List[Any],
142
+ truncate_fn: Any,
143
+ ) -> List[Dict[str, Any]]:
144
+ """将 LangChain Message 列表转为 [{"role": "...", "content": ...}, ...] 的 JSON 友好结构。"""
145
+ result: List[Dict[str, Any]] = []
146
+ for m in messages:
147
+ if not _is_langchain_message(m):
148
+ return [] # 非全为 message 时交给默认序列化
149
+ msg_type = getattr(m, "type", "user")
150
+ content = getattr(m, "content", None)
151
+ role = _message_type_to_role(msg_type)
152
+ result.append({
153
+ "role": role,
154
+ "content": _message_content_to_serializable(content, truncate_fn),
155
+ })
156
+ return result
157
+
158
+
159
+ def _serialize_span_io(
160
+ data: Any,
161
+ max_length: Optional[int],
162
+ ) -> str:
163
+ def _truncate_str(s: str) -> str:
164
+ if max_length is not None and len(s) > max_length:
165
+ return s[:max_length] + "..."
166
+ return s
167
+
168
+ def _to_serializable(obj: Any) -> Any:
169
+ if obj is None:
170
+ return None
171
+ if isinstance(obj, str):
172
+ return _truncate_str(obj)
173
+ if isinstance(obj, dict):
174
+ return {k: _to_serializable(v) for k, v in obj.items()}
175
+ if isinstance(obj, (list, tuple)):
176
+ seq = list(obj)
177
+ if seq and _is_langchain_message(seq[0]) and all(_is_langchain_message(x) for x in seq):
178
+ return _langchain_messages_to_messages_json(seq, _truncate_str)
179
+ return [_to_serializable(x) for x in obj]
180
+ if hasattr(obj, "page_content") and hasattr(obj, "metadata"):
181
+ return {"page_content": _truncate_str(getattr(obj, "page_content", "") or ""), "metadata": getattr(obj, "metadata", {}) or {}}
182
+ # 如 PromptValue 等带 .messages 的对象,统一转为 [{"role","content"}] 的 JSON
183
+ if hasattr(obj, "messages"):
184
+ msgs = getattr(obj, "messages", None)
185
+ if isinstance(msgs, (list, tuple)) and msgs and all(_is_langchain_message(m) for m in msgs):
186
+ return _langchain_messages_to_messages_json(list(msgs), _truncate_str)
187
+ if _is_langchain_message(obj):
188
+ role = _message_type_to_role(getattr(obj, "type", "user"))
189
+ content = _message_content_to_serializable(getattr(obj, "content", None), _truncate_str)
190
+ return [{"role": role, "content": content}]
191
+ try:
192
+ json.dumps(obj)
193
+ return obj
194
+ except (TypeError, ValueError):
195
+ return str(obj)
196
+
197
+ return json.dumps(_to_serializable(data), ensure_ascii=False)
198
+
199
+
200
+ def record_span_input(
201
+ span: Any,
202
+ input_data: Any,
203
+ *,
204
+ record_as: RecordInputOutputAs = "events",
205
+ max_length: Optional[int] = None,
206
+ attributes: Optional[Dict[str, Any]] = None,
207
+ event_attributes: Optional[Dict[str, Any]] = None,
208
+ ) -> None:
209
+ """
210
+ 在 Span 上仅记录输入。可与 record_span_output 分开调用,按需只添加输入或只添加输出。
211
+ attributes: 自定义 Span 属性(会写入当前 Span)。
212
+ event_attributes: 自定义 Event 属性(record_as=events 时合并到 gen_ai.input 的 attributes)。
213
+ """
214
+ if record_as == "none" and not (attributes or event_attributes):
215
+ return
216
+ if not span.is_recording():
217
+ return
218
+ if attributes:
219
+ for k, v in attributes.items():
220
+ span.set_attribute(k, _coerce_attribute_value(v))
221
+ if record_as != "none":
222
+ payload = _serialize_span_io(input_data, max_length)
223
+ ev_attrs = {GEN_AI_INPUT_MESSAGES: payload}
224
+ if event_attributes:
225
+ for k, v in event_attributes.items():
226
+ ev_attrs[k] = _coerce_attribute_value(v)
227
+ if record_as == "events":
228
+ span.add_event(GEN_AI_EVENT_INPUT, attributes=ev_attrs)
229
+ elif record_as == "attributes":
230
+ span.set_attribute(GEN_AI_INPUT_MESSAGES, payload)
231
+ for k, v in (event_attributes or {}).items():
232
+ span.set_attribute(k, _coerce_attribute_value(v))
233
+
234
+
235
+ def record_span_output(
236
+ span: Any,
237
+ output_data: Any,
238
+ *,
239
+ record_as: RecordInputOutputAs = "events",
240
+ max_length: Optional[int] = None,
241
+ attributes: Optional[Dict[str, Any]] = None,
242
+ event_attributes: Optional[Dict[str, Any]] = None,
243
+ ) -> None:
244
+ """
245
+ 在 Span 上仅记录输出。可与 record_span_input 分开调用,按需只添加输入或只添加输出。
246
+ attributes: 自定义 Span 属性(会写入当前 Span)。
247
+ event_attributes: 自定义 Event 属性(record_as=events 时合并到 gen_ai.output 的 attributes)。
248
+ """
249
+ if record_as == "none" and not (attributes or event_attributes):
250
+ return
251
+ if not span.is_recording():
252
+ return
253
+ if attributes:
254
+ for k, v in attributes.items():
255
+ span.set_attribute(k, _coerce_attribute_value(v))
256
+ if record_as != "none":
257
+ payload = _serialize_span_io(output_data, max_length)
258
+ ev_attrs = {GEN_AI_OUTPUT_MESSAGES: payload}
259
+ if event_attributes:
260
+ for k, v in event_attributes.items():
261
+ ev_attrs[k] = _coerce_attribute_value(v)
262
+ if record_as == "events":
263
+ span.add_event(GEN_AI_EVENT_OUTPUT, attributes=ev_attrs)
264
+ elif record_as == "attributes":
265
+ span.set_attribute(GEN_AI_OUTPUT_MESSAGES, payload)
266
+ for k, v in (event_attributes or {}).items():
267
+ span.set_attribute(k, _coerce_attribute_value(v))
268
+
269
+
270
+ def record_span_input_output(
271
+ span: Any,
272
+ input_data: Any,
273
+ output_data: Any,
274
+ *,
275
+ record_as: RecordInputOutputAs = "events",
276
+ max_length: Optional[int] = None,
277
+ ) -> None:
278
+ """
279
+ 在 Span 上记录任意类型的输入/输出(chain、tool、retriever 等),优先使用 Event。
280
+
281
+ 与 record_llm_input_output 一致:同一套 Event 名与属性键,payload 为 JSON 序列化后的
282
+ input_data / output_data(dict、list、str 等可序列化结构)。
283
+
284
+ Args:
285
+ span: OpenTelemetry Span。
286
+ input_data: 可 JSON 序列化的输入(如 chain 的 inputs、tool 的 input_str、retriever 的 query)。
287
+ output_data: 可 JSON 序列化的输出(如 chain 的 outputs、tool 的 output、retriever 的 documents)。
288
+ record_as: "events"(默认)| "attributes" | "none"。
289
+ max_length: 可选,对字符串类 content 截断长度。
290
+ """
291
+ if record_as == "none":
292
+ return
293
+ if not span.is_recording():
294
+ return
295
+ payload_input = _serialize_span_io(input_data, max_length)
296
+ payload_output = _serialize_span_io(output_data, max_length)
297
+ if record_as == "events":
298
+ span.add_event(GEN_AI_EVENT_INPUT, attributes={GEN_AI_INPUT_MESSAGES: payload_input})
299
+ span.add_event(GEN_AI_EVENT_OUTPUT, attributes={GEN_AI_OUTPUT_MESSAGES: payload_output})
300
+ elif record_as == "attributes":
301
+ span.set_attribute(GEN_AI_INPUT_MESSAGES, payload_input)
302
+ span.set_attribute(GEN_AI_OUTPUT_MESSAGES, payload_output)
303
+
304
+
305
+ def span_input(
306
+ input_data: Any,
307
+ *,
308
+ span: Optional[Any] = None,
309
+ record_as: RecordInputOutputAs = "events",
310
+ max_length: Optional[int] = None,
311
+ attributes: Optional[Dict[str, Any]] = None,
312
+ event_attributes: Optional[Dict[str, Any]] = None,
313
+ ) -> None:
314
+ """
315
+ 向 Span 添加输入(入参)。可与 span_output 分开调用,按需只添加输入或只添加输出。
316
+
317
+ 用法(原生 OTEL 风格):
318
+ with get_tracer().start_as_current_span("call_model") as span:
319
+ span_input({"prompt": "..."})
320
+ result = call_model()
321
+ span_output(result)
322
+
323
+ 若未传 span,则对当前 Span 生效;无活跃 Span 或未 recording 时静默忽略。
324
+ attributes: 自定义 Span 属性。event_attributes: 自定义 Event 属性(合并到 gen_ai.input)。
325
+ """
326
+ from opentelemetry import trace
327
+ s = span if span is not None else trace.get_current_span()
328
+ record_span_input(
329
+ s,
330
+ input_data,
331
+ record_as=record_as,
332
+ max_length=max_length,
333
+ attributes=attributes,
334
+ event_attributes=event_attributes,
335
+ )
336
+
337
+
338
+ def span_output(
339
+ output_data: Any,
340
+ *,
341
+ span: Optional[Any] = None,
342
+ record_as: RecordInputOutputAs = "events",
343
+ max_length: Optional[int] = None,
344
+ attributes: Optional[Dict[str, Any]] = None,
345
+ event_attributes: Optional[Dict[str, Any]] = None,
346
+ ) -> None:
347
+ """
348
+ 向 Span 添加输出(返回值)。可与 span_input 分开调用,按需只添加输入或只添加输出。
349
+
350
+ 用法(原生 OTEL 风格):
351
+ with get_tracer().start_as_current_span("call_model") as span:
352
+ span_input({"prompt": "..."})
353
+ result = call_model()
354
+ span_output(result)
355
+
356
+ 若未传 span,则对当前 Span 生效;无活跃 Span 或未 recording 时静默忽略。
357
+ attributes: 自定义 Span 属性。event_attributes: 自定义 Event 属性(合并到 gen_ai.output)。
358
+ """
359
+ from opentelemetry import trace
360
+ s = span if span is not None else trace.get_current_span()
361
+ record_span_output(
362
+ s,
363
+ output_data,
364
+ record_as=record_as,
365
+ max_length=max_length,
366
+ attributes=attributes,
367
+ event_attributes=event_attributes,
368
+ )
369
+
370
+
371
+ def record_current_span_input_output(
372
+ input_data: Any,
373
+ output_data: Any,
374
+ *,
375
+ record_as: RecordInputOutputAs = "events",
376
+ max_length: Optional[int] = None,
377
+ ) -> None:
378
+ """
379
+ 手动向当前 Span 上报输入/输出(入参作为输入,返回值作为输出,无返回值传 None 或空即可)。
380
+
381
+ 适用于:在 @traced 装饰的函数内、或已有 Span 的上下文中,手动补充或覆盖输入/输出。
382
+ 若当前无活跃 Span 或 Span 未在 recording 则静默忽略。
383
+
384
+ Args:
385
+ input_data: 可序列化的输入(如函数入参、dict、list、str)。
386
+ output_data: 可序列化的输出(如函数返回值);无返回值可传 None 或 {}。
387
+ record_as: "events"(默认)| "attributes" | "none"。
388
+ max_length: 可选,单条字符串截断长度。
389
+ """
390
+ from opentelemetry import trace
391
+ span = trace.get_current_span()
392
+ if not span.is_recording():
393
+ return
394
+ record_span_input_output(
395
+ span,
396
+ input_data,
397
+ output_data,
398
+ record_as=record_as,
399
+ max_length=max_length,
400
+ )
401
+
402
+
403
+ def build_input_messages_from_prompts(prompts: List[str]) -> List[Dict[str, Any]]:
404
+ """将 LangChain 的 prompts(str 列表)转为 GenAI 规范的 input messages 结构。"""
405
+ return [{"role": "user", "content": p} for p in (prompts or [])]
406
+
407
+
408
+ def build_input_messages_from_langchain_messages(
409
+ messages: List[Any],
410
+ max_length: Optional[int] = None,
411
+ ) -> List[Dict[str, Any]]:
412
+ """
413
+ 将 LangChain BaseMessage 列表转为 GenAI 规范的 input messages 结构 [{"role", "content"}, ...]。
414
+ Adapter 在 on_chat_model_start 收到 messages 后,在 on_llm_end 用此函数生成 input_messages。
415
+ """
416
+ if not messages:
417
+ return []
418
+ if not all(_is_langchain_message(m) for m in messages):
419
+ return []
420
+ truncate_fn = (lambda s: s[:max_length] + "..." if max_length is not None and len(s) > max_length else s) if max_length else (lambda s: s)
421
+ return _langchain_messages_to_messages_json(messages, truncate_fn)
422
+
423
+
424
+ def build_output_messages_from_llm_result(response: Any) -> List[Dict[str, Any]]:
425
+ """从 LangChain LLMResult 提取 output messages 结构。"""
426
+ out: List[Dict[str, Any]] = []
427
+ try:
428
+ for gen_list in (response.generations or []):
429
+ for gen in gen_list or []:
430
+ if hasattr(gen, "message") and gen.message:
431
+ msg = gen.message
432
+ content = getattr(msg, "content", None) or ""
433
+ if isinstance(content, str):
434
+ out.append({"role": "assistant", "content": content})
435
+ else:
436
+ out.append({"role": "assistant", "content": str(content)})
437
+ except Exception:
438
+ pass
439
+ return out
@@ -0,0 +1,172 @@
1
+ """
2
+ 各类型 Span 上送属性的数据模型。
3
+
4
+ 将 LLM/Chain/Tool/Retriever 等不同语义的 Span 属性拆成独立模型,
5
+ 通过 to_attributes() 统一输出为 OpenTelemetry 所需的 Dict[str, Any],保证设计清晰、易扩展。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from mingx._default_attributes import (
14
+ MINGX_SPAN_TYPE,
15
+ SPAN_TYPE_CHAIN,
16
+ SPAN_TYPE_MODEL,
17
+ SPAN_TYPE_RETRIEVER,
18
+ SPAN_TYPE_TOOL,
19
+ )
20
+
21
+ from . import attributes as genai_attrs
22
+
23
+ # 适配器层常用键(如 LangChain run_id)
24
+ LC_RUN_ID = "lc.run_id"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class InferenceSpanAttributes:
29
+ """推理/LLM Span 上送属性(gen_ai.request.* 等)。"""
30
+
31
+ operation_name: str
32
+ provider_name: str
33
+ run_id: str
34
+ span_type: str = SPAN_TYPE_MODEL
35
+ model: Optional[str] = None
36
+ temperature: Optional[float] = None
37
+ max_tokens: Optional[int] = None
38
+ top_p: Optional[float] = None
39
+ top_k: Optional[int] = None
40
+ frequency_penalty: Optional[float] = None
41
+ presence_penalty: Optional[float] = None
42
+ stop_sequences: Optional[List[str]] = None
43
+
44
+ def to_attributes(self) -> Dict[str, Any]:
45
+ d = genai_attrs.inference_attributes(
46
+ self.operation_name,
47
+ self.provider_name,
48
+ model=self.model,
49
+ temperature=self.temperature,
50
+ max_tokens=self.max_tokens,
51
+ top_p=self.top_p,
52
+ top_k=self.top_k,
53
+ frequency_penalty=self.frequency_penalty,
54
+ presence_penalty=self.presence_penalty,
55
+ stop_sequences=self.stop_sequences,
56
+ )
57
+ d[LC_RUN_ID] = self.run_id
58
+ d[MINGX_SPAN_TYPE] = self.span_type
59
+ return d
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ChainSpanAttributes:
64
+ """Chain Span 上送属性。"""
65
+
66
+ run_id: str
67
+ operation_name: str = genai_attrs.OPERATION_INVOKE_AGENT
68
+ span_type: str = SPAN_TYPE_CHAIN
69
+ extra: Dict[str, Any] = field(default_factory=dict)
70
+
71
+ def to_attributes(self) -> Dict[str, Any]:
72
+ d: Dict[str, Any] = {
73
+ LC_RUN_ID: self.run_id,
74
+ genai_attrs.GEN_AI_OPERATION_NAME: self.operation_name,
75
+ MINGX_SPAN_TYPE: self.span_type,
76
+ }
77
+ d.update(self.extra)
78
+ return d
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class ToolSpanAttributes:
83
+ """Execute Tool Span 上送属性。"""
84
+
85
+ tool_name: str
86
+ run_id: str
87
+ span_type: str = SPAN_TYPE_TOOL
88
+ tool_description: Optional[str] = None
89
+ tool_call_id: Optional[str] = None
90
+ tool_type: Optional[str] = None
91
+ extra: Dict[str, Any] = field(default_factory=dict)
92
+
93
+ def to_attributes(self) -> Dict[str, Any]:
94
+ d = genai_attrs.execute_tool_attributes(
95
+ self.tool_name,
96
+ tool_description=self.tool_description,
97
+ tool_call_id=self.tool_call_id,
98
+ tool_type=self.tool_type,
99
+ )
100
+ d[LC_RUN_ID] = self.run_id
101
+ d[MINGX_SPAN_TYPE] = self.span_type
102
+ d.update(self.extra)
103
+ return d
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class RetrieverSpanAttributes:
108
+ """Retriever Span 上送属性。"""
109
+
110
+ run_id: str
111
+ operation_name: str = "retriever"
112
+ span_type: str = SPAN_TYPE_RETRIEVER
113
+ extra: Dict[str, Any] = field(default_factory=dict)
114
+
115
+ def to_attributes(self) -> Dict[str, Any]:
116
+ d: Dict[str, Any] = {
117
+ LC_RUN_ID: self.run_id,
118
+ genai_attrs.GEN_AI_OPERATION_NAME: self.operation_name,
119
+ MINGX_SPAN_TYPE: self.span_type,
120
+ }
121
+ d.update(self.extra)
122
+ return d
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # 大模型调用结束时的上送数据:Token 使用、响应元数据、输入/输出消息
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class TokenUsage:
132
+ """Token 使用情况(gen_ai.usage.*),用于推理/Embeddings 等 Span 结束时的属性。"""
133
+
134
+ input_tokens: Optional[int] = None
135
+ output_tokens: Optional[int] = None
136
+
137
+ def apply_to_span(self, span: Any) -> None:
138
+ """将 usage 写入当前 Span 的 gen_ai.usage.* 属性。"""
139
+ if not getattr(span, "is_recording", lambda: False)():
140
+ return
141
+ if self.input_tokens is not None:
142
+ span.set_attribute(genai_attrs.GEN_AI_USAGE_INPUT_TOKENS, self.input_tokens)
143
+ if self.output_tokens is not None:
144
+ span.set_attribute(genai_attrs.GEN_AI_USAGE_OUTPUT_TOKENS, self.output_tokens)
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class InferenceResponseAttributes:
149
+ """大模型调用响应元数据(gen_ai.response.*),在 Span 结束时上送。"""
150
+
151
+ response_model: Optional[str] = None
152
+ finish_reasons: Optional[List[str]] = None
153
+ response_id: Optional[str] = None
154
+
155
+ def apply_to_span(self, span: Any) -> None:
156
+ """将响应元数据写入当前 Span 的 gen_ai.response.* 属性。"""
157
+ if not getattr(span, "is_recording", lambda: False)():
158
+ return
159
+ if self.response_model is not None:
160
+ span.set_attribute(genai_attrs.GEN_AI_RESPONSE_MODEL, self.response_model)
161
+ if self.finish_reasons is not None:
162
+ span.set_attribute(genai_attrs.GEN_AI_RESPONSE_FINISH_REASONS, self.finish_reasons)
163
+ if self.response_id is not None:
164
+ span.set_attribute(genai_attrs.GEN_AI_RESPONSE_ID, self.response_id)
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class InferenceInputOutput:
169
+ """大模型调用的输入/输出消息体,用于 Span 的 Event 或属性记录(由 io.record_llm_input_output 写入)。"""
170
+
171
+ input_messages: List[Dict[str, Any]] = field(default_factory=list)
172
+ output_messages: List[Dict[str, Any]] = field(default_factory=list)