From f91e16c4fab7c41b389fdb286b5d4a5df65f544b Mon Sep 17 00:00:00 2001 From: PR Bot Date: Sun, 15 Mar 2026 22:13:04 +0800 Subject: [PATCH 1/3] feat: add MiniMax LLM provider instrumentation Add OpenTelemetry instrumentation for MiniMax (https://www.minimax.io/), which provides an OpenAI-compatible API. This integration supports: - MiniMax-M2.5 and MiniMax-M2.5-highspeed models (204K context) - Sync and async chat completions - Streaming responses - Function/tool calling - Token usage tracking The implementation follows the same pattern as the existing DeepSeek integration, detecting MiniMax clients by their base_url (api.minimax.io) and wrapping OpenAI SDK calls accordingly. Changes: - New package: python/frameworks/minimax/ (traceai_minimax) - Added MINIMAX to FiLLMProviderValues enum - Updated README.md with MiniMax in supported frameworks - Includes comprehensive tests (19 passing) and usage examples --- README.md | 2 + python/fi_instrumentation/fi_types.py | 1 + python/frameworks/minimax/README.md | 257 ++++++++++++ .../frameworks/minimax/examples/basic_chat.py | 118 ++++++ python/frameworks/minimax/pyproject.toml | 18 + python/frameworks/minimax/tests/__init__.py | 0 python/frameworks/minimax/tests/conftest.py | 157 ++++++++ .../minimax/tests/test_instrumentation.py | 369 ++++++++++++++++++ .../minimax/traceai_minimax/__init__.py | 122 ++++++ .../_request_attributes_extractor.py | 115 ++++++ .../_response_attributes_extractor.py | 202 ++++++++++ .../minimax/traceai_minimax/_utils.py | 88 +++++ .../minimax/traceai_minimax/_with_span.py | 40 ++ .../minimax/traceai_minimax/_wrappers.py | 274 +++++++++++++ .../minimax/traceai_minimax/version.py | 1 + 15 files changed, 1764 insertions(+) create mode 100644 python/frameworks/minimax/README.md create mode 100644 python/frameworks/minimax/examples/basic_chat.py create mode 100644 python/frameworks/minimax/pyproject.toml create mode 100644 python/frameworks/minimax/tests/__init__.py create mode 100644 python/frameworks/minimax/tests/conftest.py create mode 100644 python/frameworks/minimax/tests/test_instrumentation.py create mode 100644 python/frameworks/minimax/traceai_minimax/__init__.py create mode 100644 python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py create mode 100644 python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py create mode 100644 python/frameworks/minimax/traceai_minimax/_utils.py create mode 100644 python/frameworks/minimax/traceai_minimax/_with_span.py create mode 100644 python/frameworks/minimax/traceai_minimax/_wrappers.py create mode 100644 python/frameworks/minimax/traceai_minimax/version.py diff --git a/README.md b/README.md index 619b0754..52c9cc99 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ var tracer = FITracer.Initialize(new FITracerOptions | [`traceAI-huggingface`](https://pypi.org/project/traceAI-huggingface/) | HuggingFace | [![PyPI](https://img.shields.io/pypi/v/traceAI-huggingface)](https://pypi.org/project/traceAI-huggingface/) | | [`traceAI-xai`](https://pypi.org/project/traceAI-xai/) | xAI (Grok) | [![PyPI](https://img.shields.io/pypi/v/traceAI-xai)](https://pypi.org/project/traceAI-xai/) | | [`traceAI-vllm`](https://pypi.org/project/traceAI-vllm/) | vLLM | [![PyPI](https://img.shields.io/pypi/v/traceAI-vllm)](https://pypi.org/project/traceAI-vllm/) | +| [`traceAI-minimax`](https://pypi.org/project/traceAI-minimax/) | MiniMax | [![PyPI](https://img.shields.io/pypi/v/traceAI-minimax)](https://pypi.org/project/traceAI-minimax/) | #### Agent Frameworks @@ -434,6 +435,7 @@ Available on [NuGet](https://www.nuget.org/packages/fi-instrumentation-otel). | | HuggingFace | ✅ | ✅ | | | | | xAI (Grok) | ✅ | ✅ | | | | | vLLM | ✅ | ✅ | | | +| | MiniMax | ✅ | | | | | | Azure OpenAI | | | ✅ | | | | IBM Watsonx | | | ✅ | | | **Agent Frameworks** | LangChain | ✅ | ✅ | | | diff --git a/python/fi_instrumentation/fi_types.py b/python/fi_instrumentation/fi_types.py index 1e2e6b8d..d21fc7d1 100644 --- a/python/fi_instrumentation/fi_types.py +++ b/python/fi_instrumentation/fi_types.py @@ -931,6 +931,7 @@ class FiLLMProviderValues(Enum): VERTEXAI = "vertexai" XAI = "xai" DEEPSEEK = "deepseek" + MINIMAX = "minimax" class ProjectType(Enum): diff --git a/python/frameworks/minimax/README.md b/python/frameworks/minimax/README.md new file mode 100644 index 00000000..4796f13e --- /dev/null +++ b/python/frameworks/minimax/README.md @@ -0,0 +1,257 @@ +# TraceAI MiniMax Instrumentation + +OpenTelemetry instrumentation for [MiniMax](https://www.minimax.io/) - chat completions via the OpenAI-compatible API. + +## Installation + +```bash +pip install traceai-minimax +``` + +## Features + +- Automatic tracing of MiniMax API calls via OpenAI SDK +- Support for MiniMax-M2.5 and MiniMax-M2.5-highspeed models (204K context) +- Streaming response support +- Token usage tracking +- Function/tool calling support +- Full OpenTelemetry semantic conventions compliance + +## Usage + +### Basic Setup + +```python +from openai import OpenAI +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + +from traceai_minimax import MiniMaxInstrumentor + +# Set up tracing +provider = TracerProvider() +provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) +trace.set_tracer_provider(provider) + +# Instrument MiniMax +MiniMaxInstrumentor().instrument(tracer_provider=provider) + +# Use MiniMax via OpenAI SDK +client = OpenAI( + api_key="your-minimax-api-key", + base_url="https://api.minimax.io/v1" +) + +response = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[{"role": "user", "content": "Hello!"}] +) +print(response.choices[0].message.content) +``` + +### MiniMax Chat + +```python +from openai import OpenAI + +client = OpenAI( + api_key="your-minimax-api-key", + base_url="https://api.minimax.io/v1" +) + +# Simple chat +response = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is machine learning?"} + ], + temperature=0.7, + max_tokens=1024 +) +print(response.choices[0].message.content) +``` + +### Streaming Responses + +```python +from openai import OpenAI + +client = OpenAI( + api_key="your-minimax-api-key", + base_url="https://api.minimax.io/v1" +) + +# Streaming chat +stream = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[{"role": "user", "content": "Tell me a story"}], + stream=True +) + +for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) +print() +``` + +### Function Calling / Tools + +```python +from openai import OpenAI +import json + +client = OpenAI( + api_key="your-minimax-api-key", + base_url="https://api.minimax.io/v1" +) + +tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city name" + }, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + "description": "Temperature unit" + } + }, + "required": ["location"] + } + } + } +] + +response = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[{"role": "user", "content": "What's the weather in Paris?"}], + tools=tools, + tool_choice="auto" +) + +message = response.choices[0].message +if message.tool_calls: + for tool_call in message.tool_calls: + print(f"Function: {tool_call.function.name}") + print(f"Arguments: {tool_call.function.arguments}") +``` + +### Async Usage + +```python +import asyncio +from openai import AsyncOpenAI + +async def main(): + client = AsyncOpenAI( + api_key="your-minimax-api-key", + base_url="https://api.minimax.io/v1" + ) + + response = await client.chat.completions.create( + model="MiniMax-M2.5", + messages=[{"role": "user", "content": "Hello!"}] + ) + print(response.choices[0].message.content) + +asyncio.run(main()) +``` + +### JSON Mode + +```python +from openai import OpenAI + +client = OpenAI( + api_key="your-minimax-api-key", + base_url="https://api.minimax.io/v1" +) + +response = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[ + {"role": "system", "content": "Output valid JSON only."}, + {"role": "user", "content": "List 3 programming languages with their main use cases"} + ], + response_format={"type": "json_object"} +) + +import json +data = json.loads(response.choices[0].message.content) +print(data) +``` + +## Configuration Options + +### TraceConfig + +```python +from fi_instrumentation import TraceConfig +from traceai_minimax import MiniMaxInstrumentor + +config = TraceConfig( + hide_inputs=False, + hide_outputs=False, +) + +MiniMaxInstrumentor().instrument( + tracer_provider=provider, + config=config +) +``` + +## Captured Attributes + +### Common Attributes + +| Attribute | Description | +|-----------|-------------| +| `fi.span.kind` | "LLM" | +| `llm.system` | "minimax" | +| `llm.provider` | "minimax" | +| `llm.model` | Model name (MiniMax-M2.5, MiniMax-M2.5-highspeed) | +| `llm.token_count.prompt` | Input token count | +| `llm.token_count.completion` | Output token count | +| `llm.token_count.total` | Total token count | + +### MiniMax-Specific Attributes + +| Attribute | Description | +|-----------|-------------| +| `minimax.response_id` | Unique response ID | +| `minimax.finish_reason` | Response finish reason (stop, tool_calls, length) | +| `minimax.tool_calls_count` | Number of tool calls | +| `minimax.tools_count` | Number of tools provided | + +## Available Models + +| Model | Description | +|-------|-------------| +| `MiniMax-M2.5` | General-purpose model with 204K context window | +| `MiniMax-M2.5-highspeed` | Faster inference variant with 204K context window | + +## Important Notes + +1. **OpenAI SDK Required**: MiniMax uses the OpenAI-compatible API, so you need the `openai` package installed. + +2. **Base URL**: Always set `base_url="https://api.minimax.io/v1"` when creating the client. + +3. **API Key**: Get your API key from the [MiniMax Platform](https://platform.minimax.chat/). + +4. **Selective Instrumentation**: The instrumentor only traces calls to MiniMax's API. Regular OpenAI API calls are not affected. + +5. **Temperature**: MiniMax requires temperature to be in the range (0.0, 1.0]. A value of exactly 0 is not accepted. + +## License + +Apache-2.0 diff --git a/python/frameworks/minimax/examples/basic_chat.py b/python/frameworks/minimax/examples/basic_chat.py new file mode 100644 index 00000000..810dba66 --- /dev/null +++ b/python/frameworks/minimax/examples/basic_chat.py @@ -0,0 +1,118 @@ +""" +Basic MiniMax chat completion example with traceAI instrumentation. +""" + +import os + +from openai import OpenAI +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor + +from traceai_minimax import MiniMaxInstrumentor + +# Set up tracing +provider = TracerProvider() +provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) +trace.set_tracer_provider(provider) + +# Instrument MiniMax +MiniMaxInstrumentor().instrument(tracer_provider=provider) + +# Create MiniMax client using OpenAI SDK +client = OpenAI( + api_key=os.environ.get("MINIMAX_API_KEY", "your-minimax-api-key"), + base_url="https://api.minimax.io/v1", +) + + +def simple_chat(): + """Simple chat completion.""" + print("=== Simple Chat ===") + response = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is machine learning?"}, + ], + temperature=0.7, + max_tokens=1024, + ) + print(response.choices[0].message.content) + print() + + +def streaming_chat(): + """Streaming chat completion.""" + print("=== Streaming Chat ===") + stream = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[{"role": "user", "content": "Tell me a short story"}], + stream=True, + ) + + for chunk in stream: + if chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) + print("\n") + + +def function_calling(): + """Function calling example.""" + print("=== Function Calling ===") + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city name", + }, + }, + "required": ["location"], + }, + }, + } + ] + + response = client.chat.completions.create( + model="MiniMax-M2.5", + messages=[{"role": "user", "content": "What's the weather in Paris?"}], + tools=tools, + tool_choice="auto", + ) + + message = response.choices[0].message + if message.tool_calls: + for tool_call in message.tool_calls: + print(f"Function: {tool_call.function.name}") + print(f"Arguments: {tool_call.function.arguments}") + else: + print(message.content) + print() + + +def highspeed_chat(): + """Using MiniMax-M2.5-highspeed for faster inference.""" + print("=== Highspeed Chat ===") + response = client.chat.completions.create( + model="MiniMax-M2.5-highspeed", + messages=[ + {"role": "user", "content": "Summarize the key features of Python in 3 bullet points."}, + ], + temperature=0.5, + ) + print(response.choices[0].message.content) + print() + + +if __name__ == "__main__": + simple_chat() + streaming_chat() + function_calling() + highspeed_chat() diff --git a/python/frameworks/minimax/pyproject.toml b/python/frameworks/minimax/pyproject.toml new file mode 100644 index 00000000..1c9c743d --- /dev/null +++ b/python/frameworks/minimax/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "traceAI-minimax" +version = "0.1.0" +description = "OpenTelemetry instrumentation for MiniMax - chat completions via OpenAI-compatible API" +authors = ["Future AGI "] +readme = "README.md" +packages = [ + { include = "traceai_minimax" } +] + +[tool.poetry.dependencies] +python = ">=3.10" +fi-instrumentation-otel = ">=0.1.11" +openai = ">=1.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/python/frameworks/minimax/tests/__init__.py b/python/frameworks/minimax/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/frameworks/minimax/tests/conftest.py b/python/frameworks/minimax/tests/conftest.py new file mode 100644 index 00000000..ccdfd1f3 --- /dev/null +++ b/python/frameworks/minimax/tests/conftest.py @@ -0,0 +1,157 @@ +"""Pytest configuration and fixtures for MiniMax instrumentation tests.""" + +import sys +from types import ModuleType +from unittest.mock import MagicMock + +import pytest + + +# Create mock modules before any imports +def setup_mocks(): + """Set up all required module mocks.""" + + # Helper to create a module-like object + def create_mock_module(name): + mod = ModuleType(name) + return mod + + # Create a proper SpanAttributes class that returns string values + class MockSpanAttributes: + GEN_AI_PROVIDER_NAME = "llm.provider" + GEN_AI_PROVIDER_NAME_PROMPT = "llm.system_prompt" + GEN_AI_REQUEST_MODEL = "llm.model" + LLM_RESPONSE_MODEL = "llm.response_model" + GEN_AI_REQUEST_PARAMETERS = "llm.invocation_parameters" + GEN_AI_USAGE_INPUT_TOKENS = "llm.token_count.prompt" + GEN_AI_USAGE_OUTPUT_TOKENS = "llm.token_count.completion" + GEN_AI_USAGE_TOTAL_TOKENS = "llm.token_count.total" + GEN_AI_SPAN_KIND = "fi.span.kind" + INPUT_VALUE = "input.value" + OUTPUT_VALUE = "output.value" + EMBEDDING_MODEL_NAME = "embedding.model" + LLM_SYSTEM_PROMPT = "llm.system_prompt" + INPUT_MIME_TYPE = "input.mime_type" + OUTPUT_MIME_TYPE = "output.mime_type" + GEN_AI_INPUT_MESSAGES = "llm.input_messages" + GEN_AI_OUTPUT_MESSAGES = "llm.output_messages" + EMBEDDING_EMBEDDINGS = "embedding.embeddings" + RERANKER_MODEL_NAME = "reranker.model" + RERANKER_QUERY = "reranker.query" + RERANKER_TOP_K = "reranker.top_k" + + class MockMessageAttributes: + MESSAGE_ROLE = "message.role" + MESSAGE_CONTENT = "message.content" + + class MockEmbeddingAttributes: + EMBEDDING_VECTOR = "embedding.vector" + + # Create mock for fi_instrumentation.fi_types + mock_fi_types = MagicMock() + mock_fi_types.SpanAttributes = MockSpanAttributes + + # Create proper enum-like objects for FiSpanKindValues + class MockEnum: + def __init__(self, value): + self.value = value + + class MockFiSpanKindValues: + LLM = MockEnum("LLM") + EMBEDDING = MockEnum("EMBEDDING") + RERANKER = MockEnum("RERANKER") + + class MockFiMimeTypeValues: + JSON = MockEnum("application/json") + TEXT = MockEnum("text/plain") + + mock_fi_types.FiSpanKindValues = MockFiSpanKindValues + mock_fi_types.FiMimeTypeValues = MockFiMimeTypeValues + + mock_fi_types.MessageAttributes = MockMessageAttributes + mock_fi_types.EmbeddingAttributes = MockEmbeddingAttributes + + # Mock fi_instrumentation base + import json + mock_fi_instrumentation = create_mock_module("fi_instrumentation") + mock_fi_instrumentation.FITracer = MagicMock() + mock_fi_instrumentation.TraceConfig = MagicMock() + mock_fi_instrumentation.safe_json_dumps = lambda x: json.dumps(x) if x else "{}" + mock_fi_instrumentation.get_attributes_from_context = MagicMock(return_value={}) + mock_fi_instrumentation.fi_types = mock_fi_types + + # Mock fi_instrumentation.instrumentation submodule + mock_fi_instr_instr = create_mock_module("fi_instrumentation.instrumentation") + mock_fi_instr_protect = create_mock_module("fi_instrumentation.instrumentation._protect_wrapper") + mock_fi_instr_protect.GuardrailProtectWrapper = MagicMock() + + # Mock fi.evals + mock_fi = create_mock_module("fi") + mock_fi_evals = create_mock_module("fi.evals") + mock_fi_evals.Protect = None + + # Register fi mocks + sys.modules["fi_instrumentation"] = mock_fi_instrumentation + sys.modules["fi_instrumentation.fi_types"] = mock_fi_types + sys.modules["fi_instrumentation.instrumentation"] = mock_fi_instr_instr + sys.modules["fi_instrumentation.instrumentation._protect_wrapper"] = mock_fi_instr_protect + sys.modules["fi"] = mock_fi + sys.modules["fi.evals"] = mock_fi_evals + + # Mock opentelemetry - comprehensive mock + mock_otel = create_mock_module("opentelemetry") + mock_trace = create_mock_module("opentelemetry.trace") + mock_trace.get_tracer_provider = MagicMock(return_value=MagicMock()) + mock_trace.get_tracer = MagicMock(return_value=MagicMock()) + mock_trace.INVALID_SPAN = MagicMock() + mock_trace.Span = MagicMock() + mock_trace.Tracer = MagicMock() + mock_trace.Status = MagicMock() + mock_trace.StatusCode = MagicMock() + mock_trace.StatusCode.OK = "OK" + mock_trace.StatusCode.ERROR = "ERROR" + mock_trace.SpanKind = MagicMock() + mock_trace.SpanKind.CLIENT = "CLIENT" + mock_trace.SpanKind.SERVER = "SERVER" + + mock_context = create_mock_module("opentelemetry.context") + mock_util = create_mock_module("opentelemetry.util") + mock_util_types = create_mock_module("opentelemetry.util.types") + mock_util_types.AttributeValue = type + mock_util_types.Attributes = dict + + mock_otel_instr = create_mock_module("opentelemetry.instrumentation") + mock_otel_instr_instrumentor = create_mock_module("opentelemetry.instrumentation.instrumentor") + + class MockBaseInstrumentor: + def instrumentation_dependencies(self): + return [] + + def instrument(self, **kwargs): + self._instrument(**kwargs) + + def _instrument(self, **kwargs): + pass + + def _uninstrument(self, **kwargs): + pass + + mock_otel_instr_instrumentor.BaseInstrumentor = MockBaseInstrumentor + + # Register opentelemetry mocks + sys.modules["opentelemetry"] = mock_otel + sys.modules["opentelemetry.trace"] = mock_trace + sys.modules["opentelemetry.context"] = mock_context + sys.modules["opentelemetry.util"] = mock_util + sys.modules["opentelemetry.util.types"] = mock_util_types + sys.modules["opentelemetry.instrumentation"] = mock_otel_instr + sys.modules["opentelemetry.instrumentation.instrumentor"] = mock_otel_instr_instrumentor + + # Mock wrapt + mock_wrapt = create_mock_module("wrapt") + mock_wrapt.wrap_function_wrapper = MagicMock() + sys.modules["wrapt"] = mock_wrapt + + +# Set up mocks before tests run +setup_mocks() diff --git a/python/frameworks/minimax/tests/test_instrumentation.py b/python/frameworks/minimax/tests/test_instrumentation.py new file mode 100644 index 00000000..ce073811 --- /dev/null +++ b/python/frameworks/minimax/tests/test_instrumentation.py @@ -0,0 +1,369 @@ +"""Tests for MiniMax instrumentation.""" +import json +from unittest.mock import MagicMock, patch + +import pytest + +from traceai_minimax import MiniMaxInstrumentor + + +class TestMiniMaxInstrumentor: + """Tests for MiniMaxInstrumentor.""" + + def test_instrumentation_dependencies(self): + """Test instrumentation dependencies.""" + instrumentor = MiniMaxInstrumentor() + deps = instrumentor.instrumentation_dependencies() + assert "openai >= 1.0.0" in deps + + +class TestChatCompletionRequestAttributesExtractor: + """Tests for chat completion request attributes extraction.""" + + def test_extract_chat_attributes(self): + """Test extracting chat request attributes.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.5", + "messages": [ + {"role": "user", "content": "Hello!"} + ], + "temperature": 0.7, + } + + attributes = dict(extractor.get_attributes_from_request(request_params)) + + assert attributes["fi.span.kind"] == "LLM" + assert attributes["llm.provider"] == "minimax" + assert attributes["llm.model"] == "MiniMax-M2.5" + + def test_extract_chat_with_system_message(self): + """Test extracting chat with system message.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.5", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello!"}, + ], + } + + extra_attrs = dict(extractor.get_extra_attributes_from_request(request_params)) + + # Should have messages + assert any("input_messages" in k for k in extra_attrs.keys()) + + def test_extract_tools(self): + """Test extracting tools from request.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.5", + "messages": [{"role": "user", "content": "Hello"}], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather info", + "parameters": {"type": "object", "properties": {}}, + }, + } + ], + } + + extra_attrs = dict(extractor.get_extra_attributes_from_request(request_params)) + + assert extra_attrs.get("minimax.tools_count") == 1 + + def test_extract_invocation_parameters(self): + """Test extracting invocation parameters.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.5", + "messages": [{"role": "user", "content": "Hello"}], + "temperature": 0.7, + "max_tokens": 1024, + "top_p": 0.9, + "frequency_penalty": 0.5, + "presence_penalty": 0.5, + } + + attributes = dict(extractor.get_attributes_from_request(request_params)) + + assert "llm.invocation_parameters" in attributes + params = json.loads(attributes["llm.invocation_parameters"]) + assert params["temperature"] == 0.7 + assert params["max_tokens"] == 1024 + + def test_extract_highspeed_model(self): + """Test extracting attributes for MiniMax-M2.5-highspeed model.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.5-highspeed", + "messages": [{"role": "user", "content": "Hello"}], + } + + attributes = dict(extractor.get_attributes_from_request(request_params)) + + assert attributes["llm.model"] == "MiniMax-M2.5-highspeed" + + +class TestChatCompletionResponseAttributesExtractor: + """Tests for chat completion response attributes extraction.""" + + def test_extract_token_counts(self): + """Test extracting token counts from response.""" + from traceai_minimax._response_attributes_extractor import _ChatCompletionResponseAttributesExtractor + + extractor = _ChatCompletionResponseAttributesExtractor() + response = { + "id": "test-id", + "choices": [ + {"message": {"role": "assistant", "content": "Hello!"}} + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 15, + "total_tokens": 25, + }, + } + + attributes = dict(extractor.get_attributes(response)) + + assert attributes.get("llm.token_count.prompt") == 10 + assert attributes.get("llm.token_count.completion") == 15 + + def test_extract_response_content(self): + """Test extracting response content.""" + from traceai_minimax._response_attributes_extractor import _ChatCompletionResponseAttributesExtractor + + extractor = _ChatCompletionResponseAttributesExtractor() + response = { + "choices": [ + {"message": {"role": "assistant", "content": "Paris is the capital."}} + ], + } + + extra_attrs = dict(extractor.get_extra_attributes(response, {})) + + # Content is captured in output messages + assert extra_attrs.get("llm.output_messages.0.message.content") == "Paris is the capital." + + def test_extract_tool_calls(self): + """Test extracting tool calls from response.""" + from traceai_minimax._response_attributes_extractor import _ChatCompletionResponseAttributesExtractor + + extractor = _ChatCompletionResponseAttributesExtractor() + response = { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + {"function": {"name": "get_weather", "arguments": '{"location": "Paris"}'}} + ], + } + } + ], + } + + extra_attrs = dict(extractor.get_extra_attributes(response, {})) + + assert extra_attrs.get("minimax.tool_calls_count") == 1 + + def test_extract_response_id(self): + """Test extracting response ID.""" + from traceai_minimax._response_attributes_extractor import _ChatCompletionResponseAttributesExtractor + + extractor = _ChatCompletionResponseAttributesExtractor() + response = { + "id": "chatcmpl-abc123", + "choices": [ + {"message": {"role": "assistant", "content": "Hello!"}} + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + } + + attributes = dict(extractor.get_attributes(response)) + + assert attributes.get("minimax.response_id") == "chatcmpl-abc123" + + +class TestUtils: + """Tests for utility functions.""" + + def test_to_dict_with_dict(self): + """Test _to_dict with dictionary input.""" + from traceai_minimax._utils import _to_dict + + input_dict = {"key": "value"} + result = _to_dict(input_dict) + + assert result == input_dict + + def test_to_dict_with_none(self): + """Test _to_dict with None input.""" + from traceai_minimax._utils import _to_dict + + result = _to_dict(None) + + assert result == {} + + def test_to_dict_with_object(self): + """Test _to_dict with object having model_dump.""" + from traceai_minimax._utils import _to_dict + + class MockResponse: + def model_dump(self): + return {"key": "value"} + + result = _to_dict(MockResponse()) + + assert result == {"key": "value"} + + def test_is_minimax_client_with_minimax_url(self): + """Test is_minimax_client with MiniMax URL.""" + from traceai_minimax._utils import is_minimax_client + + mock_client = MagicMock() + mock_client.base_url = "https://api.minimax.io/v1" + + assert is_minimax_client(mock_client) is True + + def test_is_minimax_client_with_openai_url(self): + """Test is_minimax_client with OpenAI URL.""" + from traceai_minimax._utils import is_minimax_client + + mock_client = MagicMock() + mock_client.base_url = "https://api.openai.com/v1" + + assert is_minimax_client(mock_client) is False + + def test_is_minimax_client_with_minimax_chat_url(self): + """Test is_minimax_client with api.minimax.chat URL.""" + from traceai_minimax._utils import is_minimax_client + + mock_client = MagicMock() + mock_client.base_url = "https://api.minimax.chat/v1" + + assert is_minimax_client(mock_client) is True + + +class TestRealWorldScenarios: + """Tests for real-world usage scenarios.""" + + def test_multi_turn_conversation_attributes(self): + """Test attributes for multi-turn conversation.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.5", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "My name is Alice."}, + {"role": "assistant", "content": "Hello Alice!"}, + {"role": "user", "content": "What is my name?"}, + ], + } + + extra_attrs = dict(extractor.get_extra_attributes_from_request(request_params)) + + # Should have 4 messages + message_keys = [k for k in extra_attrs.keys() if "input_messages" in k] + assert len(message_keys) >= 4 + + def test_function_calling_flow(self): + """Test function calling flow attributes.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + from traceai_minimax._response_attributes_extractor import _ChatCompletionResponseAttributesExtractor + + # Initial request with tools + req_extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.5", + "messages": [{"role": "user", "content": "What's the weather in Paris?"}], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get current weather", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string"}, + }, + }, + }, + } + ], + "tool_choice": "auto", + } + req_attrs = dict(req_extractor.get_extra_attributes_from_request(request_params)) + assert req_attrs.get("minimax.tools_count") == 1 + + # Response with tool call + resp_extractor = _ChatCompletionResponseAttributesExtractor() + response = { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + } + extra_attrs = dict(resp_extractor.get_extra_attributes(response, {})) + assert extra_attrs.get("minimax.tool_calls_count") == 1 + assert extra_attrs.get("minimax.finish_reason") == "tool_calls" + + def test_streaming_response_extractor(self): + """Test streaming response accumulation.""" + from traceai_minimax._response_attributes_extractor import _StreamingChatCompletionResponseExtractor + + extractor = _StreamingChatCompletionResponseExtractor() + + # Simulate streaming chunks + chunks = [ + {"id": "chatcmpl-123", "model": "MiniMax-M2.5", "choices": [{"delta": {"role": "assistant"}, "finish_reason": None}]}, + {"id": "chatcmpl-123", "model": "MiniMax-M2.5", "choices": [{"delta": {"content": "Hello"}, "finish_reason": None}]}, + {"id": "chatcmpl-123", "model": "MiniMax-M2.5", "choices": [{"delta": {"content": " world!"}, "finish_reason": None}]}, + {"id": "chatcmpl-123", "model": "MiniMax-M2.5", "choices": [{"delta": {}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}}, + ] + + for chunk in chunks: + extractor.process_chunk(chunk) + + attrs = dict(extractor.get_attributes()) + extra_attrs = dict(extractor.get_extra_attributes({})) + + assert attrs.get("llm.model") == "MiniMax-M2.5" + assert attrs.get("minimax.response_id") == "chatcmpl-123" + assert attrs.get("llm.token_count.prompt") == 5 + assert attrs.get("llm.token_count.completion") == 3 + assert extra_attrs.get("minimax.finish_reason") == "stop" diff --git a/python/frameworks/minimax/traceai_minimax/__init__.py b/python/frameworks/minimax/traceai_minimax/__init__.py new file mode 100644 index 00000000..989c884f --- /dev/null +++ b/python/frameworks/minimax/traceai_minimax/__init__.py @@ -0,0 +1,122 @@ +import logging +from importlib import import_module +from typing import Any, Collection + +logger = logging.getLogger(__name__) + +try: + from fi.evals import Protect +except ImportError: + logger.debug("ai-evaluation is not installed") + Protect = None + +from fi_instrumentation import FITracer, TraceConfig +from fi_instrumentation.instrumentation._protect_wrapper import GuardrailProtectWrapper +from opentelemetry import trace as trace_api +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from wrapt import wrap_function_wrapper + +from traceai_minimax._wrappers import ( + _AsyncChatCompletionWrapper, + _ChatCompletionWrapper, +) +from traceai_minimax.version import __version__ + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +_instruments = ("openai >= 1.0.0",) + + +class MiniMaxInstrumentor(BaseInstrumentor): + """ + OpenTelemetry instrumentor for MiniMax. + + MiniMax uses the OpenAI SDK with a custom base_url (https://api.minimax.io/v1). + This instrumentor wraps OpenAI client methods and only instruments calls + when the client is configured with a MiniMax base URL. + + Supports: + - MiniMax-M2.5 (204K context window) + - MiniMax-M2.5-highspeed (204K context window, faster inference) + - Streaming and non-streaming responses + - Function/tool calling + """ + + __slots__ = ( + "_original_chat_create", + "_original_async_chat_create", + "_tracer", + ) + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Any) -> None: + if not (tracer_provider := kwargs.get("tracer_provider")): + tracer_provider = trace_api.get_tracer_provider() + if not (config := kwargs.get("config")): + config = TraceConfig() + else: + assert isinstance(config, TraceConfig) + + self._tracer = FITracer( + trace_api.get_tracer(__name__, __version__, tracer_provider), + config=config, + ) + + # Import openai module + try: + openai_module = import_module("openai") + except ImportError: + logger.warning("openai package not installed, MiniMax instrumentation skipped") + return + + # Wrap OpenAI client chat.completions.create + # The wrapper checks if the client is configured for MiniMax + try: + # Sync client + if hasattr(openai_module, "OpenAI"): + from openai.resources.chat import completions + + self._original_chat_create = completions.Completions.create + wrap_function_wrapper( + module="openai.resources.chat.completions", + name="Completions.create", + wrapper=_ChatCompletionWrapper(tracer=self._tracer), + ) + + # Async client + if hasattr(openai_module, "AsyncOpenAI"): + from openai.resources.chat import completions as async_completions + + self._original_async_chat_create = async_completions.AsyncCompletions.create + wrap_function_wrapper( + module="openai.resources.chat.completions", + name="AsyncCompletions.create", + wrapper=_AsyncChatCompletionWrapper(tracer=self._tracer), + ) + except Exception as e: + logger.warning(f"Failed to instrument OpenAI for MiniMax: {e}") + + # Wrap Protect if available + if Protect is not None: + self._original_protect = Protect.protect + wrap_function_wrapper( + module="fi.evals", + name="Protect.protect", + wrapper=GuardrailProtectWrapper(tracer=self._tracer), + ) + else: + self._original_protect = None + + def _uninstrument(self, **kwargs: Any) -> None: + try: + from openai.resources.chat import completions + + if hasattr(self, "_original_chat_create"): + completions.Completions.create = self._original_chat_create + if hasattr(self, "_original_async_chat_create"): + completions.AsyncCompletions.create = self._original_async_chat_create + except Exception as e: + logger.warning(f"Failed to uninstrument: {e}") diff --git a/python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py b/python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py new file mode 100644 index 00000000..01f01dd0 --- /dev/null +++ b/python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py @@ -0,0 +1,115 @@ +import logging +from typing import Any, Dict, Iterator, Mapping, Tuple + +from fi_instrumentation import safe_json_dumps +from fi_instrumentation.fi_types import ( + FiMimeTypeValues, + FiSpanKindValues, + MessageAttributes, + SpanAttributes, +) +from opentelemetry.util.types import AttributeValue + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class _ChatCompletionRequestAttributesExtractor: + """Extract span attributes from MiniMax chat completion request.""" + + def get_attributes_from_request( + self, request_parameters: Mapping[str, Any] + ) -> Iterator[Tuple[str, AttributeValue]]: + """Extract primary attributes from request.""" + yield SpanAttributes.GEN_AI_SPAN_KIND, FiSpanKindValues.LLM.value + yield SpanAttributes.GEN_AI_PROVIDER_NAME, "minimax" + + model = request_parameters.get("model", "MiniMax-M2.5") + yield SpanAttributes.GEN_AI_REQUEST_MODEL, model + + yield SpanAttributes.INPUT_MIME_TYPE, FiMimeTypeValues.JSON.value + yield SpanAttributes.OUTPUT_MIME_TYPE, FiMimeTypeValues.JSON.value + + # Invocation parameters + invocation_params = self._extract_invocation_parameters(request_parameters) + if invocation_params: + yield SpanAttributes.GEN_AI_REQUEST_PARAMETERS, safe_json_dumps(invocation_params) + + def get_extra_attributes_from_request( + self, request_parameters: Mapping[str, Any] + ) -> Iterator[Tuple[str, AttributeValue]]: + """Extract extra attributes including messages.""" + messages = request_parameters.get("messages", []) + + # Track input messages + for i, msg in enumerate(messages): + role = msg.get("role", "") + content = msg.get("content", "") + + # Handle content that might be a list (for multimodal) + if isinstance(content, list): + text_parts = [] + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + text_parts.append(part.get("text", "")) + content = "\n".join(text_parts) + + yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_ROLE}", role + yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_CONTENT}", str(content) + + # Handle tool calls in assistant messages + if role == "assistant" and "tool_calls" in msg: + tool_calls = msg.get("tool_calls", []) + for j, tc in enumerate(tool_calls): + yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.id", tc.get("id", "") + yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.type", tc.get("type", "function") + if "function" in tc: + yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.function.name", tc["function"].get("name", "") + yield f"{SpanAttributes.GEN_AI_INPUT_MESSAGES}.{i}.tool_calls.{j}.function.arguments", tc["function"].get("arguments", "") + + # System prompt extraction + for msg in messages: + if msg.get("role") == "system": + yield SpanAttributes.GEN_AI_PROVIDER_NAME_PROMPT, str(msg.get("content", "")) + break + + # User input for display + for msg in reversed(messages): + if msg.get("role") == "user": + content = msg.get("content", "") + if isinstance(content, list): + text_parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"] + content = "\n".join(text_parts) + yield SpanAttributes.INPUT_VALUE, str(content) + break + + # Tools + if "tools" in request_parameters: + tools = request_parameters.get("tools", []) + yield "minimax.tools_count", len(tools) + for i, tool in enumerate(tools[:10]): # Limit to first 10 + if "function" in tool: + func = tool["function"] + yield f"minimax.tools.{i}.name", func.get("name", "") + yield f"minimax.tools.{i}.description", func.get("description", "")[:200] # Truncate + + # Response format + if "response_format" in request_parameters: + resp_format = request_parameters.get("response_format", {}) + if isinstance(resp_format, dict): + yield "minimax.response_format", resp_format.get("type", "text") + + def _extract_invocation_parameters( + self, request_parameters: Mapping[str, Any] + ) -> Dict[str, Any]: + """Extract model invocation parameters.""" + params = {} + param_keys = [ + "temperature", "max_tokens", "top_p", "stop", "stream", + "frequency_penalty", "presence_penalty", "seed", "logprobs", + "top_logprobs", "n" + ] + for key in param_keys: + if key in request_parameters: + params[key] = request_parameters[key] + return params diff --git a/python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py b/python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py new file mode 100644 index 00000000..3557b9c7 --- /dev/null +++ b/python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py @@ -0,0 +1,202 @@ +import logging +from typing import Any, Dict, Iterator, Mapping, Optional, Tuple + +from fi_instrumentation import safe_json_dumps +from fi_instrumentation.fi_types import ( + MessageAttributes, + SpanAttributes, +) +from opentelemetry.util.types import AttributeValue + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class _ChatCompletionResponseAttributesExtractor: + """Extract span attributes from MiniMax chat completion response.""" + + def get_attributes( + self, + response: Optional[Dict[str, Any]], + is_streaming: bool = False, + ) -> Iterator[Tuple[str, AttributeValue]]: + """Extract primary attributes from response.""" + if response is None: + return + + # Token usage + usage = response.get("usage", {}) + if usage: + if "prompt_tokens" in usage: + yield SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS, usage["prompt_tokens"] + if "completion_tokens" in usage: + yield SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, usage["completion_tokens"] + if "total_tokens" in usage: + yield SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS, usage["total_tokens"] + + # Model info + if "model" in response: + yield SpanAttributes.GEN_AI_REQUEST_MODEL, response["model"] + + # Response ID + if "id" in response: + yield "minimax.response_id", response["id"] + + def get_extra_attributes( + self, + response: Optional[Dict[str, Any]], + request_parameters: Mapping[str, Any], + is_streaming: bool = False, + ) -> Iterator[Tuple[str, AttributeValue]]: + """Extract extra attributes including output.""" + if response is None: + return + + choices = response.get("choices", []) + + for i, choice in enumerate(choices): + message = choice.get("message", {}) + + # Role + role = message.get("role", "assistant") + yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_ROLE}", role + + # Content + content = message.get("content", "") + if content: + yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.{i}.{MessageAttributes.MESSAGE_CONTENT}", content + + # Tool calls + tool_calls = message.get("tool_calls", []) + if tool_calls: + yield "minimax.tool_calls_count", len(tool_calls) + for j, tc in enumerate(tool_calls[:10]): # Limit + yield f"minimax.tool_calls.{j}.id", tc.get("id", "") + yield f"minimax.tool_calls.{j}.type", tc.get("type", "function") + if "function" in tc: + yield f"minimax.tool_calls.{j}.function.name", tc["function"].get("name", "") + yield f"minimax.tool_calls.{j}.function.arguments", tc["function"].get("arguments", "") + + # Finish reason + finish_reason = choice.get("finish_reason", "") + if finish_reason: + yield "minimax.finish_reason", finish_reason + + # Logprobs + if "logprobs" in choice and choice["logprobs"]: + yield "minimax.has_logprobs", True + + # Primary output + if choices: + first_choice = choices[0] + message = first_choice.get("message", {}) + content = message.get("content", "") + if content: + yield SpanAttributes.OUTPUT_VALUE, content + + # Raw output + yield SpanAttributes.OUTPUT_VALUE, safe_json_dumps(response) + + +class _StreamingChatCompletionResponseExtractor: + """Extract attributes from streaming MiniMax response chunks.""" + + def __init__(self): + self._content_parts = [] + self._tool_calls = {} + self._finish_reason = None + self._usage = {} + self._model = None + self._response_id = None + + def process_chunk(self, chunk: Dict[str, Any]) -> None: + """Process a single streaming chunk.""" + if "id" in chunk and not self._response_id: + self._response_id = chunk["id"] + + if "model" in chunk and not self._model: + self._model = chunk["model"] + + # Handle usage in final chunk + if "usage" in chunk: + self._usage = chunk["usage"] + + choices = chunk.get("choices", []) + for choice in choices: + delta = choice.get("delta", {}) + + # Content + if "content" in delta and delta["content"]: + self._content_parts.append(delta["content"]) + + # Tool calls + if "tool_calls" in delta: + for tc in delta["tool_calls"]: + idx = tc.get("index", 0) + if idx not in self._tool_calls: + self._tool_calls[idx] = { + "id": tc.get("id", ""), + "type": tc.get("type", "function"), + "function": {"name": "", "arguments": ""} + } + if "id" in tc and tc["id"]: + self._tool_calls[idx]["id"] = tc["id"] + if "function" in tc: + if "name" in tc["function"] and tc["function"]["name"]: + self._tool_calls[idx]["function"]["name"] = tc["function"]["name"] + if "arguments" in tc["function"]: + self._tool_calls[idx]["function"]["arguments"] += tc["function"]["arguments"] + + # Finish reason + if "finish_reason" in choice and choice["finish_reason"]: + self._finish_reason = choice["finish_reason"] + + def get_attributes(self) -> Iterator[Tuple[str, AttributeValue]]: + """Get attributes from accumulated stream data.""" + # Token usage + if self._usage: + if "prompt_tokens" in self._usage: + yield SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS, self._usage["prompt_tokens"] + if "completion_tokens" in self._usage: + yield SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS, self._usage["completion_tokens"] + if "total_tokens" in self._usage: + yield SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS, self._usage["total_tokens"] + + if self._model: + yield SpanAttributes.GEN_AI_REQUEST_MODEL, self._model + + if self._response_id: + yield "minimax.response_id", self._response_id + + def get_extra_attributes( + self, request_parameters: Mapping[str, Any] + ) -> Iterator[Tuple[str, AttributeValue]]: + """Get extra attributes from accumulated stream.""" + content = "".join(self._content_parts) + + # Output message + yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.0.{MessageAttributes.MESSAGE_ROLE}", "assistant" + if content: + yield f"{SpanAttributes.GEN_AI_OUTPUT_MESSAGES}.0.{MessageAttributes.MESSAGE_CONTENT}", content + yield SpanAttributes.OUTPUT_VALUE, content + + # Tool calls + if self._tool_calls: + yield "minimax.tool_calls_count", len(self._tool_calls) + for idx, tc in self._tool_calls.items(): + yield f"minimax.tool_calls.{idx}.id", tc["id"] + yield f"minimax.tool_calls.{idx}.type", tc["type"] + yield f"minimax.tool_calls.{idx}.function.name", tc["function"]["name"] + yield f"minimax.tool_calls.{idx}.function.arguments", tc["function"]["arguments"] + + # Finish reason + if self._finish_reason: + yield "minimax.finish_reason", self._finish_reason + + # Raw output summary + output_summary = { + "content": content[:500] if len(content) > 500 else content, + "tool_calls": list(self._tool_calls.values()), + "finish_reason": self._finish_reason + } + yield SpanAttributes.OUTPUT_VALUE, safe_json_dumps(output_summary) diff --git a/python/frameworks/minimax/traceai_minimax/_utils.py b/python/frameworks/minimax/traceai_minimax/_utils.py new file mode 100644 index 00000000..49ef8449 --- /dev/null +++ b/python/frameworks/minimax/traceai_minimax/_utils.py @@ -0,0 +1,88 @@ +import logging +from typing import Any, Dict, Iterator, Mapping, Optional, Tuple + +from fi_instrumentation import safe_json_dumps +from opentelemetry import trace as trace_api +from opentelemetry.util.types import AttributeValue + +from traceai_minimax._with_span import _WithSpan + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +# MiniMax base URL patterns +MINIMAX_BASE_URLS = [ + "api.minimax.io", + "api.minimax.chat", +] + + +def is_minimax_client(instance: Any) -> bool: + """Check if the OpenAI client is configured for MiniMax.""" + try: + base_url = getattr(instance, "base_url", None) + if base_url is None: + # Check _client for wrapped clients + client = getattr(instance, "_client", None) + if client is not None: + base_url = getattr(client, "base_url", None) + + if base_url is not None: + base_url_str = str(base_url).lower() + return any(url in base_url_str for url in MINIMAX_BASE_URLS) + except Exception: + pass + return False + + +def _to_dict(obj: Any) -> Dict[str, Any]: + """Convert an object to a dictionary.""" + if obj is None: + return {} + if isinstance(obj, dict): + return obj + if hasattr(obj, "model_dump"): + return obj.model_dump() + if hasattr(obj, "dict"): + return obj.dict() + if hasattr(obj, "__dict__"): + return obj.__dict__ + return {"value": str(obj)} + + +def _flatten( + mapping: Mapping[str, Any], prefix: str = "" +) -> Iterator[Tuple[str, AttributeValue]]: + """Flatten a nested dictionary into dot-separated keys.""" + for key, value in mapping.items(): + if value is None: + continue + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, Mapping): + yield from _flatten(value, full_key) + elif isinstance(value, (list, tuple)): + if value and isinstance(value[0], Mapping): + for i, item in enumerate(value): + yield from _flatten(item, f"{full_key}.{i}") + else: + yield full_key, safe_json_dumps(value) + else: + yield full_key, value + + +def _finish_tracing( + status: trace_api.Status, + with_span: _WithSpan, + attributes: Optional[Iterator[Tuple[str, AttributeValue]]] = None, + extra_attributes: Optional[Iterator[Tuple[str, AttributeValue]]] = None, +) -> None: + """Finish tracing with status and attributes.""" + if attributes: + for key, value in attributes: + if value is not None: + with_span._span.set_attribute(key, value) + if extra_attributes: + for key, value in extra_attributes: + if value is not None: + with_span._span.set_attribute(key, value) + with_span.finish_tracing(status=status) diff --git a/python/frameworks/minimax/traceai_minimax/_with_span.py b/python/frameworks/minimax/traceai_minimax/_with_span.py new file mode 100644 index 00000000..dd0d014d --- /dev/null +++ b/python/frameworks/minimax/traceai_minimax/_with_span.py @@ -0,0 +1,40 @@ +from typing import Any, Dict, Optional + +from opentelemetry import trace as trace_api + + +class _WithSpan: + """Helper class to manage span lifecycle and attributes.""" + + __slots__ = ("_span", "_context_attributes", "_extra_attributes") + + def __init__( + self, + span: trace_api.Span, + context_attributes: Optional[Dict[str, Any]] = None, + extra_attributes: Optional[Dict[str, Any]] = None, + ) -> None: + self._span = span + self._context_attributes = context_attributes or {} + self._extra_attributes = extra_attributes or {} + + def record_exception(self, exception: Exception) -> None: + """Record an exception on the span.""" + if self._span.is_recording(): + self._span.record_exception(exception) + + def finish_tracing( + self, + status: Optional[trace_api.Status] = None, + ) -> None: + """Finish the span with optional status.""" + if self._span.is_recording(): + if status is not None: + self._span.set_status(status) + for key, value in self._context_attributes.items(): + if value is not None: + self._span.set_attribute(key, value) + for key, value in self._extra_attributes.items(): + if value is not None: + self._span.set_attribute(key, value) + self._span.end() diff --git a/python/frameworks/minimax/traceai_minimax/_wrappers.py b/python/frameworks/minimax/traceai_minimax/_wrappers.py new file mode 100644 index 00000000..6e2eea23 --- /dev/null +++ b/python/frameworks/minimax/traceai_minimax/_wrappers.py @@ -0,0 +1,274 @@ +import logging +from abc import ABC +from contextlib import contextmanager +from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, Tuple + +import opentelemetry.context as context_api +from fi_instrumentation import get_attributes_from_context, safe_json_dumps +from fi_instrumentation.fi_types import SpanAttributes +from opentelemetry import trace as trace_api +from opentelemetry.trace import INVALID_SPAN +from opentelemetry.util.types import AttributeValue + +from traceai_minimax._request_attributes_extractor import ( + _ChatCompletionRequestAttributesExtractor, +) +from traceai_minimax._response_attributes_extractor import ( + _ChatCompletionResponseAttributesExtractor, + _StreamingChatCompletionResponseExtractor, +) +from traceai_minimax._utils import _finish_tracing, _to_dict, is_minimax_client +from traceai_minimax._with_span import _WithSpan + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + + +class _WithTracer(ABC): + """Base class for wrappers that need a tracer.""" + + def __init__(self, tracer: trace_api.Tracer, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._tracer = tracer + + @contextmanager + def _start_as_current_span( + self, + span_name: str, + attributes: Iterable[Tuple[str, AttributeValue]], + context_attributes: Iterable[Tuple[str, AttributeValue]], + extra_attributes: Iterable[Tuple[str, AttributeValue]], + ) -> Iterator[_WithSpan]: + try: + span = self._tracer.start_span( + name=span_name, attributes=dict(extra_attributes) + ) + except Exception: + span = INVALID_SPAN + with trace_api.use_span( + span, + end_on_exit=False, + record_exception=False, + set_status_on_exception=False, + ) as span: + yield _WithSpan( + span=span, + context_attributes=dict(context_attributes), + extra_attributes=dict(attributes), + ) + + +class _ChatCompletionWrapper(_WithTracer): + """Wrapper for MiniMax chat completions (via OpenAI client).""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._request_extractor = _ChatCompletionRequestAttributesExtractor() + self._response_extractor = _ChatCompletionResponseAttributesExtractor() + + def __call__( + self, + wrapped: Callable[..., Any], + instance: Any, + args: Tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + if context_api.get_value(context_api._SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + # Check if this is a MiniMax client + # Get the parent client from the completions instance + client = getattr(instance, "_client", None) + if client is None: + # Try to get from chat.completions structure + chat = getattr(instance, "_parent", None) or getattr(instance, "chat", None) + if chat: + client = getattr(chat, "_client", None) + + if not is_minimax_client(client) and not is_minimax_client(instance): + return wrapped(*args, **kwargs) + + request_parameters = dict(kwargs) + is_streaming = request_parameters.get("stream", False) + + span_name = "minimax.chat.completions" + + with self._start_as_current_span( + span_name=span_name, + attributes=self._request_extractor.get_attributes_from_request(request_parameters), + context_attributes=get_attributes_from_context(), + extra_attributes=self._request_extractor.get_extra_attributes_from_request(request_parameters), + ) as span: + try: + response = wrapped(*args, **kwargs) + except Exception as exception: + span.record_exception(exception) + status = trace_api.Status( + status_code=trace_api.StatusCode.ERROR, + description=f"{type(exception).__name__}: {exception}", + ) + span.finish_tracing(status=status) + raise + + if is_streaming: + return self._handle_streaming_response(span, response, request_parameters) + + try: + response_dict = _to_dict(response) + _finish_tracing( + status=trace_api.Status(status_code=trace_api.StatusCode.OK), + with_span=span, + attributes=self._response_extractor.get_attributes(response_dict), + extra_attributes=self._response_extractor.get_extra_attributes( + response_dict, request_parameters + ), + ) + except Exception: + logger.exception("Failed to finalize response") + span.finish_tracing() + return response + + def _handle_streaming_response( + self, span: _WithSpan, response: Any, request_parameters: Dict[str, Any] + ): + """Handle streaming response.""" + stream_extractor = _StreamingChatCompletionResponseExtractor() + + def streaming_wrapper(): + try: + for chunk in response: + chunk_dict = _to_dict(chunk) + stream_extractor.process_chunk(chunk_dict) + yield chunk + + except Exception as exception: + span.record_exception(exception) + status = trace_api.Status( + status_code=trace_api.StatusCode.ERROR, + description=f"{type(exception).__name__}: {exception}", + ) + span.finish_tracing(status=status) + raise + else: + try: + _finish_tracing( + status=trace_api.Status(status_code=trace_api.StatusCode.OK), + with_span=span, + attributes=stream_extractor.get_attributes(), + extra_attributes=stream_extractor.get_extra_attributes(request_parameters), + ) + except Exception: + logger.exception("Failed to finalize streaming response") + span.finish_tracing() + finally: + if span._span.is_recording(): + span.finish_tracing() + + return streaming_wrapper() + + +class _AsyncChatCompletionWrapper(_WithTracer): + """Async wrapper for MiniMax chat completions.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._request_extractor = _ChatCompletionRequestAttributesExtractor() + self._response_extractor = _ChatCompletionResponseAttributesExtractor() + + async def __call__( + self, + wrapped: Callable[..., Any], + instance: Any, + args: Tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + if context_api.get_value(context_api._SUPPRESS_INSTRUMENTATION_KEY): + return await wrapped(*args, **kwargs) + + # Check if this is a MiniMax client + client = getattr(instance, "_client", None) + if client is None: + chat = getattr(instance, "_parent", None) or getattr(instance, "chat", None) + if chat: + client = getattr(chat, "_client", None) + + if not is_minimax_client(client) and not is_minimax_client(instance): + return await wrapped(*args, **kwargs) + + request_parameters = dict(kwargs) + is_streaming = request_parameters.get("stream", False) + + span_name = "minimax.chat.completions" + + with self._start_as_current_span( + span_name=span_name, + attributes=self._request_extractor.get_attributes_from_request(request_parameters), + context_attributes=get_attributes_from_context(), + extra_attributes=self._request_extractor.get_extra_attributes_from_request(request_parameters), + ) as span: + try: + response = await wrapped(*args, **kwargs) + except Exception as exception: + span.record_exception(exception) + status = trace_api.Status( + status_code=trace_api.StatusCode.ERROR, + description=f"{type(exception).__name__}: {exception}", + ) + span.finish_tracing(status=status) + raise + + if is_streaming: + return self._handle_async_streaming_response(span, response, request_parameters) + + try: + response_dict = _to_dict(response) + _finish_tracing( + status=trace_api.Status(status_code=trace_api.StatusCode.OK), + with_span=span, + attributes=self._response_extractor.get_attributes(response_dict), + extra_attributes=self._response_extractor.get_extra_attributes( + response_dict, request_parameters + ), + ) + except Exception: + logger.exception("Failed to finalize response") + span.finish_tracing() + return response + + def _handle_async_streaming_response( + self, span: _WithSpan, response: Any, request_parameters: Dict[str, Any] + ): + """Handle async streaming response.""" + stream_extractor = _StreamingChatCompletionResponseExtractor() + + async def async_streaming_wrapper(): + try: + async for chunk in response: + chunk_dict = _to_dict(chunk) + stream_extractor.process_chunk(chunk_dict) + yield chunk + + except Exception as exception: + span.record_exception(exception) + status = trace_api.Status( + status_code=trace_api.StatusCode.ERROR, + description=f"{type(exception).__name__}: {exception}", + ) + span.finish_tracing(status=status) + raise + else: + try: + _finish_tracing( + status=trace_api.Status(status_code=trace_api.StatusCode.OK), + with_span=span, + attributes=stream_extractor.get_attributes(), + extra_attributes=stream_extractor.get_extra_attributes(request_parameters), + ) + except Exception: + logger.exception("Failed to finalize async streaming response") + span.finish_tracing() + finally: + if span._span.is_recording(): + span.finish_tracing() + + return async_streaming_wrapper() diff --git a/python/frameworks/minimax/traceai_minimax/version.py b/python/frameworks/minimax/traceai_minimax/version.py new file mode 100644 index 00000000..3dc1f76b --- /dev/null +++ b/python/frameworks/minimax/traceai_minimax/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" From 20ef0bfc16bf89efb3b7556cf8f002aa4744e810 Mon Sep 17 00:00:00 2001 From: Octopus Date: Wed, 18 Mar 2026 12:17:01 -0500 Subject: [PATCH 2/3] feat: upgrade MiniMax default model to M2.7 - Add MiniMax-M2.7 and MiniMax-M2.7-highspeed to model list - Set MiniMax-M2.7 as default model - Keep all previous models as alternatives - Update related tests and documentation --- python/frameworks/minimax/README.md | 18 ++++++----- .../frameworks/minimax/examples/basic_chat.py | 10 +++--- .../minimax/tests/test_instrumentation.py | 31 +++++++++++++++++-- .../minimax/traceai_minimax/__init__.py | 2 ++ .../_request_attributes_extractor.py | 2 +- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/python/frameworks/minimax/README.md b/python/frameworks/minimax/README.md index 4796f13e..6369047b 100644 --- a/python/frameworks/minimax/README.md +++ b/python/frameworks/minimax/README.md @@ -11,7 +11,7 @@ pip install traceai-minimax ## Features - Automatic tracing of MiniMax API calls via OpenAI SDK -- Support for MiniMax-M2.5 and MiniMax-M2.5-highspeed models (204K context) +- Support for MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, and MiniMax-M2.5-highspeed models - Streaming response support - Token usage tracking - Function/tool calling support @@ -44,7 +44,7 @@ client = OpenAI( ) response = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[{"role": "user", "content": "Hello!"}] ) print(response.choices[0].message.content) @@ -62,7 +62,7 @@ client = OpenAI( # Simple chat response = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is machine learning?"} @@ -85,7 +85,7 @@ client = OpenAI( # Streaming chat stream = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[{"role": "user", "content": "Tell me a story"}], stream=True ) @@ -133,7 +133,7 @@ tools = [ ] response = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[{"role": "user", "content": "What's the weather in Paris?"}], tools=tools, tool_choice="auto" @@ -159,7 +159,7 @@ async def main(): ) response = await client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[{"role": "user", "content": "Hello!"}] ) print(response.choices[0].message.content) @@ -178,7 +178,7 @@ client = OpenAI( ) response = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[ {"role": "system", "content": "Output valid JSON only."}, {"role": "user", "content": "List 3 programming languages with their main use cases"} @@ -219,7 +219,7 @@ MiniMaxInstrumentor().instrument( | `fi.span.kind` | "LLM" | | `llm.system` | "minimax" | | `llm.provider` | "minimax" | -| `llm.model` | Model name (MiniMax-M2.5, MiniMax-M2.5-highspeed) | +| `llm.model` | Model name (MiniMax-M2.7, MiniMax-M2.7-highspeed, MiniMax-M2.5, MiniMax-M2.5-highspeed) | | `llm.token_count.prompt` | Input token count | | `llm.token_count.completion` | Output token count | | `llm.token_count.total` | Total token count | @@ -237,6 +237,8 @@ MiniMaxInstrumentor().instrument( | Model | Description | |-------|-------------| +| `MiniMax-M2.7` | Latest flagship model with enhanced reasoning and coding | +| `MiniMax-M2.7-highspeed` | High-speed version of M2.7 for low-latency scenarios | | `MiniMax-M2.5` | General-purpose model with 204K context window | | `MiniMax-M2.5-highspeed` | Faster inference variant with 204K context window | diff --git a/python/frameworks/minimax/examples/basic_chat.py b/python/frameworks/minimax/examples/basic_chat.py index 810dba66..2c1a9583 100644 --- a/python/frameworks/minimax/examples/basic_chat.py +++ b/python/frameworks/minimax/examples/basic_chat.py @@ -30,7 +30,7 @@ def simple_chat(): """Simple chat completion.""" print("=== Simple Chat ===") response = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "What is machine learning?"}, @@ -46,7 +46,7 @@ def streaming_chat(): """Streaming chat completion.""" print("=== Streaming Chat ===") stream = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[{"role": "user", "content": "Tell me a short story"}], stream=True, ) @@ -81,7 +81,7 @@ def function_calling(): ] response = client.chat.completions.create( - model="MiniMax-M2.5", + model="MiniMax-M2.7", messages=[{"role": "user", "content": "What's the weather in Paris?"}], tools=tools, tool_choice="auto", @@ -98,10 +98,10 @@ def function_calling(): def highspeed_chat(): - """Using MiniMax-M2.5-highspeed for faster inference.""" + """Using MiniMax-M2.7-highspeed for faster inference.""" print("=== Highspeed Chat ===") response = client.chat.completions.create( - model="MiniMax-M2.5-highspeed", + model="MiniMax-M2.7-highspeed", messages=[ {"role": "user", "content": "Summarize the key features of Python in 3 bullet points."}, ], diff --git a/python/frameworks/minimax/tests/test_instrumentation.py b/python/frameworks/minimax/tests/test_instrumentation.py index ce073811..a308e714 100644 --- a/python/frameworks/minimax/tests/test_instrumentation.py +++ b/python/frameworks/minimax/tests/test_instrumentation.py @@ -26,7 +26,7 @@ def test_extract_chat_attributes(self): extractor = _ChatCompletionRequestAttributesExtractor() request_params = { - "model": "MiniMax-M2.5", + "model": "MiniMax-M2.7", "messages": [ {"role": "user", "content": "Hello!"} ], @@ -37,7 +37,34 @@ def test_extract_chat_attributes(self): assert attributes["fi.span.kind"] == "LLM" assert attributes["llm.provider"] == "minimax" - assert attributes["llm.model"] == "MiniMax-M2.5" + assert attributes["llm.model"] == "MiniMax-M2.7" + + def test_default_model_is_m27(self): + """Test that default model is MiniMax-M2.7 when not specified.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "messages": [{"role": "user", "content": "Hello!"}], + } + + attributes = dict(extractor.get_attributes_from_request(request_params)) + + assert attributes["llm.model"] == "MiniMax-M2.7" + + def test_extract_m27_highspeed_model(self): + """Test extracting attributes for MiniMax-M2.7-highspeed model.""" + from traceai_minimax._request_attributes_extractor import _ChatCompletionRequestAttributesExtractor + + extractor = _ChatCompletionRequestAttributesExtractor() + request_params = { + "model": "MiniMax-M2.7-highspeed", + "messages": [{"role": "user", "content": "Hello"}], + } + + attributes = dict(extractor.get_attributes_from_request(request_params)) + + assert attributes["llm.model"] == "MiniMax-M2.7-highspeed" def test_extract_chat_with_system_message(self): """Test extracting chat with system message.""" diff --git a/python/frameworks/minimax/traceai_minimax/__init__.py b/python/frameworks/minimax/traceai_minimax/__init__.py index 989c884f..983085c9 100644 --- a/python/frameworks/minimax/traceai_minimax/__init__.py +++ b/python/frameworks/minimax/traceai_minimax/__init__.py @@ -37,6 +37,8 @@ class MiniMaxInstrumentor(BaseInstrumentor): when the client is configured with a MiniMax base URL. Supports: + - MiniMax-M2.7 (latest flagship model with enhanced reasoning and coding) + - MiniMax-M2.7-highspeed (high-speed version of M2.7 for low-latency scenarios) - MiniMax-M2.5 (204K context window) - MiniMax-M2.5-highspeed (204K context window, faster inference) - Streaming and non-streaming responses diff --git a/python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py b/python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py index 01f01dd0..f97c0e38 100644 --- a/python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py +++ b/python/frameworks/minimax/traceai_minimax/_request_attributes_extractor.py @@ -24,7 +24,7 @@ def get_attributes_from_request( yield SpanAttributes.GEN_AI_SPAN_KIND, FiSpanKindValues.LLM.value yield SpanAttributes.GEN_AI_PROVIDER_NAME, "minimax" - model = request_parameters.get("model", "MiniMax-M2.5") + model = request_parameters.get("model", "MiniMax-M2.7") yield SpanAttributes.GEN_AI_REQUEST_MODEL, model yield SpanAttributes.INPUT_MIME_TYPE, FiMimeTypeValues.JSON.value From 7150178bc55717f9bf7ffe1c85b793dcb226bb38 Mon Sep 17 00:00:00 2001 From: Octopus Date: Thu, 19 Mar 2026 02:36:52 -0500 Subject: [PATCH 3/3] Address code review feedback from @NVJKKartik - Remove protect wrapper entirely from MiniMaxInstrumentor (traced by other instrumentors), resolving both the __slots__ mismatch and the missing uninstrument restore - Fix dual OUTPUT_VALUE overwrite in both non-streaming and streaming response extractors by removing the unconditional raw JSON dump that silently overwrote human-readable content - Replace string constants with MiniMaxBaseURL enum in _utils.py and switch from substring matching to strict hostname matching via urlparse for safety - Clean up conftest.py to remove now-unnecessary protect/fi.evals mocks --- python/frameworks/minimax/tests/conftest.py | 14 -------------- .../minimax/traceai_minimax/__init__.py | 18 ------------------ .../_response_attributes_extractor.py | 12 ------------ .../minimax/traceai_minimax/_utils.py | 17 ++++++++++------- 4 files changed, 10 insertions(+), 51 deletions(-) diff --git a/python/frameworks/minimax/tests/conftest.py b/python/frameworks/minimax/tests/conftest.py index ccdfd1f3..2fa183c8 100644 --- a/python/frameworks/minimax/tests/conftest.py +++ b/python/frameworks/minimax/tests/conftest.py @@ -80,23 +80,9 @@ class MockFiMimeTypeValues: mock_fi_instrumentation.get_attributes_from_context = MagicMock(return_value={}) mock_fi_instrumentation.fi_types = mock_fi_types - # Mock fi_instrumentation.instrumentation submodule - mock_fi_instr_instr = create_mock_module("fi_instrumentation.instrumentation") - mock_fi_instr_protect = create_mock_module("fi_instrumentation.instrumentation._protect_wrapper") - mock_fi_instr_protect.GuardrailProtectWrapper = MagicMock() - - # Mock fi.evals - mock_fi = create_mock_module("fi") - mock_fi_evals = create_mock_module("fi.evals") - mock_fi_evals.Protect = None - # Register fi mocks sys.modules["fi_instrumentation"] = mock_fi_instrumentation sys.modules["fi_instrumentation.fi_types"] = mock_fi_types - sys.modules["fi_instrumentation.instrumentation"] = mock_fi_instr_instr - sys.modules["fi_instrumentation.instrumentation._protect_wrapper"] = mock_fi_instr_protect - sys.modules["fi"] = mock_fi - sys.modules["fi.evals"] = mock_fi_evals # Mock opentelemetry - comprehensive mock mock_otel = create_mock_module("opentelemetry") diff --git a/python/frameworks/minimax/traceai_minimax/__init__.py b/python/frameworks/minimax/traceai_minimax/__init__.py index 983085c9..f3b1e6fe 100644 --- a/python/frameworks/minimax/traceai_minimax/__init__.py +++ b/python/frameworks/minimax/traceai_minimax/__init__.py @@ -4,14 +4,7 @@ logger = logging.getLogger(__name__) -try: - from fi.evals import Protect -except ImportError: - logger.debug("ai-evaluation is not installed") - Protect = None - from fi_instrumentation import FITracer, TraceConfig -from fi_instrumentation.instrumentation._protect_wrapper import GuardrailProtectWrapper from opentelemetry import trace as trace_api from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from wrapt import wrap_function_wrapper @@ -101,17 +94,6 @@ def _instrument(self, **kwargs: Any) -> None: except Exception as e: logger.warning(f"Failed to instrument OpenAI for MiniMax: {e}") - # Wrap Protect if available - if Protect is not None: - self._original_protect = Protect.protect - wrap_function_wrapper( - module="fi.evals", - name="Protect.protect", - wrapper=GuardrailProtectWrapper(tracer=self._tracer), - ) - else: - self._original_protect = None - def _uninstrument(self, **kwargs: Any) -> None: try: from openai.resources.chat import completions diff --git a/python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py b/python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py index 3557b9c7..58c9319a 100644 --- a/python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py +++ b/python/frameworks/minimax/traceai_minimax/_response_attributes_extractor.py @@ -1,7 +1,6 @@ import logging from typing import Any, Dict, Iterator, Mapping, Optional, Tuple -from fi_instrumentation import safe_json_dumps from fi_instrumentation.fi_types import ( MessageAttributes, SpanAttributes, @@ -94,9 +93,6 @@ def get_extra_attributes( if content: yield SpanAttributes.OUTPUT_VALUE, content - # Raw output - yield SpanAttributes.OUTPUT_VALUE, safe_json_dumps(response) - class _StreamingChatCompletionResponseExtractor: """Extract attributes from streaming MiniMax response chunks.""" @@ -192,11 +188,3 @@ def get_extra_attributes( # Finish reason if self._finish_reason: yield "minimax.finish_reason", self._finish_reason - - # Raw output summary - output_summary = { - "content": content[:500] if len(content) > 500 else content, - "tool_calls": list(self._tool_calls.values()), - "finish_reason": self._finish_reason - } - yield SpanAttributes.OUTPUT_VALUE, safe_json_dumps(output_summary) diff --git a/python/frameworks/minimax/traceai_minimax/_utils.py b/python/frameworks/minimax/traceai_minimax/_utils.py index 49ef8449..75da8043 100644 --- a/python/frameworks/minimax/traceai_minimax/_utils.py +++ b/python/frameworks/minimax/traceai_minimax/_utils.py @@ -1,5 +1,7 @@ import logging +from enum import Enum from typing import Any, Dict, Iterator, Mapping, Optional, Tuple +from urllib.parse import urlparse from fi_instrumentation import safe_json_dumps from opentelemetry import trace as trace_api @@ -10,11 +12,11 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -# MiniMax base URL patterns -MINIMAX_BASE_URLS = [ - "api.minimax.io", - "api.minimax.chat", -] + +class MiniMaxBaseURL(str, Enum): + """MiniMax API base URL hostnames.""" + API_IO = "api.minimax.io" + API_CHAT = "api.minimax.chat" def is_minimax_client(instance: Any) -> bool: @@ -28,8 +30,9 @@ def is_minimax_client(instance: Any) -> bool: base_url = getattr(client, "base_url", None) if base_url is not None: - base_url_str = str(base_url).lower() - return any(url in base_url_str for url in MINIMAX_BASE_URLS) + parsed = urlparse(str(base_url)) + hostname = (parsed.hostname or "").lower() + return hostname in {url.value for url in MiniMaxBaseURL} except Exception: pass return False