diff --git a/README.md b/README.md index 018d432..5606d53 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,23 @@ uncommon-route provider add google AIza... uncommon-route serve ``` +MiniMax uses the global OpenAI-compatible endpoint by default. Pass the China +endpoint explicitly when needed: + +```bash +uncommon-route provider add minimax eyJ-... +uncommon-route provider add minimax eyJ-... --url https://api.minimaxi.com/v1 +``` + +| Region | OpenAI-compatible base URL | Anthropic-compatible base URL | +|---|---|---| +| Global | `https://api.minimax.io/v1` | `https://api.minimax.io/anthropic` | +| China | `https://api.minimaxi.com/v1` | `https://api.minimaxi.com/anthropic` | + +Provider configuration stores the OpenAI-compatible base URL. Anthropic +requests use the corresponding `/anthropic` base and append `/v1/messages` +internally. + > UncommonRoute doesn't automatically read `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. Use `init`, a saved connection, or one of the manual setup paths above. ### Routing Modes diff --git a/tests/test_anthropic_compat.py b/tests/test_anthropic_compat.py index a1d462d..519b1ee 100644 --- a/tests/test_anthropic_compat.py +++ b/tests/test_anthropic_compat.py @@ -1931,9 +1931,18 @@ def handler(request: httpx.Request) -> httpx.Response: finally: asyncio.run(async_client.aclose()) + @pytest.mark.parametrize( + ("base_url", "expected_url"), + [ + ("https://api.minimax.io/v1", "https://api.minimax.io/anthropic/v1/messages"), + ("https://api.minimaxi.com/v1", "https://api.minimaxi.com/anthropic/v1/messages"), + ], + ) def test_messages_use_minimax_anthropic_endpoint_for_direct_provider( self, monkeypatch: pytest.MonkeyPatch, + base_url: str, + expected_url: str, ) -> None: captured: dict[str, object] = {} @@ -1947,7 +1956,7 @@ def handler(request: httpx.Request) -> httpx.Response: "id": "msg_minimax_byok", "type": "message", "role": "assistant", - "model": "minimax/minimax-m2.1", + "model": "MiniMax-M3", "content": [{"type": "text", "text": "ok"}], "stop_reason": "end_turn", "stop_sequence": None, @@ -1964,32 +1973,44 @@ def handler(request: httpx.Request) -> httpx.Response: "minimax": ProviderEntry( name="minimax", api_key="mm-key-123", - base_url="https://api.minimax.io/v1", - models=["minimax/minimax-m2.1"], + base_url=base_url, + models=["minimax/minimax-m3"], ), }) app = create_app( - upstream="https://api.commonstack.ai/v1", + upstream="https://primary.example/v1", providers_config=providers, ) client = TestClient(app, raise_server_exceptions=False) resp = client.post("/v1/messages", json={ - "model": "minimax/minimax-m2.1", + "model": "minimax/minimax-m3", "max_tokens": 32, "messages": [{"role": "user", "content": "hello"}], }) assert resp.status_code == 200 - assert captured["url"] == "https://api.minimax.io/anthropic/v1/messages" + assert captured["url"] == expected_url headers = captured["headers"] assert isinstance(headers, dict) assert headers["x-api-key"] == "mm-key-123" + body = captured["body"] + assert isinstance(body, dict) + assert body["model"] == "MiniMax-M3" finally: asyncio.run(async_client.aclose()) - def test_chat_completions_keep_openai_transport_for_minimax_models( + @pytest.mark.parametrize( + ("base_url", "expected_url"), + [ + ("https://api.minimax.io/v1", "https://api.minimax.io/v1/chat/completions"), + ("https://api.minimaxi.com/v1", "https://api.minimaxi.com/v1/chat/completions"), + ], + ) + def test_chat_completions_use_minimax_openai_endpoint_for_direct_provider( self, monkeypatch: pytest.MonkeyPatch, + base_url: str, + expected_url: str, ) -> None: captured: dict[str, object] = {} @@ -2003,7 +2024,7 @@ def handler(request: httpx.Request) -> httpx.Response: "id": "chatcmpl_minimax", "object": "chat.completion", "created": 1, - "model": "minimax/minimax-m2.1", + "model": "MiniMax-M3", "choices": [{ "index": 0, "message": {"role": "assistant", "content": "done"}, @@ -2016,13 +2037,23 @@ def handler(request: httpx.Request) -> httpx.Response: async_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) monkeypatch.setattr("uncommon_route.proxy._get_client", lambda: async_client) - monkeypatch.setenv("UNCOMMON_ROUTE_API_KEY", "env-key-123") try: - app = create_app(upstream="https://api.commonstack.ai/v1") + providers = ProvidersConfig(providers={ + "minimax": ProviderEntry( + name="minimax", + api_key="mm-key-123", + base_url=base_url, + models=["minimax/minimax-m3"], + ), + }) + app = create_app( + upstream="https://primary.example/v1", + providers_config=providers, + ) client = TestClient(app, raise_server_exceptions=False) resp = client.post("/v1/chat/completions", json={ - "model": "minimax/minimax-m2.1", + "model": "minimax/minimax-m3", "max_tokens": 32, "messages": [{"role": "user", "content": "hello"}], }) @@ -2031,10 +2062,13 @@ def handler(request: httpx.Request) -> httpx.Response: assert resp.headers["x-uncommon-route-requested-transport"] == "openai-chat" assert resp.headers["x-uncommon-route-transport"] == "openai-chat" assert resp.headers["x-uncommon-route-transport-source"] == "ingress-policy" - assert captured["url"] == "https://api.commonstack.ai/v1/chat/completions" + assert captured["url"] == expected_url + headers = captured["headers"] + assert isinstance(headers, dict) + assert headers["authorization"] == "Bearer mm-key-123" body = captured["body"] assert isinstance(body, dict) - assert body["model"] == "minimax/minimax-m2.1" + assert body["model"] == "MiniMax-M3" finally: asyncio.run(async_client.aclose()) diff --git a/tests/test_providers.py b/tests/test_providers.py index 352f7e2..528a840 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -11,6 +11,7 @@ ProviderEntry, add_provider, load_providers, + resolve_upstream_model, remove_provider, select_preferred_model, ) @@ -41,12 +42,21 @@ def test_add_provider_with_plan(self) -> None: cfg = add_provider("minimax", "eyJ-test", plan="coding-plan") entry = cfg.providers["minimax"] assert entry.plan == "coding-plan" - assert "minimax/minimax-m2.5" in entry.models + assert entry.models == [ + "minimax/minimax-m2.5", + "minimax/minimax-m3", + "minimax/minimax-m2.7", + ] def test_add_provider_custom_url(self) -> None: cfg = add_provider("openai", "sk-openai", base_url="https://my-proxy.com/v1") assert cfg.providers["openai"].base_url == "https://my-proxy.com/v1" + def test_resolve_minimax_upstream_model_ids(self) -> None: + assert resolve_upstream_model("minimax", "minimax/minimax-m3") == "MiniMax-M3" + assert resolve_upstream_model("minimax", "minimax/minimax-m2.7") == "MiniMax-M2.7" + assert resolve_upstream_model("minimax", "minimax/unknown") == "minimax/unknown" + def test_add_provider_custom_models(self) -> None: cfg = add_provider( "custom", diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 08427f0..1b7043b 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -17,6 +17,7 @@ from uncommon_route.model_experience import InMemoryModelExperienceStorage, ModelExperienceStore from uncommon_route.providers import ProviderEntry, ProvidersConfig from uncommon_route.proxy import ( + UpstreamSemanticCompressor, _extract_current_message, _extract_prompt, _normalize_reasoning_content_chunk, @@ -77,6 +78,36 @@ def _build_test_mapper(*model_ids: str) -> ModelMapper: return mapper +class TestUpstreamSemanticCompressor: + def test_direct_minimax_provider_uses_upstream_model_id(self) -> None: + providers = ProvidersConfig(providers={ + "minimax": ProviderEntry( + name="minimax", + api_key="test-key", + base_url="https://api.minimax.io/v1", + models=["minimax/minimax-m3"], + ), + }) + compressor = UpstreamSemanticCompressor( + upstream_chat="https://primary.example/v1/chat/completions", + primary_api_key="primary-test-key", + providers_config=providers, + model_mapper=ModelMapper("https://primary.example/v1"), + composition_policy=CompositionPolicy(), + ) + + resolved = compressor._resolve_request( + "minimax/minimax-m3", + httpx.Request("POST", "https://local.example/v1/chat/completions"), + ) + + assert resolved is not None + target_url, headers, upstream_model = resolved + assert target_url == "https://api.minimax.io/v1/chat/completions" + assert headers["authorization"] == "Bearer test-key" + assert upstream_model == "MiniMax-M3" + + class TestPromptExtraction: def test_extract_prompt_ignores_claude_code_wrapper_blocks(self) -> None: prompt, system_prompt, max_tokens = _extract_prompt({ diff --git a/uncommon_route/providers.py b/uncommon_route/providers.py index 338c3de..8adfccd 100644 --- a/uncommon_route/providers.py +++ b/uncommon_route/providers.py @@ -12,7 +12,11 @@ "minimax": { "api_key": "eyJ...", "base_url": "https://api.minimax.io/v1", - "models": ["minimax/minimax-m2.5"], + "models": [ + "minimax/minimax-m2.5", + "minimax/minimax-m3", + "minimax/minimax-m2.7" + ], "plan": "coding-plan" }, "deepseek": { @@ -48,7 +52,7 @@ } PROVIDER_MODELS: dict[str, list[str]] = { - "minimax": ["minimax/minimax-m2.5"], + "minimax": ["minimax/minimax-m2.5", "minimax/minimax-m3", "minimax/minimax-m2.7"], "deepseek": ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"], "openai": ["openai/gpt-4o-mini", "openai/gpt-4o", "openai/gpt-5.2", "openai/gpt-5.2-codex", "openai/o1-mini", "openai/o3", "openai/o4-mini"], "anthropic": ["anthropic/claude-haiku-4.5", "anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.6"], @@ -57,6 +61,17 @@ "moonshot": ["moonshot/kimi-k2.5"], } +UPSTREAM_MODEL_IDS: dict[str, dict[str, str]] = { + "minimax": { + "minimax/minimax-m3": "MiniMax-M3", + "minimax/minimax-m2.7": "MiniMax-M2.7", + }, +} + + +def resolve_upstream_model(provider_name: str, model_id: str) -> str: + return UPSTREAM_MODEL_IDS.get(provider_name, {}).get(model_id, model_id) + @dataclass class ProviderEntry: diff --git a/uncommon_route/proxy.py b/uncommon_route/proxy.py index fc341aa..de6f775 100644 --- a/uncommon_route/proxy.py +++ b/uncommon_route/proxy.py @@ -106,6 +106,7 @@ ProvidersConfig, add_provider, load_providers, + resolve_upstream_model, remove_provider, verify_key, ) @@ -914,7 +915,7 @@ def _resolve_request(self, model_id: str, request: Request) -> tuple[str, dict[s provider_entry = self._providers.get_for_model(model_id) if provider_entry and provider_entry.base_url: target_chat_url = f"{provider_entry.base_url.rstrip('/')}/chat/completions" - upstream_model = model_id + upstream_model = resolve_upstream_model(provider_entry.name, model_id) elif self._upstream_chat: target_chat_url = self._upstream_chat upstream_model = self._mapper.resolve(model_id) @@ -4635,7 +4636,9 @@ def _prepare_attempt(model_name: str) -> dict[str, Any]: attempt_headers[_ORIGINAL_MODEL_HEADER] = requested_model resolved_model = model_name - if not attempt_provider_entry: + if attempt_provider_entry: + resolved_model = resolve_upstream_model(attempt_provider_entry.name, model_name) + else: resolved_model = _mapper.resolve(model_name) attempt_upstream_body["model"] = resolved_model