feat: add MiniMax as alternative LLM provider for Finogrid agents
Add MiniMax MiniMax-M2.5 (204K context) as a cost-effective alternative to OpenAI for sentiment analysis and agent LLM operations. Uses the existing openai SDK via MiniMax's OpenAI-compatible API endpoint. Changes: - MiniMaxSentimentProvider: drop-in replacement for OpenAISentimentFallback - MiniMaxLLMClient: generic async LLM client for agent use - Updated factory to support FINGPT_LLM_PROVIDER=minimax - Added MINIMAX_API_KEY to .env.example and docs - 24 unit tests + 4 integration tests (all passing)
This commit is contained in:
@@ -202,6 +202,16 @@ The datasets we used, and the **multi-task financial LLM** models are available
|
||||
|
||||
|
||||
|
||||
## Cloud LLM Providers for FinGPT Inference
|
||||
|
||||
For the Finogrid platform (finogrid/), FinGPT supports multiple cloud LLM providers as alternatives to running local models. Set `FINGPT_LLM_PROVIDER` in your environment:
|
||||
|
||||
| Provider | Model | Context Length | Use Case |
|
||||
|----------|-------|---------------|----------|
|
||||
| OpenAI | GPT-3.5-turbo | 16K | Default fallback for sentiment & agents |
|
||||
| [MiniMax](https://platform.minimaxi.com/) | MiniMax-M2.5 | 204K | Cost-effective alternative with large context window |
|
||||
| FinGPT (local) | Llama-2-13B LoRA | 4K | Full local inference (requires GPU) |
|
||||
|
||||
## Open-Source Base Model used in the LLMs layer of FinGPT
|
||||
* Feel free to contribute more open-source base models tailored for various language-specific financial markets.
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# ── FinGPT / AI (agents only — NOT in hot path) ───────────────────────────────
|
||||
# LLM provider for sentiment analysis and agent clients: "openai" | "minimax" | "fingpt"
|
||||
FINGPT_LLM_PROVIDER=openai
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
MINIMAX_API_KEY=your_minimax_api_key_here
|
||||
HUGGINGFACE_TOKEN=your_huggingface_token_here
|
||||
FINGPT_MODEL_PATH=FinGPT/fingpt-sentiment_llama2-13b_lora
|
||||
FINGPT_FORECASTER_MODEL=FinGPT/fingpt-forecaster_dow30_llama2-7b_lora
|
||||
|
||||
+3
-1
@@ -173,7 +173,7 @@ const tx = await finogrid.micropay.pay({
|
||||
| Database | AlloyDB (PostgreSQL) / SQLAlchemy 2.0 async |
|
||||
| Messaging | GCP Pub/Sub |
|
||||
| On-chain | Base L2 (native USDC, ~$0.007/tx, 2–10s confirmation) |
|
||||
| AI (inference) | FinGPT Llama-2 LoRA + OpenAI fallback |
|
||||
| AI (inference) | FinGPT Llama-2 LoRA + OpenAI / [MiniMax](https://platform.minimaxi.com/) fallback |
|
||||
| SDK | TypeScript 5.3 (`@finogrid/agent-ledger-sdk`) |
|
||||
| Infrastructure | GCP (Cloud Run, BigQuery, Secret Manager, IAM) |
|
||||
|
||||
@@ -272,7 +272,9 @@ python -m mcp.plaid.server &
|
||||
| `PLAID_SECRET` | Plaid MCP | Plaid secret |
|
||||
| `KYA_VALIDATOR_BACKEND` | KYA MCP | internal \| sardine \| persona |
|
||||
| `OPS_API_KEY` | ops console | Ops-level auth key |
|
||||
| `FINGPT_LLM_PROVIDER` | agents | LLM provider: `openai` \| `minimax` \| `fingpt` |
|
||||
| `OPENAI_API_KEY` | agents | FinGPT OpenAI fallback |
|
||||
| `MINIMAX_API_KEY` | agents | [MiniMax](https://platform.minimaxi.com/) API key (when provider=minimax) |
|
||||
| `PUBSUB_PROJECT_ID` | workers | GCP Pub/Sub project |
|
||||
|
||||
See `.env.example` for the full list.
|
||||
|
||||
@@ -13,9 +13,17 @@ What we do NOT use:
|
||||
- Trading strategies (FinGPT_Others)
|
||||
- v1 sentiment models (superseded by v3)
|
||||
|
||||
For MVP: set USE_OPENAI_FALLBACK=true to use GPT-3.5 with FinGPT prompts
|
||||
For MVP: set FINGPT_USE_OPENAI_FALLBACK=true to use GPT-3.5 with FinGPT prompts
|
||||
instead of loading the full 13B model locally. Same quality for early stage.
|
||||
|
||||
LLM provider selection (for sentiment analysis and agent LLM client):
|
||||
FINGPT_LLM_PROVIDER=openai → OpenAI GPT-3.5-turbo (default)
|
||||
FINGPT_LLM_PROVIDER=minimax → MiniMax MiniMax-M2.5 (204K context, cost-effective)
|
||||
FINGPT_LLM_PROVIDER=fingpt → Local FinGPT model (requires GPU)
|
||||
"""
|
||||
import os
|
||||
|
||||
USE_OPENAI_FALLBACK = os.getenv("FINGPT_USE_OPENAI_FALLBACK", "true").lower() == "true"
|
||||
|
||||
# Provider selection: "openai" (default), "minimax", or "fingpt" (local model)
|
||||
LLM_PROVIDER = os.getenv("FINGPT_LLM_PROVIDER", "openai").lower()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
MiniMax LLM client for Finogrid agents.
|
||||
|
||||
Provides a generic ``await client.chat(prompt)`` interface that any Finogrid
|
||||
agent can use (InternalSupport, AuditGovernance, etc.).
|
||||
|
||||
Uses MiniMax's OpenAI-compatible API so the existing ``openai`` dependency
|
||||
is reused — no new packages required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import structlog
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
MINIMAX_BASE_URL = "https://api.minimax.io/v1"
|
||||
|
||||
|
||||
class MiniMaxLLMClient:
|
||||
"""
|
||||
Async LLM client backed by MiniMax's API.
|
||||
|
||||
Compatible with the ``llm_client`` parameter accepted by all Finogrid
|
||||
agents (``InternalSupportAgent``, ``AuditGovernanceAgent``, etc.).
|
||||
|
||||
Usage::
|
||||
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
|
||||
client = MiniMaxLLMClient()
|
||||
agent = InternalSupportAgent(knowledge_base=kb, llm_client=client)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "MiniMax-M2.5",
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
):
|
||||
self.model = model
|
||||
# MiniMax requires temperature in (0.0, 1.0]
|
||||
self.temperature = max(0.01, min(temperature, 1.0))
|
||||
self.max_tokens = max_tokens
|
||||
|
||||
api_key = os.getenv("MINIMAX_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"MINIMAX_API_KEY environment variable is required. "
|
||||
"Get your API key at https://platform.minimaxi.com/"
|
||||
)
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=MINIMAX_BASE_URL,
|
||||
)
|
||||
|
||||
async def chat(self, prompt: str) -> str:
|
||||
"""Send a prompt and return the assistant's reply text."""
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=self.temperature,
|
||||
max_tokens=self.max_tokens,
|
||||
)
|
||||
return response.choices[0].message.content.strip()
|
||||
except Exception as e:
|
||||
log.error("minimax_llm_chat_failed", error=str(e))
|
||||
raise
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
MiniMax provider for FinGPT sentiment — cost-effective alternative to OpenAI.
|
||||
|
||||
Uses MiniMax's OpenAI-compatible API with MiniMax-M2.5 (204K context).
|
||||
Same interface as OpenAISentimentFallback — drop-in replacement.
|
||||
|
||||
MiniMax API docs: https://platform.minimaxi.com/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import structlog
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
SENTIMENT_PROMPT = (
|
||||
"Instruction: What is the sentiment of this news? "
|
||||
"Please choose an answer from {{positive/negative/neutral}}.\n"
|
||||
"Input: {text}\n"
|
||||
"Answer:"
|
||||
)
|
||||
|
||||
SENTIMENT_MAP = {"positive": 1, "negative": -1, "neutral": 0}
|
||||
|
||||
MINIMAX_BASE_URL = "https://api.minimax.io/v1"
|
||||
|
||||
|
||||
class MiniMaxSentimentProvider:
|
||||
"""
|
||||
Drop-in replacement for OpenAISentimentFallback using MiniMax API.
|
||||
Same interface — swap by setting FINGPT_LLM_PROVIDER=minimax.
|
||||
|
||||
MiniMax's API is OpenAI-compatible, so we reuse the openai SDK
|
||||
with a custom base_url.
|
||||
"""
|
||||
|
||||
def __init__(self, model: str = "MiniMax-M2.5"):
|
||||
self.model = model
|
||||
api_key = os.getenv("MINIMAX_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"MINIMAX_API_KEY environment variable is required. "
|
||||
"Get your API key at https://platform.minimaxi.com/"
|
||||
)
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=MINIMAX_BASE_URL,
|
||||
)
|
||||
|
||||
def load(self):
|
||||
pass # Nothing to load for MiniMax
|
||||
|
||||
async def score(self, text: str) -> dict:
|
||||
prompt = SENTIMENT_PROMPT.format(text=text[:512])
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=5,
|
||||
# MiniMax requires temperature in (0.0, 1.0]; use 0.01 for near-deterministic output
|
||||
temperature=0.01,
|
||||
)
|
||||
answer = response.choices[0].message.content.strip().lower()
|
||||
label = "neutral"
|
||||
for key in SENTIMENT_MAP:
|
||||
if key in answer:
|
||||
label = key
|
||||
break
|
||||
return {"label": label, "score": SENTIMENT_MAP[label], "raw": answer}
|
||||
except Exception as e:
|
||||
log.error("minimax_sentiment_failed", error=str(e))
|
||||
return {"label": "neutral", "score": 0, "error": str(e)}
|
||||
|
||||
async def score_corridor_news(self, news_items: list[dict], corridor_code: str) -> list[dict]:
|
||||
results = []
|
||||
for item in news_items:
|
||||
text = f"{item.get('headline', '')}. {item.get('summary', '')}"
|
||||
sentiment = await self.score(text)
|
||||
results.append({
|
||||
**item,
|
||||
"corridor": corridor_code,
|
||||
"sentiment_label": sentiment["label"],
|
||||
"sentiment_score": sentiment["score"],
|
||||
})
|
||||
return results
|
||||
@@ -71,16 +71,31 @@ class OpenAISentimentFallback:
|
||||
|
||||
def get_sentiment_analyzer():
|
||||
"""
|
||||
Factory: returns OpenAI fallback for MVP, full FinGPT model for production.
|
||||
Controlled by FINGPT_USE_OPENAI_FALLBACK env var.
|
||||
Factory: returns the configured sentiment provider.
|
||||
|
||||
Provider selection (in order of precedence):
|
||||
1. FINGPT_LLM_PROVIDER env var: "openai" | "minimax" | "fingpt"
|
||||
2. FINGPT_USE_OPENAI_FALLBACK env var (legacy): "true" → OpenAI, "false" → FinGPT model
|
||||
|
||||
Examples:
|
||||
FINGPT_LLM_PROVIDER=minimax → MiniMax MiniMax-M2.5
|
||||
FINGPT_LLM_PROVIDER=openai → OpenAI GPT-3.5-turbo (default)
|
||||
FINGPT_LLM_PROVIDER=fingpt → Local FinGPT Llama-2 model (requires GPU)
|
||||
"""
|
||||
from .. import USE_OPENAI_FALLBACK
|
||||
if USE_OPENAI_FALLBACK:
|
||||
log.info("sentiment_using_openai_fallback")
|
||||
return OpenAISentimentFallback()
|
||||
else:
|
||||
from .. import LLM_PROVIDER, USE_OPENAI_FALLBACK
|
||||
|
||||
if LLM_PROVIDER == "minimax":
|
||||
from .minimax_provider import MiniMaxSentimentProvider
|
||||
log.info("sentiment_using_minimax")
|
||||
return MiniMaxSentimentProvider()
|
||||
|
||||
if LLM_PROVIDER == "fingpt" or not USE_OPENAI_FALLBACK:
|
||||
from .crypto_sentiment import FinoGridSentimentAnalyzer
|
||||
log.info("sentiment_using_fingpt_model")
|
||||
analyzer = FinoGridSentimentAnalyzer()
|
||||
analyzer.load()
|
||||
return analyzer
|
||||
|
||||
# Default: OpenAI
|
||||
log.info("sentiment_using_openai_fallback")
|
||||
return OpenAISentimentFallback()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Integration tests for MiniMax provider.
|
||||
|
||||
These tests call the real MiniMax API and require:
|
||||
- MINIMAX_API_KEY environment variable set
|
||||
|
||||
Run with:
|
||||
MINIMAX_API_KEY=your_key pytest finogrid/tests/integration/test_minimax_integration.py -v
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.getenv("MINIMAX_API_KEY"),
|
||||
reason="MINIMAX_API_KEY not set — skipping live integration tests",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_sentiment_live():
|
||||
"""Call MiniMax API to score a financial headline and verify the response shape."""
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
|
||||
provider = MiniMaxSentimentProvider()
|
||||
result = await provider.score("Apple stock surges to all-time high on strong earnings")
|
||||
|
||||
assert "label" in result
|
||||
assert result["label"] in ("positive", "negative", "neutral")
|
||||
assert "score" in result
|
||||
assert result["score"] in (1, 0, -1)
|
||||
assert "raw" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_sentiment_corridor_news_live():
|
||||
"""Score a batch of corridor news items via the MiniMax API."""
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
|
||||
provider = MiniMaxSentimentProvider()
|
||||
news = [
|
||||
{"headline": "Brazil GDP grows 3%", "summary": "Economy beats expectations"},
|
||||
{"headline": "PIX outage nationwide", "summary": "Central bank investigates"},
|
||||
]
|
||||
results = await provider.score_corridor_news(news, "BR")
|
||||
|
||||
assert len(results) == 2
|
||||
for r in results:
|
||||
assert r["corridor"] == "BR"
|
||||
assert r["sentiment_label"] in ("positive", "negative", "neutral")
|
||||
assert r["sentiment_score"] in (1, 0, -1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_llm_client_live():
|
||||
"""Call the MiniMax LLM client and verify it returns a non-empty string."""
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
|
||||
client = MiniMaxLLMClient()
|
||||
response = await client.chat("What is 2 + 2? Answer with just the number.")
|
||||
|
||||
assert isinstance(response, str)
|
||||
assert len(response) > 0
|
||||
assert "4" in response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_minimax_llm_client_with_agent():
|
||||
"""Verify MiniMaxLLMClient works as an llm_client for InternalSupportAgent."""
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
from finogrid.agents.internal_support.agent import InternalSupportAgent
|
||||
|
||||
client = MiniMaxLLMClient()
|
||||
agent = InternalSupportAgent(knowledge_base=None, llm_client=client)
|
||||
|
||||
result = await agent.answer("What is Finogrid?")
|
||||
|
||||
assert "answer" in result
|
||||
assert isinstance(result["answer"], str)
|
||||
assert len(result["answer"]) > 0
|
||||
@@ -0,0 +1,323 @@
|
||||
"""Unit tests for MiniMax sentiment provider and LLM client."""
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MiniMaxSentimentProvider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMiniMaxSentimentProvider:
|
||||
"""Tests for MiniMaxSentimentProvider."""
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_init_with_api_key(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
assert provider.model == "MiniMax-M2.5"
|
||||
assert provider.client is not None
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_init_custom_model(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider(model="MiniMax-M2.5-highspeed")
|
||||
assert provider.model == "MiniMax-M2.5-highspeed"
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_init_missing_api_key(self):
|
||||
# Remove MINIMAX_API_KEY if set
|
||||
os.environ.pop("MINIMAX_API_KEY", None)
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
with pytest.raises(ValueError, match="MINIMAX_API_KEY"):
|
||||
MiniMaxSentimentProvider()
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_load_is_noop(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
provider.load() # Should not raise
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_score_positive(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "positive"
|
||||
|
||||
provider.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await provider.score("Apple stock surges to all-time high")
|
||||
assert result["label"] == "positive"
|
||||
assert result["score"] == 1
|
||||
assert result["raw"] == "positive"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_score_negative(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "negative"
|
||||
|
||||
provider.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await provider.score("Market crashes amid recession fears")
|
||||
assert result["label"] == "negative"
|
||||
assert result["score"] == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_score_neutral(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "neutral"
|
||||
|
||||
provider.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await provider.score("Fed holds rates steady as expected")
|
||||
assert result["label"] == "neutral"
|
||||
assert result["score"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_score_api_error_returns_neutral(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
|
||||
provider.client.chat.completions.create = AsyncMock(side_effect=Exception("API error"))
|
||||
|
||||
result = await provider.score("Some news text")
|
||||
assert result["label"] == "neutral"
|
||||
assert result["score"] == 0
|
||||
assert "error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_score_temperature_is_low(self):
|
||||
"""Verify MiniMax uses near-zero temperature (0.01) for deterministic output."""
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "positive"
|
||||
|
||||
provider.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
await provider.score("test")
|
||||
|
||||
call_kwargs = provider.client.chat.completions.create.call_args[1]
|
||||
assert call_kwargs["temperature"] == 0.01
|
||||
assert call_kwargs["max_tokens"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_score_corridor_news(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "positive"
|
||||
|
||||
provider.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
news = [
|
||||
{"headline": "Brazil economy grows", "summary": "GDP up 3%"},
|
||||
{"headline": "PIX adoption soars", "summary": "50M new users"},
|
||||
]
|
||||
results = await provider.score_corridor_news(news, "BR")
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0]["corridor"] == "BR"
|
||||
assert results[0]["sentiment_label"] == "positive"
|
||||
assert results[0]["sentiment_score"] == 1
|
||||
assert results[0]["headline"] == "Brazil economy grows"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_score_truncates_long_text(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "neutral"
|
||||
|
||||
provider.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
long_text = "x" * 1000
|
||||
await provider.score(long_text)
|
||||
|
||||
call_kwargs = provider.client.chat.completions.create.call_args[1]
|
||||
prompt = call_kwargs["messages"][0]["content"]
|
||||
# The prompt should contain at most 512 chars of the input text
|
||||
assert len(prompt) < 1000
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_base_url_is_minimax(self):
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
provider = MiniMaxSentimentProvider()
|
||||
assert str(provider.client.base_url).rstrip("/").endswith("api.minimax.io/v1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MiniMaxLLMClient tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMiniMaxLLMClient:
|
||||
"""Tests for MiniMaxLLMClient."""
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_init_defaults(self):
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient()
|
||||
assert client.model == "MiniMax-M2.5"
|
||||
assert client.temperature == 0.7
|
||||
assert client.max_tokens == 1024
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_init_custom_params(self):
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient(
|
||||
model="MiniMax-M2.5-highspeed",
|
||||
temperature=0.5,
|
||||
max_tokens=2048,
|
||||
)
|
||||
assert client.model == "MiniMax-M2.5-highspeed"
|
||||
assert client.temperature == 0.5
|
||||
assert client.max_tokens == 2048
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_temperature_clamped_to_min(self):
|
||||
"""MiniMax requires temperature > 0; verify clamping to 0.01."""
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient(temperature=0.0)
|
||||
assert client.temperature == 0.01
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_temperature_clamped_to_max(self):
|
||||
"""MiniMax requires temperature <= 1.0; verify clamping."""
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient(temperature=2.0)
|
||||
assert client.temperature == 1.0
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_init_missing_api_key(self):
|
||||
os.environ.pop("MINIMAX_API_KEY", None)
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
with pytest.raises(ValueError, match="MINIMAX_API_KEY"):
|
||||
MiniMaxLLMClient()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_chat_returns_text(self):
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "This is a test response."
|
||||
|
||||
client.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await client.chat("Hello, world!")
|
||||
assert result == "This is a test response."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_chat_strips_whitespace(self):
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = " response with spaces \n"
|
||||
|
||||
client.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await client.chat("prompt")
|
||||
assert result == "response with spaces"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_chat_api_error_raises(self):
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient()
|
||||
|
||||
client.client.chat.completions.create = AsyncMock(side_effect=Exception("API error"))
|
||||
|
||||
with pytest.raises(Exception, match="API error"):
|
||||
await client.chat("prompt")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
async def test_chat_passes_correct_params(self):
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient(model="MiniMax-M2.5-highspeed", temperature=0.3, max_tokens=512)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [MagicMock()]
|
||||
mock_response.choices[0].message.content = "ok"
|
||||
|
||||
client.client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
await client.chat("test prompt")
|
||||
|
||||
call_kwargs = client.client.chat.completions.create.call_args[1]
|
||||
assert call_kwargs["model"] == "MiniMax-M2.5-highspeed"
|
||||
assert call_kwargs["temperature"] == 0.3
|
||||
assert call_kwargs["max_tokens"] == 512
|
||||
assert call_kwargs["messages"] == [{"role": "user", "content": "test prompt"}]
|
||||
|
||||
@patch.dict(os.environ, {"MINIMAX_API_KEY": "test-key"})
|
||||
def test_base_url_is_minimax(self):
|
||||
from finogrid.fingpt_integration.minimax_llm_client import MiniMaxLLMClient
|
||||
client = MiniMaxLLMClient()
|
||||
assert str(client.client.base_url).rstrip("/").endswith("api.minimax.io/v1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory function tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetSentimentAnalyzerFactory:
|
||||
"""Tests for the get_sentiment_analyzer() factory."""
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"MINIMAX_API_KEY": "test-key",
|
||||
"FINGPT_LLM_PROVIDER": "minimax",
|
||||
"FINGPT_USE_OPENAI_FALLBACK": "true",
|
||||
})
|
||||
def test_factory_returns_minimax_provider(self):
|
||||
# Need to reload modules to pick up env changes
|
||||
import importlib
|
||||
import finogrid.fingpt_integration
|
||||
importlib.reload(finogrid.fingpt_integration)
|
||||
|
||||
from finogrid.fingpt_integration.sentiment.minimax_provider import MiniMaxSentimentProvider
|
||||
from finogrid.fingpt_integration.sentiment.openai_fallback import get_sentiment_analyzer
|
||||
analyzer = get_sentiment_analyzer()
|
||||
assert isinstance(analyzer, MiniMaxSentimentProvider)
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"OPENAI_API_KEY": "test-key",
|
||||
"FINGPT_LLM_PROVIDER": "openai",
|
||||
"FINGPT_USE_OPENAI_FALLBACK": "true",
|
||||
})
|
||||
def test_factory_returns_openai_provider(self):
|
||||
import importlib
|
||||
import finogrid.fingpt_integration
|
||||
importlib.reload(finogrid.fingpt_integration)
|
||||
|
||||
from finogrid.fingpt_integration.sentiment.openai_fallback import (
|
||||
get_sentiment_analyzer, OpenAISentimentFallback,
|
||||
)
|
||||
analyzer = get_sentiment_analyzer()
|
||||
assert isinstance(analyzer, OpenAISentimentFallback)
|
||||
Reference in New Issue
Block a user