traceAI-minimax 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.
@@ -0,0 +1,106 @@
1
+ import logging
2
+ from importlib import import_module
3
+ from typing import Any, Collection
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ from fi_instrumentation import FITracer, TraceConfig
8
+ from opentelemetry import trace as trace_api
9
+ from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
10
+ from wrapt import wrap_function_wrapper
11
+
12
+ from traceai_minimax._wrappers import (
13
+ _AsyncChatCompletionWrapper,
14
+ _ChatCompletionWrapper,
15
+ )
16
+ from traceai_minimax.version import __version__
17
+
18
+ logger = logging.getLogger(__name__)
19
+ logger.addHandler(logging.NullHandler())
20
+
21
+ _instruments = ("openai >= 1.0.0",)
22
+
23
+
24
+ class MiniMaxInstrumentor(BaseInstrumentor):
25
+ """
26
+ OpenTelemetry instrumentor for MiniMax.
27
+
28
+ MiniMax uses the OpenAI SDK with a custom base_url (https://api.minimax.io/v1).
29
+ This instrumentor wraps OpenAI client methods and only instruments calls
30
+ when the client is configured with a MiniMax base URL.
31
+
32
+ Supports:
33
+ - MiniMax-M2.7 (latest flagship model with enhanced reasoning and coding)
34
+ - MiniMax-M2.7-highspeed (high-speed version of M2.7 for low-latency scenarios)
35
+ - MiniMax-M2.5 (204K context window)
36
+ - MiniMax-M2.5-highspeed (204K context window, faster inference)
37
+ - Streaming and non-streaming responses
38
+ - Function/tool calling
39
+ """
40
+
41
+ __slots__ = (
42
+ "_original_chat_create",
43
+ "_original_async_chat_create",
44
+ "_tracer",
45
+ )
46
+
47
+ def instrumentation_dependencies(self) -> Collection[str]:
48
+ return _instruments
49
+
50
+ def _instrument(self, **kwargs: Any) -> None:
51
+ if not (tracer_provider := kwargs.get("tracer_provider")):
52
+ tracer_provider = trace_api.get_tracer_provider()
53
+ if not (config := kwargs.get("config")):
54
+ config = TraceConfig()
55
+ else:
56
+ assert isinstance(config, TraceConfig)
57
+
58
+ self._tracer = FITracer(
59
+ trace_api.get_tracer(__name__, __version__, tracer_provider),
60
+ config=config,
61
+ )
62
+
63
+ # Import openai module
64
+ try:
65
+ openai_module = import_module("openai")
66
+ except ImportError:
67
+ logger.warning("openai package not installed, MiniMax instrumentation skipped")
68
+ return
69
+
70
+ # Wrap OpenAI client chat.completions.create
71
+ # The wrapper checks if the client is configured for MiniMax
72
+ try:
73
+ # Sync client
74
+ if hasattr(openai_module, "OpenAI"):
75
+ from openai.resources.chat import completions
76
+
77
+ self._original_chat_create = completions.Completions.create
78
+ wrap_function_wrapper(
79
+ module="openai.resources.chat.completions",
80
+ name="Completions.create",
81
+ wrapper=_ChatCompletionWrapper(tracer=self._tracer),
82
+ )
83
+
84
+ # Async client
85
+ if hasattr(openai_module, "AsyncOpenAI"):
86
+ from openai.resources.chat import completions as async_completions
87
+
88
+ self._original_async_chat_create = async_completions.AsyncCompletions.create
89
+ wrap_function_wrapper(
90
+ module="openai.resources.chat.completions",
91
+ name="AsyncCompletions.create",
92
+ wrapper=_AsyncChatCompletionWrapper(tracer=self._tracer),
93
+ )
94
+ except Exception as e:
95
+ logger.warning(f"Failed to instrument OpenAI for MiniMax: {e}")
96
+
97
+ def _uninstrument(self, **kwargs: Any) -> None:
98
+ try:
99
+ from openai.resources.chat import completions
100
+
101
+ if hasattr(self, "_original_chat_create"):
102
+ completions.Completions.create = self._original_chat_create
103
+ if hasattr(self, "_original_async_chat_create"):
104
+ completions.AsyncCompletions.create = self._original_async_chat_create
105
+ except Exception as e:
106
+ logger.warning(f"Failed to uninstrument: {e}")
@@ -0,0 +1,115 @@
1
+ import logging
2
+ from typing import Any, Dict, Iterator, Mapping, Tuple
3
+
4
+ from fi_instrumentation import safe_json_dumps
5
+ from fi_instrumentation.fi_types import (
6
+ FiMimeTypeValues,
7
+ FiSpanKindValues,
8
+ MessageAttributes,
9
+ SpanAttributes,
10
+ )
11
+ from opentelemetry.util.types import AttributeValue
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logger.addHandler(logging.NullHandler())
15
+
16
+
17
+ class _ChatCompletionRequestAttributesExtractor:
18
+ """Extract span attributes from MiniMax chat completion request."""
19
+
20
+ def get_attributes_from_request(
21
+ self, request_parameters: Mapping[str, Any]
22
+ ) -> Iterator[Tuple[str, AttributeValue]]:
23
+ """Extract primary attributes from request."""
24
+ yield SpanAttributes.GEN_AI_SPAN_KIND, FiSpanKindValues.LLM.value
25
+ yield SpanAttributes.GEN_AI_PROVIDER_NAME, "minimax"
26
+
27
+ model = request_parameters.get("model", "MiniMax-M2.7")
28
+ yield SpanAttributes.GEN_AI_REQUEST_MODEL, model
29
+
30
+ yield SpanAttributes.INPUT_MIME_TYPE, FiMimeTypeValues.JSON.value
31
+ yield SpanAttributes.OUTPUT_MIME_TYPE, FiMimeTypeValues.JSON.value
32
+
33
+ # Invocation parameters
34
+ invocation_params = self._extract_invocation_parameters(request_parameters)
35
+ if invocation_params:
36
+ yield SpanAttributes.GEN_AI_REQUEST_PARAMETERS, safe_json_dumps(invocation_params)
37
+
38
+ def get_extra_attributes_from_request(
39
+ self, request_parameters: Mapping[str, Any]
40
+ ) -> Iterator[Tuple[str, AttributeValue]]:
41
+ """Extract extra attributes including messages."""
42
+ messages = request_parameters.get("messages", [])
43
+
44
+ # Track input messages
45
+ for i, msg in enumerate(messages):
46
+ role = msg.get("role", "")
47
+ content = msg.get("content", "")
48
+
49
+ # Handle content that might be a list (for multimodal)
50
+ if isinstance(content, list):
51
+ text_parts = []
52
+ for part in content:
53
+ if isinstance(part, dict) and part.get("type") == "text":
54
+ text_parts.append(part.get("text", ""))
55
+ content = "\n".join(text_parts)
56
+
57
+ yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_ROLE}", role
58
+ yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_CONTENT}", str(content)
59
+
60
+ # Handle tool calls in assistant messages
61
+ if role == "assistant" and "tool_calls" in msg:
62
+ tool_calls = msg.get("tool_calls", [])
63
+ for j, tc in enumerate(tool_calls):
64
+ yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.id", tc.get("id", "")
65
+ yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.type", tc.get("type", "function")
66
+ if "function" in tc:
67
+ yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.function.name", tc["function"].get("name", "")
68
+ yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.function.arguments", tc["function"].get("arguments", "")
69
+
70
+ # System prompt extraction
71
+ for msg in messages:
72
+ if msg.get("role") == "system":
73
+ yield SpanAttributes.GEN_AI_PROVIDER_NAME_PROMPT, str(msg.get("content", ""))
74
+ break
75
+
76
+ # User input for display
77
+ for msg in reversed(messages):
78
+ if msg.get("role") == "user":
79
+ content = msg.get("content", "")
80
+ if isinstance(content, list):
81
+ text_parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
82
+ content = "\n".join(text_parts)
83
+ yield SpanAttributes.INPUT_VALUE, str(content)
84
+ break
85
+
86
+ # Tools
87
+ if "tools" in request_parameters:
88
+ tools = request_parameters.get("tools", [])
89
+ yield "minimax.tools_count", len(tools)
90
+ for i, tool in enumerate(tools[:10]): # Limit to first 10
91
+ if "function" in tool:
92
+ func = tool["function"]
93
+ yield f"minimax.tools.{i}.name", func.get("name", "")
94
+ yield f"minimax.tools.{i}.description", func.get("description", "")[:200] # Truncate
95
+
96
+ # Response format
97
+ if "response_format" in request_parameters:
98
+ resp_format = request_parameters.get("response_format", {})
99
+ if isinstance(resp_format, dict):
100
+ yield "minimax.response_format", resp_format.get("type", "text")
101
+
102
+ def _extract_invocation_parameters(
103
+ self, request_parameters: Mapping[str, Any]
104
+ ) -> Dict[str, Any]:
105
+ """Extract model invocation parameters."""
106
+ params = {}
107
+ param_keys = [
108
+ "temperature", "max_tokens", "top_p", "stop", "stream",
109
+ "frequency_penalty", "presence_penalty", "seed", "logprobs",
110
+ "top_logprobs", "n"
111
+ ]
112
+ for key in param_keys:
113
+ if key in request_parameters:
114
+ params[key] = request_parameters[key]
115
+ return params
@@ -0,0 +1,190 @@
1
+ import logging
2
+ from typing import Any, Dict, Iterator, Mapping, Optional, Tuple
3
+
4
+ from fi_instrumentation.fi_types import (
5
+ MessageAttributes,
6
+ SpanAttributes,
7
+ )
8
+ from opentelemetry.util.types import AttributeValue
9
+
10
+ logger = logging.getLogger(__name__)
11
+ logger.addHandler(logging.NullHandler())
12
+
13
+
14
+ class _ChatCompletionResponseAttributesExtractor:
15
+ """Extract span attributes from MiniMax chat completion response."""
16
+
17
+ def get_attributes(
18
+ self,
19
+ response: Optional[Dict[str, Any]],
20
+ is_streaming: bool = False,
21
+ ) -> Iterator[Tuple[str, AttributeValue]]:
22
+ """Extract primary attributes from response."""
23
+ if response is None:
24
+ return
25
+
26
+ # Token usage
27
+ usage = response.get("usage", {})
28
+ if usage:
29
+ if "prompt_tokens" in usage:
30
+ yield SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS, usage["prompt_tokens"]
31
+ if "completion_tokens" in usage:
32
+ yield SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, usage["completion_tokens"]
33
+ if "total_tokens" in usage:
34
+ yield SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS, usage["total_tokens"]
35
+
36
+ # Model info
37
+ if "model" in response:
38
+ yield SpanAttributes.GEN_AI_REQUEST_MODEL, response["model"]
39
+
40
+ # Response ID
41
+ if "id" in response:
42
+ yield "minimax.response_id", response["id"]
43
+
44
+ def get_extra_attributes(
45
+ self,
46
+ response: Optional[Dict[str, Any]],
47
+ request_parameters: Mapping[str, Any],
48
+ is_streaming: bool = False,
49
+ ) -> Iterator[Tuple[str, AttributeValue]]:
50
+ """Extract extra attributes including output."""
51
+ if response is None:
52
+ return
53
+
54
+ choices = response.get("choices", [])
55
+
56
+ for i, choice in enumerate(choices):
57
+ message = choice.get("message", {})
58
+
59
+ # Role
60
+ role = message.get("role", "assistant")
61
+ yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_ROLE}", role
62
+
63
+ # Content
64
+ content = message.get("content", "")
65
+ if content:
66
+ yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_CONTENT}", content
67
+
68
+ # Tool calls
69
+ tool_calls = message.get("tool_calls", [])
70
+ if tool_calls:
71
+ yield "minimax.tool_calls_count", len(tool_calls)
72
+ for j, tc in enumerate(tool_calls[:10]): # Limit
73
+ yield f"minimax.tool_calls.{j}.id", tc.get("id", "")
74
+ yield f"minimax.tool_calls.{j}.type", tc.get("type", "function")
75
+ if "function" in tc:
76
+ yield f"minimax.tool_calls.{j}.function.name", tc["function"].get("name", "")
77
+ yield f"minimax.tool_calls.{j}.function.arguments", tc["function"].get("arguments", "")
78
+
79
+ # Finish reason
80
+ finish_reason = choice.get("finish_reason", "")
81
+ if finish_reason:
82
+ yield "minimax.finish_reason", finish_reason
83
+
84
+ # Logprobs
85
+ if "logprobs" in choice and choice["logprobs"]:
86
+ yield "minimax.has_logprobs", True
87
+
88
+ # Primary output
89
+ if choices:
90
+ first_choice = choices[0]
91
+ message = first_choice.get("message", {})
92
+ content = message.get("content", "")
93
+ if content:
94
+ yield SpanAttributes.OUTPUT_VALUE, content
95
+
96
+
97
+ class _StreamingChatCompletionResponseExtractor:
98
+ """Extract attributes from streaming MiniMax response chunks."""
99
+
100
+ def __init__(self):
101
+ self._content_parts = []
102
+ self._tool_calls = {}
103
+ self._finish_reason = None
104
+ self._usage = {}
105
+ self._model = None
106
+ self._response_id = None
107
+
108
+ def process_chunk(self, chunk: Dict[str, Any]) -> None:
109
+ """Process a single streaming chunk."""
110
+ if "id" in chunk and not self._response_id:
111
+ self._response_id = chunk["id"]
112
+
113
+ if "model" in chunk and not self._model:
114
+ self._model = chunk["model"]
115
+
116
+ # Handle usage in final chunk
117
+ if "usage" in chunk:
118
+ self._usage = chunk["usage"]
119
+
120
+ choices = chunk.get("choices", [])
121
+ for choice in choices:
122
+ delta = choice.get("delta", {})
123
+
124
+ # Content
125
+ if "content" in delta and delta["content"]:
126
+ self._content_parts.append(delta["content"])
127
+
128
+ # Tool calls
129
+ if "tool_calls" in delta:
130
+ for tc in delta["tool_calls"]:
131
+ idx = tc.get("index", 0)
132
+ if idx not in self._tool_calls:
133
+ self._tool_calls[idx] = {
134
+ "id": tc.get("id", ""),
135
+ "type": tc.get("type", "function"),
136
+ "function": {"name": "", "arguments": ""}
137
+ }
138
+ if "id" in tc and tc["id"]:
139
+ self._tool_calls[idx]["id"] = tc["id"]
140
+ if "function" in tc:
141
+ if "name" in tc["function"] and tc["function"]["name"]:
142
+ self._tool_calls[idx]["function"]["name"] = tc["function"]["name"]
143
+ if "arguments" in tc["function"]:
144
+ self._tool_calls[idx]["function"]["arguments"] += tc["function"]["arguments"]
145
+
146
+ # Finish reason
147
+ if "finish_reason" in choice and choice["finish_reason"]:
148
+ self._finish_reason = choice["finish_reason"]
149
+
150
+ def get_attributes(self) -> Iterator[Tuple[str, AttributeValue]]:
151
+ """Get attributes from accumulated stream data."""
152
+ # Token usage
153
+ if self._usage:
154
+ if "prompt_tokens" in self._usage:
155
+ yield SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS, self._usage["prompt_tokens"]
156
+ if "completion_tokens" in self._usage:
157
+ yield SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, self._usage["completion_tokens"]
158
+ if "total_tokens" in self._usage:
159
+ yield SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS, self._usage["total_tokens"]
160
+
161
+ if self._model:
162
+ yield SpanAttributes.GEN_AI_REQUEST_MODEL, self._model
163
+
164
+ if self._response_id:
165
+ yield "minimax.response_id", self._response_id
166
+
167
+ def get_extra_attributes(
168
+ self, request_parameters: Mapping[str, Any]
169
+ ) -> Iterator[Tuple[str, AttributeValue]]:
170
+ """Get extra attributes from accumulated stream."""
171
+ content = "".join(self._content_parts)
172
+
173
+ # Output message
174
+ yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.0.{MessageAttributes.MESSAGE_ROLE}", "assistant"
175
+ if content:
176
+ yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.0.{MessageAttributes.MESSAGE_CONTENT}", content
177
+ yield SpanAttributes.OUTPUT_VALUE, content
178
+
179
+ # Tool calls
180
+ if self._tool_calls:
181
+ yield "minimax.tool_calls_count", len(self._tool_calls)
182
+ for idx, tc in self._tool_calls.items():
183
+ yield f"minimax.tool_calls.{idx}.id", tc["id"]
184
+ yield f"minimax.tool_calls.{idx}.type", tc["type"]
185
+ yield f"minimax.tool_calls.{idx}.function.name", tc["function"]["name"]
186
+ yield f"minimax.tool_calls.{idx}.function.arguments", tc["function"]["arguments"]
187
+
188
+ # Finish reason
189
+ if self._finish_reason:
190
+ yield "minimax.finish_reason", self._finish_reason
@@ -0,0 +1,91 @@
1
+ import logging
2
+ from enum import Enum
3
+ from typing import Any, Dict, Iterator, Mapping, Optional, Tuple
4
+ from urllib.parse import urlparse
5
+
6
+ from fi_instrumentation import safe_json_dumps
7
+ from opentelemetry import trace as trace_api
8
+ from opentelemetry.util.types import AttributeValue
9
+
10
+ from traceai_minimax._with_span import _WithSpan
11
+
12
+ logger = logging.getLogger(__name__)
13
+ logger.addHandler(logging.NullHandler())
14
+
15
+
16
+ class MiniMaxBaseURL(str, Enum):
17
+ """MiniMax API base URL hostnames."""
18
+ API_IO = "api.minimax.io"
19
+ API_CHAT = "api.minimax.chat"
20
+
21
+
22
+ def is_minimax_client(instance: Any) -> bool:
23
+ """Check if the OpenAI client is configured for MiniMax."""
24
+ try:
25
+ base_url = getattr(instance, "base_url", None)
26
+ if base_url is None:
27
+ # Check _client for wrapped clients
28
+ client = getattr(instance, "_client", None)
29
+ if client is not None:
30
+ base_url = getattr(client, "base_url", None)
31
+
32
+ if base_url is not None:
33
+ parsed = urlparse(str(base_url))
34
+ hostname = (parsed.hostname or "").lower()
35
+ return hostname in {url.value for url in MiniMaxBaseURL}
36
+ except Exception:
37
+ pass
38
+ return False
39
+
40
+
41
+ def _to_dict(obj: Any) -> Dict[str, Any]:
42
+ """Convert an object to a dictionary."""
43
+ if obj is None:
44
+ return {}
45
+ if isinstance(obj, dict):
46
+ return obj
47
+ if hasattr(obj, "model_dump"):
48
+ return obj.model_dump()
49
+ if hasattr(obj, "dict"):
50
+ return obj.dict()
51
+ if hasattr(obj, "__dict__"):
52
+ return obj.__dict__
53
+ return {"value": str(obj)}
54
+
55
+
56
+ def _flatten(
57
+ mapping: Mapping[str, Any], prefix: str = ""
58
+ ) -> Iterator[Tuple[str, AttributeValue]]:
59
+ """Flatten a nested dictionary into dot-separated keys."""
60
+ for key, value in mapping.items():
61
+ if value is None:
62
+ continue
63
+ full_key = f"{prefix}.{key}" if prefix else key
64
+ if isinstance(value, Mapping):
65
+ yield from _flatten(value, full_key)
66
+ elif isinstance(value, (list, tuple)):
67
+ if value and isinstance(value[0], Mapping):
68
+ for i, item in enumerate(value):
69
+ yield from _flatten(item, f"{full_key}.{i}")
70
+ else:
71
+ yield full_key, safe_json_dumps(value)
72
+ else:
73
+ yield full_key, value
74
+
75
+
76
+ def _finish_tracing(
77
+ status: trace_api.Status,
78
+ with_span: _WithSpan,
79
+ attributes: Optional[Iterator[Tuple[str, AttributeValue]]] = None,
80
+ extra_attributes: Optional[Iterator[Tuple[str, AttributeValue]]] = None,
81
+ ) -> None:
82
+ """Finish tracing with status and attributes."""
83
+ if attributes:
84
+ for key, value in attributes:
85
+ if value is not None:
86
+ with_span._span.set_attribute(key, value)
87
+ if extra_attributes:
88
+ for key, value in extra_attributes:
89
+ if value is not None:
90
+ with_span._span.set_attribute(key, value)
91
+ with_span.finish_tracing(status=status)
@@ -0,0 +1,40 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from opentelemetry import trace as trace_api
4
+
5
+
6
+ class _WithSpan:
7
+ """Helper class to manage span lifecycle and attributes."""
8
+
9
+ __slots__ = ("_span", "_context_attributes", "_extra_attributes")
10
+
11
+ def __init__(
12
+ self,
13
+ span: trace_api.Span,
14
+ context_attributes: Optional[Dict[str, Any]] = None,
15
+ extra_attributes: Optional[Dict[str, Any]] = None,
16
+ ) -> None:
17
+ self._span = span
18
+ self._context_attributes = context_attributes or {}
19
+ self._extra_attributes = extra_attributes or {}
20
+
21
+ def record_exception(self, exception: Exception) -> None:
22
+ """Record an exception on the span."""
23
+ if self._span.is_recording():
24
+ self._span.record_exception(exception)
25
+
26
+ def finish_tracing(
27
+ self,
28
+ status: Optional[trace_api.Status] = None,
29
+ ) -> None:
30
+ """Finish the span with optional status."""
31
+ if self._span.is_recording():
32
+ if status is not None:
33
+ self._span.set_status(status)
34
+ for key, value in self._context_attributes.items():
35
+ if value is not None:
36
+ self._span.set_attribute(key, value)
37
+ for key, value in self._extra_attributes.items():
38
+ if value is not None:
39
+ self._span.set_attribute(key, value)
40
+ self._span.end()
@@ -0,0 +1,274 @@
1
+ import logging
2
+ from abc import ABC
3
+ from contextlib import contextmanager
4
+ from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, Tuple
5
+
6
+ import opentelemetry.context as context_api
7
+ from fi_instrumentation import get_attributes_from_context, safe_json_dumps
8
+ from fi_instrumentation.fi_types import SpanAttributes
9
+ from opentelemetry import trace as trace_api
10
+ from opentelemetry.trace import INVALID_SPAN
11
+ from opentelemetry.util.types import AttributeValue
12
+
13
+ from traceai_minimax._request_attributes_extractor import (
14
+ _ChatCompletionRequestAttributesExtractor,
15
+ )
16
+ from traceai_minimax._response_attributes_extractor import (
17
+ _ChatCompletionResponseAttributesExtractor,
18
+ _StreamingChatCompletionResponseExtractor,
19
+ )
20
+ from traceai_minimax._utils import _finish_tracing, _to_dict, is_minimax_client
21
+ from traceai_minimax._with_span import _WithSpan
22
+
23
+ logger = logging.getLogger(__name__)
24
+ logger.addHandler(logging.NullHandler())
25
+
26
+
27
+ class _WithTracer(ABC):
28
+ """Base class for wrappers that need a tracer."""
29
+
30
+ def __init__(self, tracer: trace_api.Tracer, *args: Any, **kwargs: Any) -> None:
31
+ super().__init__(*args, **kwargs)
32
+ self._tracer = tracer
33
+
34
+ @contextmanager
35
+ def _start_as_current_span(
36
+ self,
37
+ span_name: str,
38
+ attributes: Iterable[Tuple[str, AttributeValue]],
39
+ context_attributes: Iterable[Tuple[str, AttributeValue]],
40
+ extra_attributes: Iterable[Tuple[str, AttributeValue]],
41
+ ) -> Iterator[_WithSpan]:
42
+ try:
43
+ span = self._tracer.start_span(
44
+ name=span_name, attributes=dict(extra_attributes)
45
+ )
46
+ except Exception:
47
+ span = INVALID_SPAN
48
+ with trace_api.use_span(
49
+ span,
50
+ end_on_exit=False,
51
+ record_exception=False,
52
+ set_status_on_exception=False,
53
+ ) as span:
54
+ yield _WithSpan(
55
+ span=span,
56
+ context_attributes=dict(context_attributes),
57
+ extra_attributes=dict(attributes),
58
+ )
59
+
60
+
61
+ class _ChatCompletionWrapper(_WithTracer):
62
+ """Wrapper for MiniMax chat completions (via OpenAI client)."""
63
+
64
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
65
+ super().__init__(*args, **kwargs)
66
+ self._request_extractor = _ChatCompletionRequestAttributesExtractor()
67
+ self._response_extractor = _ChatCompletionResponseAttributesExtractor()
68
+
69
+ def __call__(
70
+ self,
71
+ wrapped: Callable[..., Any],
72
+ instance: Any,
73
+ args: Tuple[Any, ...],
74
+ kwargs: Mapping[str, Any],
75
+ ) -> Any:
76
+ if context_api.get_value(context_api._SUPPRESS_INSTRUMENTATION_KEY):
77
+ return wrapped(*args, **kwargs)
78
+
79
+ # Check if this is a MiniMax client
80
+ # Get the parent client from the completions instance
81
+ client = getattr(instance, "_client", None)
82
+ if client is None:
83
+ # Try to get from chat.completions structure
84
+ chat = getattr(instance, "_parent", None) or getattr(instance, "chat", None)
85
+ if chat:
86
+ client = getattr(chat, "_client", None)
87
+
88
+ if not is_minimax_client(client) and not is_minimax_client(instance):
89
+ return wrapped(*args, **kwargs)
90
+
91
+ request_parameters = dict(kwargs)
92
+ is_streaming = request_parameters.get("stream", False)
93
+
94
+ span_name = "minimax.chat.completions"
95
+
96
+ with self._start_as_current_span(
97
+ span_name=span_name,
98
+ attributes=self._request_extractor.get_attributes_from_request(request_parameters),
99
+ context_attributes=get_attributes_from_context(),
100
+ extra_attributes=self._request_extractor.get_extra_attributes_from_request(request_parameters),
101
+ ) as span:
102
+ try:
103
+ response = wrapped(*args, **kwargs)
104
+ except Exception as exception:
105
+ span.record_exception(exception)
106
+ status = trace_api.Status(
107
+ status_code=trace_api.StatusCode.ERROR,
108
+ description=f"{type(exception).__name__}: {exception}",
109
+ )
110
+ span.finish_tracing(status=status)
111
+ raise
112
+
113
+ if is_streaming:
114
+ return self._handle_streaming_response(span, response, request_parameters)
115
+
116
+ try:
117
+ response_dict = _to_dict(response)
118
+ _finish_tracing(
119
+ status=trace_api.Status(status_code=trace_api.StatusCode.OK),
120
+ with_span=span,
121
+ attributes=self._response_extractor.get_attributes(response_dict),
122
+ extra_attributes=self._response_extractor.get_extra_attributes(
123
+ response_dict, request_parameters
124
+ ),
125
+ )
126
+ except Exception:
127
+ logger.exception("Failed to finalize response")
128
+ span.finish_tracing()
129
+ return response
130
+
131
+ def _handle_streaming_response(
132
+ self, span: _WithSpan, response: Any, request_parameters: Dict[str, Any]
133
+ ):
134
+ """Handle streaming response."""
135
+ stream_extractor = _StreamingChatCompletionResponseExtractor()
136
+
137
+ def streaming_wrapper():
138
+ try:
139
+ for chunk in response:
140
+ chunk_dict = _to_dict(chunk)
141
+ stream_extractor.process_chunk(chunk_dict)
142
+ yield chunk
143
+
144
+ except Exception as exception:
145
+ span.record_exception(exception)
146
+ status = trace_api.Status(
147
+ status_code=trace_api.StatusCode.ERROR,
148
+ description=f"{type(exception).__name__}: {exception}",
149
+ )
150
+ span.finish_tracing(status=status)
151
+ raise
152
+ else:
153
+ try:
154
+ _finish_tracing(
155
+ status=trace_api.Status(status_code=trace_api.StatusCode.OK),
156
+ with_span=span,
157
+ attributes=stream_extractor.get_attributes(),
158
+ extra_attributes=stream_extractor.get_extra_attributes(request_parameters),
159
+ )
160
+ except Exception:
161
+ logger.exception("Failed to finalize streaming response")
162
+ span.finish_tracing()
163
+ finally:
164
+ if span._span.is_recording():
165
+ span.finish_tracing()
166
+
167
+ return streaming_wrapper()
168
+
169
+
170
+ class _AsyncChatCompletionWrapper(_WithTracer):
171
+ """Async wrapper for MiniMax chat completions."""
172
+
173
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
174
+ super().__init__(*args, **kwargs)
175
+ self._request_extractor = _ChatCompletionRequestAttributesExtractor()
176
+ self._response_extractor = _ChatCompletionResponseAttributesExtractor()
177
+
178
+ async def __call__(
179
+ self,
180
+ wrapped: Callable[..., Any],
181
+ instance: Any,
182
+ args: Tuple[Any, ...],
183
+ kwargs: Mapping[str, Any],
184
+ ) -> Any:
185
+ if context_api.get_value(context_api._SUPPRESS_INSTRUMENTATION_KEY):
186
+ return await wrapped(*args, **kwargs)
187
+
188
+ # Check if this is a MiniMax client
189
+ client = getattr(instance, "_client", None)
190
+ if client is None:
191
+ chat = getattr(instance, "_parent", None) or getattr(instance, "chat", None)
192
+ if chat:
193
+ client = getattr(chat, "_client", None)
194
+
195
+ if not is_minimax_client(client) and not is_minimax_client(instance):
196
+ return await wrapped(*args, **kwargs)
197
+
198
+ request_parameters = dict(kwargs)
199
+ is_streaming = request_parameters.get("stream", False)
200
+
201
+ span_name = "minimax.chat.completions"
202
+
203
+ with self._start_as_current_span(
204
+ span_name=span_name,
205
+ attributes=self._request_extractor.get_attributes_from_request(request_parameters),
206
+ context_attributes=get_attributes_from_context(),
207
+ extra_attributes=self._request_extractor.get_extra_attributes_from_request(request_parameters),
208
+ ) as span:
209
+ try:
210
+ response = await wrapped(*args, **kwargs)
211
+ except Exception as exception:
212
+ span.record_exception(exception)
213
+ status = trace_api.Status(
214
+ status_code=trace_api.StatusCode.ERROR,
215
+ description=f"{type(exception).__name__}: {exception}",
216
+ )
217
+ span.finish_tracing(status=status)
218
+ raise
219
+
220
+ if is_streaming:
221
+ return self._handle_async_streaming_response(span, response, request_parameters)
222
+
223
+ try:
224
+ response_dict = _to_dict(response)
225
+ _finish_tracing(
226
+ status=trace_api.Status(status_code=trace_api.StatusCode.OK),
227
+ with_span=span,
228
+ attributes=self._response_extractor.get_attributes(response_dict),
229
+ extra_attributes=self._response_extractor.get_extra_attributes(
230
+ response_dict, request_parameters
231
+ ),
232
+ )
233
+ except Exception:
234
+ logger.exception("Failed to finalize response")
235
+ span.finish_tracing()
236
+ return response
237
+
238
+ def _handle_async_streaming_response(
239
+ self, span: _WithSpan, response: Any, request_parameters: Dict[str, Any]
240
+ ):
241
+ """Handle async streaming response."""
242
+ stream_extractor = _StreamingChatCompletionResponseExtractor()
243
+
244
+ async def async_streaming_wrapper():
245
+ try:
246
+ async for chunk in response:
247
+ chunk_dict = _to_dict(chunk)
248
+ stream_extractor.process_chunk(chunk_dict)
249
+ yield chunk
250
+
251
+ except Exception as exception:
252
+ span.record_exception(exception)
253
+ status = trace_api.Status(
254
+ status_code=trace_api.StatusCode.ERROR,
255
+ description=f"{type(exception).__name__}: {exception}",
256
+ )
257
+ span.finish_tracing(status=status)
258
+ raise
259
+ else:
260
+ try:
261
+ _finish_tracing(
262
+ status=trace_api.Status(status_code=trace_api.StatusCode.OK),
263
+ with_span=span,
264
+ attributes=stream_extractor.get_attributes(),
265
+ extra_attributes=stream_extractor.get_extra_attributes(request_parameters),
266
+ )
267
+ except Exception:
268
+ logger.exception("Failed to finalize async streaming response")
269
+ span.finish_tracing()
270
+ finally:
271
+ if span._span.is_recording():
272
+ span.finish_tracing()
273
+
274
+ return async_streaming_wrapper()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0" # x-release-please-version
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.4
2
+ Name: traceAI-minimax
3
+ Version: 0.1.0
4
+ Summary: OpenTelemetry instrumentation for MiniMax - chat completions via OpenAI-compatible API
5
+ License: Apache-2.0
6
+ Keywords: traceai,futureagi,opentelemetry,tracing,observability,llm,minimax
7
+ Author: Future AGI
8
+ Author-email: no-reply@futureagi.com
9
+ Requires-Python: >=3.10,<3.14
10
+ Classifier: Framework :: OpenTelemetry
11
+ Classifier: Framework :: OpenTelemetry :: Instrumentations
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Monitoring
23
+ Requires-Dist: fi-instrumentation-otel (>=0.1.11)
24
+ Requires-Dist: openai (>=1.0.0)
25
+ Project-URL: Changelog, https://github.com/future-agi/traceAI/releases
26
+ Project-URL: Documentation, https://docs.futureagi.com/docs/tracing
27
+ Project-URL: Homepage, https://futureagi.com
28
+ Project-URL: Issues, https://github.com/future-agi/traceAI/issues
29
+ Project-URL: Repository, https://github.com/future-agi/traceAI/tree/dev/python/frameworks/minimax
30
+ Description-Content-Type: text/markdown
31
+
32
+ # TraceAI MiniMax Instrumentation
33
+
34
+ OpenTelemetry instrumentation for [MiniMax](https://www.minimax.io/) - chat completions via the OpenAI-compatible API.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install traceai-minimax
40
+ ```
41
+
42
+ ## Features
43
+
44
+ - Automatic tracing of MiniMax API calls via OpenAI SDK
45
+ - Support for MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, and MiniMax-M2.5-highspeed models
46
+ - Streaming response support
47
+ - Token usage tracking
48
+ - Function/tool calling support
49
+ - Full OpenTelemetry semantic conventions compliance
50
+
51
+ ## Usage
52
+
53
+ ### Basic Setup
54
+
55
+ ```python
56
+ from openai import OpenAI
57
+ from opentelemetry import trace
58
+ from opentelemetry.sdk.trace import TracerProvider
59
+ from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
60
+
61
+ from traceai_minimax import MiniMaxInstrumentor
62
+
63
+ # Set up tracing
64
+ provider = TracerProvider()
65
+ provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
66
+ trace.set_tracer_provider(provider)
67
+
68
+ # Instrument MiniMax
69
+ MiniMaxInstrumentor().instrument(tracer_provider=provider)
70
+
71
+ # Use MiniMax via OpenAI SDK
72
+ client = OpenAI(
73
+ api_key="your-minimax-api-key",
74
+ base_url="https://api.minimax.io/v1"
75
+ )
76
+
77
+ response = client.chat.completions.create(
78
+ model="MiniMax-M2.7",
79
+ messages=[{"role": "user", "content": "Hello!"}]
80
+ )
81
+ print(response.choices[0].message.content)
82
+ ```
83
+
84
+ ### MiniMax Chat
85
+
86
+ ```python
87
+ from openai import OpenAI
88
+
89
+ client = OpenAI(
90
+ api_key="your-minimax-api-key",
91
+ base_url="https://api.minimax.io/v1"
92
+ )
93
+
94
+ # Simple chat
95
+ response = client.chat.completions.create(
96
+ model="MiniMax-M2.7",
97
+ messages=[
98
+ {"role": "system", "content": "You are a helpful assistant."},
99
+ {"role": "user", "content": "What is machine learning?"}
100
+ ],
101
+ temperature=0.7,
102
+ max_tokens=1024
103
+ )
104
+ print(response.choices[0].message.content)
105
+ ```
106
+
107
+ ### Streaming Responses
108
+
109
+ ```python
110
+ from openai import OpenAI
111
+
112
+ client = OpenAI(
113
+ api_key="your-minimax-api-key",
114
+ base_url="https://api.minimax.io/v1"
115
+ )
116
+
117
+ # Streaming chat
118
+ stream = client.chat.completions.create(
119
+ model="MiniMax-M2.7",
120
+ messages=[{"role": "user", "content": "Tell me a story"}],
121
+ stream=True
122
+ )
123
+
124
+ for chunk in stream:
125
+ if chunk.choices[0].delta.content:
126
+ print(chunk.choices[0].delta.content, end="", flush=True)
127
+ print()
128
+ ```
129
+
130
+ ### Function Calling / Tools
131
+
132
+ ```python
133
+ from openai import OpenAI
134
+ import json
135
+
136
+ client = OpenAI(
137
+ api_key="your-minimax-api-key",
138
+ base_url="https://api.minimax.io/v1"
139
+ )
140
+
141
+ tools = [
142
+ {
143
+ "type": "function",
144
+ "function": {
145
+ "name": "get_weather",
146
+ "description": "Get the current weather for a location",
147
+ "parameters": {
148
+ "type": "object",
149
+ "properties": {
150
+ "location": {
151
+ "type": "string",
152
+ "description": "The city name"
153
+ },
154
+ "unit": {
155
+ "type": "string",
156
+ "enum": ["celsius", "fahrenheit"],
157
+ "description": "Temperature unit"
158
+ }
159
+ },
160
+ "required": ["location"]
161
+ }
162
+ }
163
+ }
164
+ ]
165
+
166
+ response = client.chat.completions.create(
167
+ model="MiniMax-M2.7",
168
+ messages=[{"role": "user", "content": "What's the weather in Paris?"}],
169
+ tools=tools,
170
+ tool_choice="auto"
171
+ )
172
+
173
+ message = response.choices[0].message
174
+ if message.tool_calls:
175
+ for tool_call in message.tool_calls:
176
+ print(f"Function: {tool_call.function.name}")
177
+ print(f"Arguments: {tool_call.function.arguments}")
178
+ ```
179
+
180
+ ### Async Usage
181
+
182
+ ```python
183
+ import asyncio
184
+ from openai import AsyncOpenAI
185
+
186
+ async def main():
187
+ client = AsyncOpenAI(
188
+ api_key="your-minimax-api-key",
189
+ base_url="https://api.minimax.io/v1"
190
+ )
191
+
192
+ response = await client.chat.completions.create(
193
+ model="MiniMax-M2.7",
194
+ messages=[{"role": "user", "content": "Hello!"}]
195
+ )
196
+ print(response.choices[0].message.content)
197
+
198
+ asyncio.run(main())
199
+ ```
200
+
201
+ ### JSON Mode
202
+
203
+ ```python
204
+ from openai import OpenAI
205
+
206
+ client = OpenAI(
207
+ api_key="your-minimax-api-key",
208
+ base_url="https://api.minimax.io/v1"
209
+ )
210
+
211
+ response = client.chat.completions.create(
212
+ model="MiniMax-M2.7",
213
+ messages=[
214
+ {"role": "system", "content": "Output valid JSON only."},
215
+ {"role": "user", "content": "List 3 programming languages with their main use cases"}
216
+ ],
217
+ response_format={"type": "json_object"}
218
+ )
219
+
220
+ import json
221
+ data = json.loads(response.choices[0].message.content)
222
+ print(data)
223
+ ```
224
+
225
+ ## Configuration Options
226
+
227
+ ### TraceConfig
228
+
229
+ ```python
230
+ from fi_instrumentation import TraceConfig
231
+ from traceai_minimax import MiniMaxInstrumentor
232
+
233
+ config = TraceConfig(
234
+ hide_inputs=False,
235
+ hide_outputs=False,
236
+ )
237
+
238
+ MiniMaxInstrumentor().instrument(
239
+ tracer_provider=provider,
240
+ config=config
241
+ )
242
+ ```
243
+
244
+ ## Captured Attributes
245
+
246
+ ### Common Attributes
247
+
248
+ | Attribute | Description |
249
+ |-----------|-------------|
250
+ | `fi.span.kind` | "LLM" |
251
+ | `llm.system` | "minimax" |
252
+ | `llm.provider` | "minimax" |
253
+ | `llm.model` | Model name (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed) |
254
+ | `llm.token_count.prompt` | Input token count |
255
+ | `llm.token_count.completion` | Output token count |
256
+ | `llm.token_count.total` | Total token count |
257
+
258
+ ### MiniMax-Specific Attributes
259
+
260
+ | Attribute | Description |
261
+ |-----------|-------------|
262
+ | `minimax.response_id` | Unique response ID |
263
+ | `minimax.finish_reason` | Response finish reason (stop, tool_calls, length) |
264
+ | `minimax.tool_calls_count` | Number of tool calls |
265
+ | `minimax.tools_count` | Number of tools provided |
266
+
267
+ ## Available Models
268
+
269
+ | Model | Description |
270
+ |-------|-------------|
271
+ | `MiniMax-M2.7` | Latest flagship model with enhanced reasoning and coding |
272
+ | `MiniMax-M2.7-highspeed` | High-speed version of M2.7 for low-latency scenarios |
273
+ | `MiniMax-M2.5` | General-purpose model with 204K context window |
274
+ | `MiniMax-M2.5-highspeed` | Faster inference variant with 204K context window |
275
+
276
+ ## Important Notes
277
+
278
+ 1. **OpenAI SDK Required**: MiniMax uses the OpenAI-compatible API, so you need the `openai` package installed.
279
+
280
+ 2. **Base URL**: Always set `base_url="https://api.minimax.io/v1"` when creating the client.
281
+
282
+ 3. **API Key**: Get your API key from the [MiniMax Platform](https://platform.minimax.chat/).
283
+
284
+ 4. **Selective Instrumentation**: The instrumentor only traces calls to MiniMax's API. Regular OpenAI API calls are not affected.
285
+
286
+ 5. **Temperature**: MiniMax requires temperature to be in the range (0.0, 1.0]. A value of exactly 0 is not accepted.
287
+
288
+ ## License
289
+
290
+ Apache-2.0
291
+
@@ -0,0 +1,10 @@
1
+ traceai_minimax/__init__.py,sha256=N5ZpvSFDFzU136BFCrsh-BXcZrufGDxOYVOKCxTSVeE,3910
2
+ traceai_minimax/_request_attributes_extractor.py,sha256=fp5k7KK5k4F133NQAnRrOi4KseBxzuWpJNeyWFJCtzQ,5141
3
+ traceai_minimax/_response_attributes_extractor.py,sha256=zjrkkLGCp-LPjcYbCSQZ165Z4xeADqvh9O5yS7q_C1s,7550
4
+ traceai_minimax/_utils.py,sha256=bu8GIPQyj_8xJqWUbsVmx45DphScGPcbLduncM7Sv7g,2985
5
+ traceai_minimax/_with_span.py,sha256=SPzQfr85aprbb5Kdajk81Bbp71XRIF85DrjM4-RQwp4,1399
6
+ traceai_minimax/_wrappers.py,sha256=-3F_W_WbyfdX2EKlJJYEpsaa_PH65J1ZD76tgVGbdGw,10882
7
+ traceai_minimax/version.py,sha256=IOEzhCbX916JIkvZr03GFY4bl9YqSkfjdqLO4qJ2FOs,50
8
+ traceai_minimax-0.1.0.dist-info/METADATA,sha256=RGZiocCInopTOmr_ZD567kCtwiF0gkp16cZ_w64rwY0,8082
9
+ traceai_minimax-0.1.0.dist-info/WHEEL,sha256=L-WvLdSBvJWHXv5zrQxDvNRnIgoBzLUpcbuiw19KCuQ,88
10
+ traceai_minimax-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.5.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any