Skip to content

Commit b0fd8b8

Browse files
Merge pull request #5896 from VasuBansal7576/codex/pr-minimax-single
fix: add minimax provider mapping and stream fallback
2 parents f36add8 + 988a58c commit b0fd8b8

File tree

7 files changed

+257
-34
lines changed

7 files changed

+257
-34
lines changed

core/framework/credentials/aden/client.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,14 @@
3030

3131
from __future__ import annotations
3232

33+
import json as _json
3334
import logging
3435
import os
3536
import time
3637
from dataclasses import dataclass, field
3738
from datetime import datetime
3839
from typing import Any
3940

40-
import json as _json
41-
4241
import httpx
4342

4443
logger = logging.getLogger(__name__)

core/framework/llm/litellm.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def _sync_wrapper(*args, _orig=original, **kwargs):
117117
RATE_LIMIT_MAX_RETRIES = 10
118118
RATE_LIMIT_BACKOFF_BASE = 2 # seconds
119119
RATE_LIMIT_MAX_DELAY = 120 # seconds - cap to prevent absurd waits
120+
MINIMAX_API_BASE = "https://api.minimax.io/v1"
120121

121122
# Empty-stream retries use a short fixed delay, not the rate-limit backoff.
122123
# Conversation-structure issues are deterministic — long waits don't help.
@@ -324,11 +325,13 @@ def __init__(
324325
"""
325326
self.model = model
326327
self.api_key = api_key
327-
self.api_base = api_base
328+
self.api_base = api_base or self._default_api_base_for_model(model)
328329
self.extra_kwargs = kwargs
329330
# The Codex ChatGPT backend (chatgpt.com/backend-api/codex) rejects
330331
# several standard OpenAI params: max_output_tokens, stream_options.
331-
self._codex_backend = bool(api_base and "chatgpt.com/backend-api/codex" in api_base)
332+
self._codex_backend = bool(
333+
self.api_base and "chatgpt.com/backend-api/codex" in self.api_base
334+
)
332335

333336
if litellm is None:
334337
raise ImportError(
@@ -341,6 +344,14 @@ def __init__(
341344
# override the mode. The responses_api_bridge in litellm handles
342345
# converting Chat Completions requests to Responses API format.
343346

347+
@staticmethod
348+
def _default_api_base_for_model(model: str) -> str | None:
349+
"""Return provider-specific default API base when required."""
350+
model_lower = model.lower()
351+
if model_lower.startswith("minimax/") or model_lower.startswith("minimax-"):
352+
return MINIMAX_API_BASE
353+
return None
354+
344355
def _completion_with_rate_limit_retry(
345356
self, max_retries: int | None = None, **kwargs: Any
346357
) -> Any:
@@ -735,6 +746,77 @@ def _tool_to_openai_format(self, tool: Tool) -> dict[str, Any]:
735746
},
736747
}
737748

749+
def _is_minimax_model(self) -> bool:
750+
"""Return True when the configured model targets MiniMax."""
751+
model = (self.model or "").lower()
752+
return model.startswith("minimax/") or model.startswith("minimax-")
753+
754+
async def _stream_via_nonstream_completion(
755+
self,
756+
messages: list[dict[str, Any]],
757+
system: str,
758+
tools: list[Tool] | None,
759+
max_tokens: int,
760+
response_format: dict[str, Any] | None,
761+
json_mode: bool,
762+
) -> AsyncIterator[StreamEvent]:
763+
"""Fallback path: convert non-stream completion to stream events.
764+
765+
Some providers currently fail in LiteLLM's chunk parser for stream=True.
766+
For those providers we do a regular async completion and emit equivalent
767+
stream events so higher layers continue to work.
768+
"""
769+
from framework.llm.stream_events import (
770+
FinishEvent,
771+
StreamErrorEvent,
772+
TextDeltaEvent,
773+
TextEndEvent,
774+
ToolCallEvent,
775+
)
776+
777+
try:
778+
response = await self.acomplete(
779+
messages=messages,
780+
system=system,
781+
tools=tools,
782+
max_tokens=max_tokens,
783+
response_format=response_format,
784+
json_mode=json_mode,
785+
)
786+
except Exception as e:
787+
yield StreamErrorEvent(error=str(e), recoverable=False)
788+
return
789+
790+
raw = response.raw_response
791+
tool_calls = []
792+
if raw and hasattr(raw, "choices") and raw.choices:
793+
msg = raw.choices[0].message
794+
tool_calls = msg.tool_calls or []
795+
796+
for tc in tool_calls:
797+
parsed_args: Any
798+
args = tc.function.arguments if tc.function else ""
799+
try:
800+
parsed_args = json.loads(args) if args else {}
801+
except json.JSONDecodeError:
802+
parsed_args = {"_raw": args}
803+
yield ToolCallEvent(
804+
tool_use_id=getattr(tc, "id", ""),
805+
tool_name=tc.function.name if tc.function else "",
806+
tool_input=parsed_args,
807+
)
808+
809+
if response.content:
810+
yield TextDeltaEvent(content=response.content, snapshot=response.content)
811+
yield TextEndEvent(full_text=response.content)
812+
813+
yield FinishEvent(
814+
stop_reason=response.stop_reason or "stop",
815+
input_tokens=response.input_tokens,
816+
output_tokens=response.output_tokens,
817+
model=response.model,
818+
)
819+
738820
async def stream(
739821
self,
740822
messages: list[dict[str, Any]],
@@ -762,6 +844,20 @@ async def stream(
762844
ToolCallEvent,
763845
)
764846

847+
# MiniMax currently fails in litellm's stream chunk parser for some
848+
# responses (missing "id" in stream chunks). Use non-stream fallback.
849+
if self._is_minimax_model():
850+
async for event in self._stream_via_nonstream_completion(
851+
messages=messages,
852+
system=system,
853+
tools=tools,
854+
max_tokens=max_tokens,
855+
response_format=response_format,
856+
json_mode=json_mode,
857+
):
858+
yield event
859+
return
860+
765861
full_messages: list[dict[str, Any]] = []
766862
if system:
767863
full_messages.append({"role": "system", "content": system})

core/framework/runner/runner.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -959,11 +959,16 @@ def load(
959959
if not agent_json_path.is_file():
960960
raise FileNotFoundError(f"No agent.py or agent.json found in {agent_path}")
961961

962-
content = agent_json_path.read_text(encoding="utf-8").strip()
963-
if not content:
964-
raise FileNotFoundError(f"agent.json is empty: {agent_json_path}")
962+
with open(agent_json_path, encoding="utf-8") as f:
963+
export_data = f.read()
965964

966-
graph, goal = load_agent_export(content)
965+
if not export_data.strip():
966+
raise ValueError(f"Empty agent export file: {agent_json_path}")
967+
968+
try:
969+
graph, goal = load_agent_export(export_data)
970+
except json.JSONDecodeError as exc:
971+
raise ValueError(f"Invalid JSON in agent export file: {agent_json_path}") from exc
967972

968973
return cls(
969974
agent_path=agent_path,
@@ -1307,6 +1312,8 @@ def _get_api_key_env_var(self, model: str) -> str | None:
13071312
return "REPLICATE_API_KEY"
13081313
elif model_lower.startswith("together/"):
13091314
return "TOGETHER_API_KEY"
1315+
elif model_lower.startswith("minimax/") or model_lower.startswith("minimax-"):
1316+
return "MINIMAX_API_KEY"
13101317
else:
13111318
# Default: assume OpenAI-compatible
13121319
return "OPENAI_API_KEY"
@@ -1325,6 +1332,8 @@ def _get_api_key_from_credential_store(self) -> str | None:
13251332
cred_id = None
13261333
if model_lower.startswith("anthropic/") or model_lower.startswith("claude"):
13271334
cred_id = "anthropic"
1335+
elif model_lower.startswith("minimax/") or model_lower.startswith("minimax-"):
1336+
cred_id = "minimax"
13281337
# Add more mappings as providers are added to LLM_CREDENTIALS
13291338

13301339
if cred_id is None:

core/tests/test_litellm_provider.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import threading
1515
import time
1616
from datetime import UTC, datetime, timedelta
17-
from unittest.mock import MagicMock, patch
17+
from unittest.mock import AsyncMock, MagicMock, patch
1818

1919
import pytest
2020

@@ -58,6 +58,20 @@ def test_init_with_api_base(self):
5858
)
5959
assert provider.api_base == "https://my-proxy.com/v1"
6060

61+
def test_init_minimax_defaults_api_base(self):
62+
"""MiniMax should default to the official OpenAI-compatible endpoint."""
63+
provider = LiteLLMProvider(model="minimax/MiniMax-M2.1", api_key="my-key")
64+
assert provider.api_base == "https://api.minimax.io/v1"
65+
66+
def test_init_minimax_keeps_custom_api_base(self):
67+
"""Explicit api_base should win over MiniMax defaults."""
68+
provider = LiteLLMProvider(
69+
model="minimax/MiniMax-M2.1",
70+
api_key="my-key",
71+
api_base="https://proxy.example/v1",
72+
)
73+
assert provider.api_base == "https://proxy.example/v1"
74+
6175
def test_init_ollama_no_key_needed(self):
6276
"""Test that Ollama models don't require API key."""
6377
with patch.dict(os.environ, {}, clear=True):
@@ -631,6 +645,43 @@ def complete(
631645
)
632646

633647

648+
class TestMiniMaxStreamFallback:
649+
"""MiniMax models should use non-stream fallback due to parser incompatibility."""
650+
651+
@pytest.mark.asyncio
652+
async def test_stream_uses_nonstream_fallback_for_minimax(self):
653+
"""stream() should call acomplete() and synthesize stream events for MiniMax."""
654+
from framework.llm.stream_events import FinishEvent, TextDeltaEvent
655+
656+
provider = LiteLLMProvider(model="minimax-text-01", api_key="test-key")
657+
658+
mock_response = LLMResponse(
659+
content="hello from minimax",
660+
model="minimax-text-01",
661+
input_tokens=7,
662+
output_tokens=4,
663+
stop_reason="stop",
664+
raw_response=None,
665+
)
666+
provider.acomplete = AsyncMock(return_value=mock_response)
667+
668+
events = []
669+
async for event in provider.stream(messages=[{"role": "user", "content": "hi"}]):
670+
events.append(event)
671+
672+
assert provider.acomplete.await_count == 1
673+
assert any(isinstance(e, TextDeltaEvent) for e in events)
674+
finish = [e for e in events if isinstance(e, FinishEvent)]
675+
assert len(finish) == 1
676+
assert finish[0].model == "minimax-text-01"
677+
678+
def test_is_minimax_model_variants(self):
679+
"""Recognize both prefixed and plain MiniMax model names."""
680+
assert LiteLLMProvider(model="minimax-text-01", api_key="x")._is_minimax_model()
681+
assert LiteLLMProvider(model="minimax/minimax-text-01", api_key="x")._is_minimax_model()
682+
assert not LiteLLMProvider(model="gpt-4o-mini", api_key="x")._is_minimax_model()
683+
684+
634685
# ---------------------------------------------------------------------------
635686
# AgentRunner._is_local_model — parameterized tests
636687
# ---------------------------------------------------------------------------
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from framework.runner.runner import AgentRunner
2+
3+
4+
class _NoopRegistry:
5+
def cleanup(self) -> None:
6+
pass
7+
8+
9+
def _runner_for_unit_test() -> AgentRunner:
10+
runner = AgentRunner.__new__(AgentRunner)
11+
runner._tool_registry = _NoopRegistry()
12+
runner._temp_dir = None
13+
return runner
14+
15+
16+
def test_minimax_provider_prefix_maps_to_minimax_api_key():
17+
runner = _runner_for_unit_test()
18+
assert runner._get_api_key_env_var("minimax/minimax-text-01") == "MINIMAX_API_KEY"
19+
20+
21+
def test_minimax_model_name_prefix_maps_to_minimax_api_key():
22+
runner = _runner_for_unit_test()
23+
assert runner._get_api_key_env_var("minimax-chat") == "MINIMAX_API_KEY"

0 commit comments

Comments
 (0)