diff --git a/26.multiple-llm-providers-with-litellm.ipynb b/26.multiple-llm-providers-with-litellm.ipynb index da9f873..cf9cc6a 100644 --- a/26.multiple-llm-providers-with-litellm.ipynb +++ b/26.multiple-llm-providers-with-litellm.ipynb @@ -324,6 +324,20 @@ "print(textwrap.fill(response.choices[0].message.content, 120))" ] }, + { + "cell_type": "code", + "source": "%%time\n\nimport os\n\nresponse = completion(\n model=\"openai/MiniMax-M2.7\",\n messages=[{\"content\": PROMPT, \"role\": \"user\"}],\n api_base=\"https://api.minimax.io/v1\",\n api_key=os.environ.get(\"MINIMAX_API_KEY\"),\n temperature=0.1,\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "source": "print(textwrap.fill(response.choices[0].message.content, 120))", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, { "cell_type": "markdown", "metadata": { @@ -819,4 +833,4 @@ }, "nbformat": 4, "nbformat_minor": 0 -} +} \ No newline at end of file diff --git a/README.md b/README.md index 36837ab..10e8687 100644 --- a/README.md +++ b/README.md @@ -74,4 +74,15 @@ Build the future of automation. Design intelligent agents that can reason, plan, | Teamwork Makes the Dream Work - Build Agentic Workflow | Build an agentic workflow that analyzes Reddit posts and generates a report based on the analysis. All using only local models. | [Read](https://www.mlexpert.io/academy/v1/ai-agents/build-agentic-workflow) | [Watch](https://www.youtube.com/watch?v=dVf1z2BDVtI) | | Thinking and Acting - Build an AI Agent | Build an AI agent that lets you to talk to your database. Working with a local LLM using LangChain and Ollama. | [Read](https://www.mlexpert.io/academy/v1/ai-agents/build-ai-agent) | [Watch](https://www.youtube.com/watch?v=ay_sYadoxgk) | | Chat With Your Data - A Local MCP AI Agent | Build a secure, local-first AI agent that can chat with your files. This tutorial uses the Model Context Protocol (MCP), LangGraph, and Streamlit to create a powerful personal knowledge manager. | [Read](https://www.mlexpert.io/academy/v1/ai-agents/build-mcp-agent) | [Watch](https://www.youtube.com/watch?v=ZkMlWwgiFGw) | -| Agentic RAG - Building an AI Financial Analyst Team | Build a multi-agent system with LangGraph that dynamically plans and retrieves financial data from stock APIs and SEC filings to answer complex questions, moving beyond simple RAG pipelines. | [Read](https://www.mlexpert.io/academy/v1/ai-agents/agentic-rag) | | \ No newline at end of file +| Agentic RAG - Building an AI Financial Analyst Team | Build a multi-agent system with LangGraph that dynamically plans and retrieves financial data from stock APIs and SEC filings to answer complex questions, moving beyond simple RAG pipelines. | [Read](https://www.mlexpert.io/academy/v1/ai-agents/agentic-rag) | | + +## Model Explorations + +Hands-on notebooks for exploring specific LLM providers and models. + +| Model | Description | Notebook | +| ----- | ----------- | -------- | +| GPT-4o | OpenAI's multimodal model - text generation, vision, streaming, JSON output, and tool calling | [Open](gpt-4o.ipynb) | +| DeepSeek R1 | DeepSeek's reasoning model via Ollama - lyrics, coding, labeling, summarization, and structured data extraction | [Open](deepseek-r1.ipynb) | +| MiniMax M2.7 | MiniMax's 204K context model via OpenAI-compatible API - text generation, streaming, structured output, tool calling, and data labelling | [Open](minimax-m2.7.ipynb) | +| Multiple Providers with LiteLLM | Use OpenAI, Gemini, and MiniMax through a single unified interface with litellm | [Open](26.multiple-llm-providers-with-litellm.ipynb) | \ No newline at end of file diff --git a/minimax-m2.7.ipynb b/minimax-m2.7.ipynb new file mode 100644 index 0000000..d94cb0d --- /dev/null +++ b/minimax-m2.7.ipynb @@ -0,0 +1,624 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "cell-0", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install -Uqqq pip --progress-bar off\n", + "!pip install -qqq openai==1.82.0 --progress-bar off\n", + "!pip install -qqq python-dotenv==1.1.0 --progress-bar off\n", + "!pip install -qqq pydantic==2.11.7 --progress-bar off" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-1", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import textwrap\n", + "from typing import Literal\n", + "\n", + "from dotenv import load_dotenv\n", + "from IPython.display import Markdown, display\n", + "from openai import OpenAI\n", + "from pydantic import BaseModel\n", + "\n", + "load_dotenv()\n", + "\n", + "MODEL_NAME = \"MiniMax-M2.7\"\n", + "\n", + "client = OpenAI(\n", + " api_key=os.environ[\"MINIMAX_API_KEY\"],\n", + " base_url=\"https://api.minimax.io/v1\",\n", + ")\n", + "\n", + "\n", + "def format_response(response):\n", + " response_txt = response.choices[0].message.content\n", + " text = \"\"\n", + " for chunk in response_txt.split(\"\\n\"):\n", + " text += \"\\n\"\n", + " if not chunk:\n", + " continue\n", + " text += (\n", + " \"\\n\".join(textwrap.wrap(chunk, 100, break_long_words=False))\n", + " ).strip()\n", + " return text.strip()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "## Text Generation\n", + "\n", + "[MiniMax M2.7](https://www.minimax.io/) is a large language model with a 204K context window.\n", + "It provides an OpenAI-compatible API, so you can use the standard `openai` Python SDK." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-3", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "\n", + "messages = [\n", + " {\"role\": \"user\", \"content\": \"Explain what is Deep Learning in one sentence\"},\n", + "]\n", + "\n", + "response = client.chat.completions.create(\n", + " model=MODEL_NAME, messages=messages, temperature=0.1\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-4", + "metadata": {}, + "outputs": [], + "source": [ + "print(textwrap.fill(response.choices[0].message.content, 120))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-5", + "metadata": {}, + "outputs": [], + "source": [ + "usage = response.usage\n", + "print(\n", + " f\"\"\"\n", + "Tokens Used\n", + "\n", + "Prompt: {usage.prompt_tokens}\n", + "Completion: {usage.completion_tokens}\n", + "Total: {usage.total_tokens}\n", + "\"\"\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "## System Prompt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-7", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "\n", + "messages = [\n", + " {\n", + " \"role\": \"system\",\n", + " \"content\": \"You are a helpful AI coding assistant. Always provide clear, concise answers with code examples.\",\n", + " },\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"How do I read a CSV file in Python using pandas?\",\n", + " },\n", + "]\n", + "\n", + "response = client.chat.completions.create(\n", + " model=MODEL_NAME, messages=messages, temperature=0.1\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-8", + "metadata": {}, + "outputs": [], + "source": [ + "display(Markdown(format_response(response)))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-9", + "metadata": {}, + "source": [ + "## Streaming" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-10", + "metadata": {}, + "outputs": [], + "source": [ + "messages = [\n", + " {\"role\": \"user\", \"content\": \"Write a short poem about machine learning\"},\n", + "]\n", + "\n", + "completion = client.chat.completions.create(\n", + " model=MODEL_NAME, messages=messages, temperature=0.7, stream=True\n", + ")\n", + "\n", + "for chunk in completion:\n", + " content = chunk.choices[0].delta.content\n", + " if content is not None:\n", + " print(content, end=\"\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-11", + "metadata": {}, + "source": [ + "## JSON Response" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-12", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "\n", + "messages = [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"List the top 3 programming languages for AI development. Return a JSON object with a 'languages' key containing a list of objects with 'name', 'reason', and 'popularity_rank' fields.\",\n", + " },\n", + "]\n", + "\n", + "response = client.chat.completions.create(\n", + " model=MODEL_NAME,\n", + " messages=messages,\n", + " response_format={\"type\": \"json_object\"},\n", + " temperature=0.1,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-13", + "metadata": {}, + "outputs": [], + "source": [ + "result = json.loads(response.choices[0].message.content)\n", + "print(json.dumps(result, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "## Structured Output with Pydantic" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-15", + "metadata": {}, + "outputs": [], + "source": [ + "class SentimentClassification(BaseModel):\n", + " sentiment: Literal[\"negative\", \"neutral\", \"positive\"]\n", + " reasoning: str\n", + "\n", + "\n", + "PROMPT = \"\"\"\n", + "Classify the text sentiment into one of negative, neutral or positive.\n", + "Give your reasoning in the `reasoning` field.\n", + "\n", + "Text:\n", + "```\n", + "I am very happy to say that AI has taken my job, for good!\n", + "```\n", + "\n", + "Respond with a JSON object matching this schema:\n", + "{\"sentiment\": \"negative|neutral|positive\", \"reasoning\": \"your reasoning\"}\n", + "\"\"\"\n", + "\n", + "messages = [{\"role\": \"user\", \"content\": PROMPT}]\n", + "\n", + "response = client.chat.completions.create(\n", + " model=MODEL_NAME,\n", + " messages=messages,\n", + " response_format={\"type\": \"json_object\"},\n", + " temperature=0.1,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-16", + "metadata": {}, + "outputs": [], + "source": [ + "json_content = response.choices[0].message.content\n", + "result = SentimentClassification.model_validate_json(json_content)\n", + "print(f\"Sentiment: {result.sentiment}\")\n", + "print(f\"Reasoning: {result.reasoning}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-17", + "metadata": {}, + "source": [ + "## Simulating a Chat" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-18", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "\n", + "messages = [\n", + " {\n", + " \"role\": \"system\",\n", + " \"content\": \"You are a helpful AI tutor specializing in machine learning.\",\n", + " },\n", + " {\"role\": \"user\", \"content\": \"What is the difference between supervised and unsupervised learning?\"},\n", + " {\n", + " \"role\": \"assistant\",\n", + " \"content\": \"Supervised learning uses labeled data to train models, while unsupervised learning finds patterns in unlabeled data.\",\n", + " },\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": \"Give me an example of each.\",\n", + " },\n", + "]\n", + "\n", + "response = client.chat.completions.create(\n", + " model=MODEL_NAME, messages=messages, temperature=0.1\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-19", + "metadata": {}, + "outputs": [], + "source": [ + "display(Markdown(format_response(response)))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-20", + "metadata": {}, + "source": [ + "## Using Tools (Function Calling)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-21", + "metadata": {}, + "outputs": [], + "source": [ + "def get_weather(city: str, unit: str = \"celsius\") -> str:\n", + " \"\"\"Get the current weather for a city.\n", + "\n", + " Parameters\n", + " ----------\n", + " city : str\n", + " The city name\n", + " unit : str\n", + " Temperature unit (celsius or fahrenheit)\n", + "\n", + " Returns\n", + " -------\n", + " str\n", + " Weather information as JSON string\n", + " \"\"\"\n", + " weather_data = {\n", + " \"San Francisco\": {\"temp\": 18, \"condition\": \"Foggy\"},\n", + " \"New York\": {\"temp\": 25, \"condition\": \"Sunny\"},\n", + " \"London\": {\"temp\": 15, \"condition\": \"Rainy\"},\n", + " \"Tokyo\": {\"temp\": 28, \"condition\": \"Humid\"},\n", + " }\n", + " data = weather_data.get(city, {\"temp\": 20, \"condition\": \"Unknown\"})\n", + " if unit == \"fahrenheit\":\n", + " data[\"temp\"] = round(data[\"temp\"] * 9 / 5 + 32)\n", + " data[\"unit\"] = unit\n", + " data[\"city\"] = city\n", + " return json.dumps(data)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-22", + "metadata": {}, + "outputs": [], + "source": [ + "tools = [\n", + " {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": \"get_weather\",\n", + " \"description\": \"Get the current weather for a city\",\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " \"city\": {\n", + " \"type\": \"string\",\n", + " \"description\": \"The city name\",\n", + " },\n", + " \"unit\": {\n", + " \"type\": \"string\",\n", + " \"enum\": [\"celsius\", \"fahrenheit\"],\n", + " \"description\": \"Temperature unit\",\n", + " },\n", + " },\n", + " \"required\": [\"city\"],\n", + " },\n", + " },\n", + " }\n", + "]\n", + "\n", + "messages = [\n", + " {\"role\": \"user\", \"content\": \"What's the weather like in Tokyo?\"}\n", + "]\n", + "\n", + "response = client.chat.completions.create(\n", + " model=MODEL_NAME,\n", + " messages=messages,\n", + " tools=tools,\n", + " tool_choice=\"auto\",\n", + " temperature=0.1,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-23", + "metadata": {}, + "outputs": [], + "source": [ + "response_message = response.choices[0].message\n", + "tool_calls = response_message.tool_calls\n", + "print(f\"Tool calls: {tool_calls}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-24", + "metadata": {}, + "outputs": [], + "source": [ + "available_functions = {\"get_weather\": get_weather}\n", + "\n", + "messages.append(response_message)\n", + "\n", + "for tool_call in tool_calls:\n", + " function_name = tool_call.function.name\n", + " function_to_call = available_functions[function_name]\n", + " function_args = json.loads(tool_call.function.arguments)\n", + " function_response = function_to_call(**function_args)\n", + " messages.append(\n", + " {\n", + " \"tool_call_id\": tool_call.id,\n", + " \"role\": \"tool\",\n", + " \"name\": function_name,\n", + " \"content\": function_response,\n", + " }\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-25", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "\n", + "final_response = client.chat.completions.create(\n", + " model=MODEL_NAME, messages=messages, temperature=0.1\n", + ")\n", + "\n", + "print(final_response.choices[0].message.content)" + ] + }, + { + "cell_type": "markdown", + "id": "cell-26", + "metadata": {}, + "source": [ + "## Text Summarization" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-27", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "\n", + "article = \"\"\"\n", + "Artificial intelligence has made remarkable strides in recent years, particularly in the field of\n", + "natural language processing. Large language models, trained on vast amounts of text data, have\n", + "demonstrated the ability to generate human-like text, translate languages, write different kinds\n", + "of creative content, and answer questions in an informative way. These models use transformer\n", + "architectures that process text through attention mechanisms, allowing them to capture long-range\n", + "dependencies in language. The implications for industries ranging from healthcare to education\n", + "are profound, as these models can assist with everything from medical diagnosis to personalized\n", + "tutoring. However, challenges remain around hallucination, bias, and the environmental cost of\n", + "training increasingly large models.\n", + "\"\"\"\n", + "\n", + "messages = [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": f\"Summarize the following text in 2-3 sentences:\\n\\n{article}\",\n", + " },\n", + "]\n", + "\n", + "response = client.chat.completions.create(\n", + " model=MODEL_NAME, messages=messages, temperature=0.1\n", + ")\n", + "\n", + "print(textwrap.fill(response.choices[0].message.content, 120))" + ] + }, + { + "cell_type": "markdown", + "id": "cell-28", + "metadata": {}, + "source": [ + "## Data Labelling" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-29", + "metadata": {}, + "outputs": [], + "source": [ + "CLASSIFY_TEXT_PROMPT = \"\"\"\n", + "Your task is to analyze the following text and classify it based on multiple criteria.\n", + "Provide your analysis as a JSON object. Use only the specified categories:\n", + "\n", + "1. Target audience:\n", + " ['General public', 'Professionals', 'Academics', 'Students']\n", + "\n", + "2. Tone:\n", + " ['Neutral', 'Positive', 'Negative', 'Formal', 'Informal', 'Humorous']\n", + "\n", + "3. Complexity level:\n", + " ['Elementary', 'Intermediate', 'Advanced', 'Technical']\n", + "\n", + "4. Main topic:\n", + " ['Technology', 'Science', 'Health', 'Business', 'Education', 'Entertainment']\n", + "\n", + "For each classification, choose the most appropriate category.\n", + "\n", + "\n", + "{text}\n", + "\n", + "\n", + "Respond with JSON: {{\"target_audience\": \"...\", \"tone\": \"...\", \"complexity\": \"...\", \"topic\": \"...\"}}\n", + "\"\"\"\n", + "\n", + "texts = [\n", + " \"The new iPhone 16 features an improved camera system and A18 chip.\",\n", + " \"Deep reinforcement learning combines neural networks with reward-based training.\",\n", + " \"Five easy stretches you can do at your desk to reduce back pain!\",\n", + "]\n", + "\n", + "results = []\n", + "for text in texts:\n", + " response = client.chat.completions.create(\n", + " model=MODEL_NAME,\n", + " messages=[{\"role\": \"user\", \"content\": CLASSIFY_TEXT_PROMPT.format(text=text)}],\n", + " response_format={\"type\": \"json_object\"},\n", + " temperature=0.1,\n", + " )\n", + " result = json.loads(response.choices[0].message.content)\n", + " result[\"text\"] = text[:60]\n", + " results.append(result)\n", + "\n", + "for r in results:\n", + " print(json.dumps(r, indent=2))\n", + " print()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-30", + "metadata": {}, + "source": [ + "## Using the Highspeed Model\n", + "\n", + "MiniMax also offers `MiniMax-M2.7-highspeed` for faster inference at a lower cost." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-31", + "metadata": {}, + "outputs": [], + "source": [ + "%%time\n", + "\n", + "response = client.chat.completions.create(\n", + " model=\"MiniMax-M2.7-highspeed\",\n", + " messages=[{\"role\": \"user\", \"content\": \"Explain what is Deep Learning in one sentence\"}],\n", + " temperature=0.1,\n", + ")\n", + "\n", + "print(textwrap.fill(response.choices[0].message.content, 120))" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.13.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_minimax.py b/tests/test_minimax.py new file mode 100644 index 0000000..ab86a93 --- /dev/null +++ b/tests/test_minimax.py @@ -0,0 +1,355 @@ +"""Unit tests for MiniMax M2.7 notebook code patterns.""" + +import json +import os +import textwrap +from typing import Literal +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import BaseModel + + +class SentimentClassification(BaseModel): + sentiment: Literal["negative", "neutral", "positive"] + reasoning: str + + +# --- Unit Tests --- + + +class TestMiniMaxClientSetup: + """Test MiniMax client configuration.""" + + def test_base_url_is_correct(self): + base_url = "https://api.minimax.io/v1" + assert base_url.startswith("https://") + assert "minimax.io" in base_url + assert base_url.endswith("/v1") + + def test_model_names(self): + models = ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"] + for model in models: + assert model.startswith("MiniMax-") + assert "M2.7" in model + + def test_temperature_must_be_positive(self): + valid_temps = [0.1, 0.5, 0.7, 1.0] + invalid_temps = [0.0, -0.1, -1.0] + for temp in valid_temps: + assert 0 < temp <= 1.0 + for temp in invalid_temps: + assert not (0 < temp <= 1.0) + + +class TestFormatResponse: + """Test the format_response helper function.""" + + def test_format_simple_text(self): + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "Hello world" + + response_txt = mock_response.choices[0].message.content + text = "" + for chunk in response_txt.split("\n"): + text += "\n" + if not chunk: + continue + text += ( + "\n".join(textwrap.wrap(chunk, 100, break_long_words=False)) + ).strip() + result = text.strip() + assert result == "Hello world" + + def test_format_multiline_text(self): + mock_response = MagicMock() + mock_response.choices[0].message.content = "Line 1\n\nLine 2\nLine 3" + + response_txt = mock_response.choices[0].message.content + text = "" + for chunk in response_txt.split("\n"): + text += "\n" + if not chunk: + continue + text += ( + "\n".join(textwrap.wrap(chunk, 100, break_long_words=False)) + ).strip() + result = text.strip() + assert "Line 1" in result + assert "Line 2" in result + assert "Line 3" in result + + def test_format_long_line_wraps(self): + long_text = "A " * 100 # 200 chars + mock_response = MagicMock() + mock_response.choices[0].message.content = long_text.strip() + + response_txt = mock_response.choices[0].message.content + text = "" + for chunk in response_txt.split("\n"): + text += "\n" + if not chunk: + continue + text += ( + "\n".join(textwrap.wrap(chunk, 100, break_long_words=False)) + ).strip() + result = text.strip() + lines = result.split("\n") + assert len(lines) > 1 + + +class TestStructuredOutput: + """Test Pydantic model parsing for structured output.""" + + def test_valid_sentiment_positive(self): + json_str = '{"sentiment": "positive", "reasoning": "The user is happy"}' + result = SentimentClassification.model_validate_json(json_str) + assert result.sentiment == "positive" + assert result.reasoning == "The user is happy" + + def test_valid_sentiment_negative(self): + json_str = '{"sentiment": "negative", "reasoning": "The user is sad"}' + result = SentimentClassification.model_validate_json(json_str) + assert result.sentiment == "negative" + + def test_valid_sentiment_neutral(self): + json_str = '{"sentiment": "neutral", "reasoning": "The text is factual"}' + result = SentimentClassification.model_validate_json(json_str) + assert result.sentiment == "neutral" + + def test_invalid_sentiment_raises(self): + json_str = '{"sentiment": "angry", "reasoning": "Invalid value"}' + with pytest.raises(Exception): + SentimentClassification.model_validate_json(json_str) + + def test_missing_field_raises(self): + json_str = '{"sentiment": "positive"}' + with pytest.raises(Exception): + SentimentClassification.model_validate_json(json_str) + + +class TestToolCalling: + """Test tool/function calling patterns.""" + + def test_get_weather_function(self): + def get_weather(city: str, unit: str = "celsius") -> str: + weather_data = { + "San Francisco": {"temp": 18, "condition": "Foggy"}, + "New York": {"temp": 25, "condition": "Sunny"}, + "London": {"temp": 15, "condition": "Rainy"}, + "Tokyo": {"temp": 28, "condition": "Humid"}, + } + data = weather_data.get(city, {"temp": 20, "condition": "Unknown"}) + if unit == "fahrenheit": + data["temp"] = round(data["temp"] * 9 / 5 + 32) + data["unit"] = unit + data["city"] = city + return json.dumps(data) + + result = json.loads(get_weather("Tokyo")) + assert result["city"] == "Tokyo" + assert result["temp"] == 28 + assert result["condition"] == "Humid" + assert result["unit"] == "celsius" + + def test_get_weather_fahrenheit(self): + def get_weather(city: str, unit: str = "celsius") -> str: + weather_data = { + "San Francisco": {"temp": 18, "condition": "Foggy"}, + "New York": {"temp": 25, "condition": "Sunny"}, + } + data = weather_data.get(city, {"temp": 20, "condition": "Unknown"}) + if unit == "fahrenheit": + data["temp"] = round(data["temp"] * 9 / 5 + 32) + data["unit"] = unit + data["city"] = city + return json.dumps(data) + + result = json.loads(get_weather("New York", "fahrenheit")) + assert result["temp"] == 77 # 25 * 9/5 + 32 + assert result["unit"] == "fahrenheit" + + def test_get_weather_unknown_city(self): + def get_weather(city: str, unit: str = "celsius") -> str: + weather_data = {} + data = weather_data.get(city, {"temp": 20, "condition": "Unknown"}) + data["unit"] = unit + data["city"] = city + return json.dumps(data) + + result = json.loads(get_weather("UnknownCity")) + assert result["condition"] == "Unknown" + assert result["temp"] == 20 + + def test_tool_schema_format(self): + tool = { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a city", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "The city name"}, + "unit": { + "type": "string", + "enum": ["celsius", "fahrenheit"], + }, + }, + "required": ["city"], + }, + }, + } + assert tool["type"] == "function" + assert tool["function"]["name"] == "get_weather" + assert "city" in tool["function"]["parameters"]["required"] + + def test_tool_call_args_parsing(self): + args_json = '{"city": "Tokyo", "unit": "celsius"}' + args = json.loads(args_json) + assert args["city"] == "Tokyo" + assert args["unit"] == "celsius" + + +class TestDataLabelling: + """Test data labelling prompt patterns.""" + + def test_classify_text_prompt_format(self): + template = """ +Your task is to analyze the following text and classify it. + +{text} + +""" + text = "The new iPhone features an improved camera." + prompt = template.format(text=text) + assert text in prompt + assert "" in prompt + assert "" in prompt + + def test_json_response_parsing(self): + response_json = json.dumps( + { + "target_audience": "General public", + "tone": "Neutral", + "complexity": "Intermediate", + "topic": "Technology", + } + ) + result = json.loads(response_json) + assert result["topic"] == "Technology" + assert result["tone"] == "Neutral" + + def test_multiple_text_classification_results(self): + texts = [ + "Tech news article", + "Scientific paper", + "Health blog post", + ] + results = [] + for text in texts: + results.append({"text": text[:60], "topic": "test"}) + assert len(results) == 3 + assert all("text" in r for r in results) + + +class TestLiteLLMIntegration: + """Test litellm integration patterns for MiniMax.""" + + def test_litellm_model_prefix(self): + model = "openai/MiniMax-M2.7" + assert model.startswith("openai/") + provider, model_name = model.split("/", 1) + assert provider == "openai" + assert model_name == "MiniMax-M2.7" + + def test_litellm_api_base_config(self): + api_base = "https://api.minimax.io/v1" + assert api_base.startswith("https://") + assert "minimax" in api_base + + def test_litellm_completion_kwargs(self): + kwargs = { + "model": "openai/MiniMax-M2.7", + "messages": [{"content": "test", "role": "user"}], + "api_base": "https://api.minimax.io/v1", + "temperature": 0.1, + } + assert kwargs["model"] == "openai/MiniMax-M2.7" + assert kwargs["api_base"] == "https://api.minimax.io/v1" + assert 0 < kwargs["temperature"] <= 1.0 + + def test_message_format(self): + messages = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Hello"}, + ] + assert messages[0]["role"] == "system" + assert messages[1]["role"] == "user" + assert all("role" in m and "content" in m for m in messages) + + +class TestNotebookStructure: + """Test that notebook files are valid JSON.""" + + def test_minimax_notebook_valid_json(self): + notebook_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "minimax-m2.7.ipynb" + ) + with open(notebook_path) as f: + nb = json.load(f) + assert nb["nbformat"] == 4 + assert "cells" in nb + assert len(nb["cells"]) > 0 + + def test_minimax_notebook_has_required_sections(self): + notebook_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "minimax-m2.7.ipynb" + ) + with open(notebook_path) as f: + nb = json.load(f) + + all_source = " ".join( + "".join(cell["source"]) for cell in nb["cells"] + ) + assert "MiniMax-M2.7" in all_source + assert "api.minimax.io" in all_source + assert "MINIMAX_API_KEY" in all_source + assert "json_object" in all_source + assert "stream=True" in all_source + assert "tool_choice" in all_source + + def test_minimax_notebook_cell_types(self): + notebook_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "minimax-m2.7.ipynb" + ) + with open(notebook_path) as f: + nb = json.load(f) + + cell_types = [cell["cell_type"] for cell in nb["cells"]] + assert "code" in cell_types + assert "markdown" in cell_types + + def test_litellm_notebook_has_minimax(self): + notebook_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + "26.multiple-llm-providers-with-litellm.ipynb", + ) + with open(notebook_path) as f: + nb = json.load(f) + + all_source = " ".join( + "".join(cell["source"]) for cell in nb["cells"] + ) + assert "MiniMax-M2.7" in all_source + assert "minimax.io" in all_source + + def test_readme_mentions_minimax(self): + readme_path = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "README.md" + ) + with open(readme_path) as f: + content = f.read() + assert "MiniMax" in content + assert "minimax-m2.7.ipynb" in content diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py new file mode 100644 index 0000000..2230232 --- /dev/null +++ b/tests/test_minimax_integration.py @@ -0,0 +1,107 @@ +"""Integration tests for MiniMax M2.7 API. + +These tests require a valid MINIMAX_API_KEY environment variable. +They are skipped automatically if the key is not set. +""" + +import json +import os +import textwrap + +import pytest + +pytestmark = pytest.mark.skipif( + not os.environ.get("MINIMAX_API_KEY"), + reason="MINIMAX_API_KEY not set", +) + + +@pytest.fixture +def client(): + from openai import OpenAI + + return OpenAI( + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", + ) + + +class TestMiniMaxCompletion: + """Integration tests for MiniMax chat completions.""" + + def test_basic_completion(self, client): + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "Say 'hello' and nothing else."}], + temperature=0.1, + ) + assert response.choices[0].message.content is not None + assert len(response.choices[0].message.content) > 0 + + def test_completion_with_system_prompt(self, client): + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + {"role": "system", "content": "You always respond in exactly one word."}, + {"role": "user", "content": "What color is the sky?"}, + ], + temperature=0.1, + ) + assert response.choices[0].message.content is not None + + def test_json_mode(self, client): + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[ + { + "role": "user", + "content": 'Return a JSON object with key "answer" and value 42. Output only JSON, no explanation.', + } + ], + response_format={"type": "json_object"}, + temperature=0.1, + ) + content = response.choices[0].message.content + # Strip thinking tags if present + import re + content = re.sub(r".*?", "", content, flags=re.DOTALL).strip() + # Extract JSON from markdown code block if present + json_match = re.search(r"```(?:json)?\s*(.*?)\s*```", content, re.DOTALL) + if json_match: + content = json_match.group(1) + result = json.loads(content) + assert "answer" in result + assert result["answer"] == 42 + + def test_streaming(self, client): + stream = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "Count from 1 to 3."}], + temperature=0.1, + stream=True, + ) + chunks = [] + for chunk in stream: + if chunk.choices[0].delta.content is not None: + chunks.append(chunk.choices[0].delta.content) + full_text = "".join(chunks) + assert len(full_text) > 0 + + def test_highspeed_model(self, client): + response = client.chat.completions.create( + model="MiniMax-M2.7-highspeed", + messages=[{"role": "user", "content": "Say 'hi' and nothing else."}], + temperature=0.1, + ) + assert response.choices[0].message.content is not None + + def test_usage_tracking(self, client): + response = client.chat.completions.create( + model="MiniMax-M2.7", + messages=[{"role": "user", "content": "Hello"}], + temperature=0.1, + ) + assert response.usage is not None + assert response.usage.prompt_tokens > 0 + assert response.usage.completion_tokens > 0 + assert response.usage.total_tokens > 0