diff --git a/README.md b/README.md index 018d432..56f9076 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,53 @@ 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. + +New MiniMax connections offer **MiniMax-M3.1-Flash**, then **MiniMax-M3**. +Saved custom and legacy model lists remain usable; older MiniMax text models +are omitted from ordinary discovery suggestions. Image, speech, and video +model entries are unaffected. + +Direct Flash requests use adaptive thinking with `max` effort by default; +`low`, `medium`, `high`, `xhigh`, and `max` are accepted. The proxy removes +unsupported `service_tier` and `reasoning_split=false` controls. Native +Anthropic requests carry effort in `output_config.effort`; OpenAI-compatible +requests use `reasoning_effort`. M3 supports adaptive/disabled thinking without +an effort control. Tools, reasoning history, and image/video content are kept. + +Direct Flash advertises a 1,000,000-token context and 524,288-token maximum +output. Flash costs $0.15 input / $0.015 cached input / $0.60 output per million +tokens. M3 standard bands are $0.30/$0.06/$1.20 and $0.60/$0.12/$2.40; priority +bands are $0.45/$0.09/$1.80 and $0.90/$0.18/$3.60 (input/cache/output). +Until the M3 input-band boundary is verified, local estimates conservatively +use the upper band. Flash has no priority tier or Coding Plan promise. + +Gateway catalogs remain authoritative for IDs, rates, context, and modalities. +For example, OpenRouter currently publishes `minimax/minimax-m3.1-flash:free`, +with zero catalog pricing and a 1,000,000-token context; requests use its +`reasoning.effort` field and preserve `reasoning_details`. Commonstack models +are taken from its live `/models` discovery; no unverified Flash slug is added. +These gateway values are never replaced with direct MiniMax prices. + +Sources: [MiniMax OpenAI API](https://platform.minimax.io/docs/api-reference/text-openai-api.md), +[MiniMax pricing](https://platform.minimax.io/docs/guides/pricing-paygo.md), +[OpenRouter model catalog](https://openrouter.ai/api/v1/models). + > 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..4f1b4dc 100644 --- a/tests/test_anthropic_compat.py +++ b/tests/test_anthropic_compat.py @@ -1181,10 +1181,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert resp.status_code == 200 - assert set(routed["available_models"]) == { - "deepseek/deepseek-v3.2", - "minimax/minimax-m2.1", - } + assert set(routed["available_models"]) == {"deepseek/deepseek-v3.2"} assert routed["capability_lane"] == CapabilityLane.GENERAL assert resp.headers["x-uncommon-route-requested-transport"] == "anthropic-messages" assert resp.headers["x-uncommon-route-transport"] == "openai-chat" @@ -1675,10 +1672,7 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert resp.status_code == 200 - assert set(routed["available_models"]) == { - "minimax/minimax-m2.7", - "deepseek/deepseek-v3.2", - } + assert set(routed["available_models"]) == {"deepseek/deepseek-v3.2"} assert captured["url"] == "https://api.commonstack.ai/v1/chat/completions" body = captured["body"] assert isinstance(body, dict) @@ -1779,8 +1773,6 @@ def handler(request: httpx.Request) -> httpx.Response: "deepseek/deepseek-v3.2", "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6", - "minimax/minimax-m2.1", - "minimax/minimax-m2.7", } assert captured["url"] == "https://api.commonstack.ai/v1/chat/completions" body = captured["body"] @@ -1931,9 +1923,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 +1948,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 +1965,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 +2016,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 +2029,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 +2054,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_cache_support.py b/tests/test_cache_support.py index adbe483..072921c 100644 --- a/tests/test_cache_support.py +++ b/tests/test_cache_support.py @@ -2,8 +2,15 @@ import json -from uncommon_route.cache_support import apply_anthropic_cache_breakpoints, parse_stream_usage_metrics -from uncommon_route.router.types import ModelPricing +import pytest + +from uncommon_route.cache_support import ( + apply_anthropic_cache_breakpoints, + estimate_usage_cost, + parse_stream_usage_metrics, + parse_usage_metrics, +) +from uncommon_route.router.types import ModelPricing, ModelPricingTier def test_anthropic_cache_breakpoints_do_not_upgrade_after_existing_5m() -> None: @@ -88,3 +95,74 @@ def test_stream_usage_falls_back_to_reasoning_content_tokens_when_usage_missing( assert usage.output_tokens > 0 assert usage.total_tokens == usage.output_tokens assert usage.actual_cost is not None + + +def test_usage_cost_applies_long_context_pricing_tier() -> None: + pricing = ModelPricing( + 0.30, + 1.20, + pricing_tiers=( + ModelPricingTier("standard", 0.30, 1.20, input_tokens_lte=512_000), + ModelPricingTier("standard", 0.60, 2.40, input_tokens_gt=512_000), + ), + ) + + cost = estimate_usage_cost( + input_tokens_uncached=600_000, + output_tokens=100_000, + cache_read_input_tokens=0, + cache_write_input_tokens=0, + pricing=pricing, + ) + + assert cost == 0.60 + + +def test_usage_cost_applies_priority_pricing_tier() -> None: + pricing = ModelPricing( + 0.30, + 1.20, + pricing_tiers=( + ModelPricingTier("standard", 0.60, 2.40, input_tokens_gt=512_000), + ModelPricingTier("priority", 0.90, 3.60, input_tokens_gt=512_000), + ), + ) + + cost = estimate_usage_cost( + input_tokens_uncached=600_000, + output_tokens=100_000, + cache_read_input_tokens=0, + cache_write_input_tokens=0, + pricing=pricing, + service_tier="priority", + ) + + assert cost == pytest.approx(0.90) + + +def test_usage_metrics_prefers_response_service_tier() -> None: + pricing = ModelPricing( + 0.30, + 1.20, + pricing_tiers=( + ModelPricingTier("standard", 0.60, 2.40, input_tokens_gt=512_000), + ModelPricingTier("priority", 0.90, 3.60, input_tokens_gt=512_000), + ), + ) + content = json.dumps({ + "service_tier": "priority", + "usage": { + "prompt_tokens": 600_000, + "completion_tokens": 100_000, + "total_tokens": 700_000, + }, + }).encode() + + usage = parse_usage_metrics( + content, + "test/model", + {"test/model": pricing}, + ) + + assert usage is not None + assert usage.actual_cost == pytest.approx(0.90) diff --git a/tests/test_minimax_rollout.py b/tests/test_minimax_rollout.py new file mode 100644 index 0000000..a8fcc1e --- /dev/null +++ b/tests/test_minimax_rollout.py @@ -0,0 +1,223 @@ +"""Exercise MiniMax selection and serialized HTTP requests without inference.""" + +import asyncio +import json + +import httpx +import pytest +from starlette.testclient import TestClient + +from uncommon_route.model_map import DiscoveredModel, ModelMapper, ordinary_model_choices +from uncommon_route.providers import ProviderEntry, ProvidersConfig, load_providers, save_providers +from uncommon_route.proxy import create_app +from uncommon_route.router.types import ModelCapabilities, ModelPricing + + +FLASH = "minimax/minimax-m3.1-flash" + + +def test_defaults_and_saved_models(tmp_path): + path = tmp_path / "providers.json" + path.write_text(json.dumps({"providers": {"minimax": {"api_key": "fixture"}}})) + assert load_providers(path).providers["minimax"].models == [FLASH, "minimax/minimax-m3"] + saved = ["minimax/minimax-m2.5", "minimax/custom-model"] + config = ProvidersConfig({"minimax": ProviderEntry("minimax", "fixture", "https://custom.test/v1", saved)}) + save_providers(config, path) + assert load_providers(path).providers["minimax"].models == saved + + +def test_ordinary_pool_preserves_explicit_legacy_and_media(): + models = ["minimax/minimax-m2.5", "minimax/minimax-m3", FLASH + ":free", "vendor/model", "minimax/image-01"] + mapper = ModelMapper("https://openrouter.ai/api/v1") + mapper._discovered = True + mapper._upstream_models = set(models) + for model in models: + mapper._pool[model] = DiscoveredModel(model, "minimax", "minimax", ModelPricing(0, 0), ModelCapabilities()) + assert ordinary_model_choices(models) == [FLASH + ":free", "minimax/minimax-m3", "vendor/model", "minimax/image-01"] + assert mapper.routable_models == [FLASH + ":free", "minimax/minimax-m3", "vendor/model"] + assert mapper.resolve(models[0]) == models[0] + assert models[0] not in [row["id"] for row in mapper.pool_table()] + assert "minimax/image-01" in [row["id"] for row in mapper.pool_table()] + + +@pytest.mark.parametrize("host", ["api.minimax.io", "api.minimaxi.com"]) +@pytest.mark.parametrize("effort", [None, "low", "medium", "high", "xhigh", "max", "none"]) +def test_flash_chat_http_boundary(monkeypatch, host, effort): + captured = [] + + def handler(request): + captured.append((str(request.url), json.loads(request.content))) + return httpx.Response(200, json={"id": "fixture", "object": "chat.completion", "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "ok", "reasoning_content": "reason"}, "finish_reason": "stop"} + ]}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr("uncommon_route.proxy._get_client", lambda: client) + providers = ProvidersConfig({"minimax": ProviderEntry("minimax", "fixture", f"https://{host}/v1", [FLASH])}) + app = create_app(upstream="https://unused.test/v1", providers_config=providers) + messages = [ + {"role": "user", "content": [{"type": "text", "text": "Inspect"}, + {"type": "image_url", "image_url": {"url": "https://example.test/image.png"}}, + {"type": "video_url", "video_url": {"url": "mm_file://fixture"}}]}, + {"role": "assistant", "content": "keep", "reasoning_content": "keep reasoning", + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "inspect", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call_1", "content": "done"}, + ] + tools = [{"type": "function", "function": {"name": "inspect", "parameters": {"type": "object", "properties": {}}}}] + body = {"model": FLASH, "messages": messages, "tools": tools, "thinking": {"type": "disabled"}, + "service_tier": "priority", "reasoning_split": False} + if effort is not None: + body["reasoning_effort"] = effort + try: + response = TestClient(app).post("/v1/chat/completions", json=body) + assert response.status_code == 200 + url, sent = captured[-1] + assert url == f"https://{host}/v1/chat/completions" + assert sent["model"] == "MiniMax-M3.1-Flash" + assert sent["thinking"] == {"type": "adaptive"} + assert sent["reasoning_effort"] == (effort if effort not in (None, "none") else "max") + assert "service_tier" not in sent and "reasoning_split" not in sent + assert sent["messages"] == messages + assert sent["tools"] == tools + finally: + asyncio.run(client.aclose()) + + +@pytest.mark.parametrize("model,base,expected", [ + (FLASH, "https://api.minimax.io/v1", "MiniMax-M3.1-Flash"), + ("minimax/minimax-m3", "https://api.minimax.io/v1", "MiniMax-M3"), + ("minimax/minimax-m2.7", "https://api.minimax.io/v1", "MiniMax-M2.7"), + (FLASH, "https://custom.test/v1", FLASH), + (FLASH, "https://api.minimax.io.example.test/v1", FLASH), +]) +def test_anthropic_http_history_and_scope(monkeypatch, model, base, expected): + captured = [] + + def handler(request): + captured.append(json.loads(request.content)) + return httpx.Response(200, json={"id": "fixture", "type": "message", "role": "assistant", "model": expected, + "content": [{"type": "text", "text": "ok"}], "stop_reason": "end_turn", "usage": {"input_tokens": 1, "output_tokens": 1}}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr("uncommon_route.proxy._get_client", lambda: client) + providers = ProvidersConfig({"minimax": ProviderEntry("minimax", "fixture", base, [model])}) + app = create_app(upstream="https://unused.test/v1", providers_config=providers) + messages = [ + {"role": "user", "content": [{"type": "text", "text": "Inspect"}, {"type": "image", "source": {"type": "url", "url": "https://example.test/a.png"}}]}, + {"role": "assistant", "content": [{"type": "thinking", "thinking": "keep", "signature": "signed"}, + {"type": "tool_use", "id": "call_1", "name": "inspect", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "call_1", "content": "done"}]}, + ] + body = {"model": model, "messages": messages, "max_tokens": 1000, "thinking": {"type": "disabled"}, + "output_config": {"effort": "low"}, "service_tier": "priority", + "tools": [{"name": "inspect", "input_schema": {"type": "object", "properties": {}}}]} + try: + response = TestClient(app).post("/v1/messages", json=body) + assert response.status_code == 200 + sent = captured[-1] + assert sent["model"] == expected + if model == FLASH and base == "https://api.minimax.io/v1": + assert sent["thinking"] == {"type": "adaptive"} + assert sent["output_config"]["effort"] == "low" + assert "service_tier" not in sent + elif model == "minimax/minimax-m3": + assert sent["thinking"] == {"type": "disabled"} + assert "effort" not in sent.get("output_config", {}) + assert sent["service_tier"] == "priority" + # Native transport preserves signed reasoning and all tool/image blocks. + if base == "https://api.minimax.io/v1": + for actual, original in zip(sent["messages"], messages): + for actual_block, original_block in zip(actual["content"], original["content"]): + assert {key: actual_block[key] for key in original_block} == original_block + finally: + asyncio.run(client.aclose()) + + +@pytest.mark.parametrize("stream", [False, True]) +def test_openrouter_exact_id_and_reasoning_http(monkeypatch, stream): + captured = [] + delta = {"role": "assistant", "content": "ok", "reasoning_details": [{"type": "reasoning.text", "text": "kept"}]} + + def handler(request): + captured.append(json.loads(request.content)) + if stream: + chunk = {"id": "fixture", "choices": [{"index": 0, "delta": delta}]} + return httpx.Response(200, content=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n", headers={"content-type": "text/event-stream"}) + return httpx.Response(200, json={"id": "fixture", "choices": [{"index": 0, "message": delta, "finish_reason": "stop"}]}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr("uncommon_route.proxy._get_client", lambda: client) + app = create_app(upstream="https://openrouter.ai/api/v1", providers_config=ProvidersConfig()) + message = {"role": "assistant", "content": "previous", "reasoning_details": delta["reasoning_details"]} + try: + response = TestClient(app).post("/v1/chat/completions", json={ + "model": FLASH + ":free", "messages": [message, {"role": "user", "content": "next"}], "stream": stream, + "reasoning": {"effort": "none", "enabled": False, "exclude": True}, "service_tier": "priority", + }) + assert response.status_code == 200 + sent = captured[-1] + assert sent["model"] == FLASH + ":free" + assert sent["reasoning"] == {"effort": "max", "exclude": False} + assert "thinking" not in sent and "service_tier" not in sent + assert sent["messages"][0] == message + assert "reasoning_details" in response.text + finally: + asyncio.run(client.aclose()) + + +def test_discovery_uses_gateway_metadata(monkeypatch): + model_id = FLASH + ":free" + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"data": [{ + "id": model_id, "context_length": 1_048_576, + "architecture": {"input_modalities": ["text", "image", "video"]}, + "pricing": {"prompt": "0", "completion": "0"}, + }]}))) + monkeypatch.setattr("uncommon_route.model_map.httpx.AsyncClient", lambda **kwargs: client) + mapper = ModelMapper("https://openrouter.ai/api/v1") + asyncio.run(mapper.discover()) + caps = mapper.dynamic_capabilities[model_id] + assert caps.context_window == 1_048_576 + assert caps.input_modalities == ("text", "image", "video") + assert caps.vision is True + assert mapper.dynamic_pricing[model_id].input_price == 0 + assert mapper.dynamic_pricing[model_id].output_price == 0 + + +@pytest.mark.parametrize("stream", [False, True]) +def test_responses_adapter_preserves_images_tools_and_history(monkeypatch, stream): + captured = [] + message = {"role": "assistant", "content": "done", "reasoning_content": "kept reasoning"} + + def handler(request): + captured.append(json.loads(request.content)) + if captured[-1].get("stream"): + chunk = {"id": "fixture", "model": "MiniMax-M3.1-Flash", "choices": [{"index": 0, "delta": message}]} + return httpx.Response(200, content=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n", headers={"content-type": "text/event-stream"}) + return httpx.Response(200, json={"id": "fixture", "model": "MiniMax-M3.1-Flash", "choices": [{"index": 0, "message": message, "finish_reason": "stop"}]}) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr("uncommon_route.proxy._get_client", lambda: client) + providers = ProvidersConfig({"minimax": ProviderEntry("minimax", "fixture", "https://api.minimax.io/v1", [FLASH])}) + local = TestClient(create_app(upstream="https://unused.test/v1", providers_config=providers)) + try: + response = local.post("/v1/responses", json={"model": FLASH, "stream": stream, "reasoning": {"effort": "low"}, + "input": [{"role": "user", "content": [{"type": "input_text", "text": "inspect"}, + {"type": "input_image", "image_url": "https://example.test/a.png"}]}], + "tools": [{"type": "function", "name": "inspect", "parameters": {"type": "object", "properties": {}}}], + }) + assert response.status_code == 200 + sent = captured[-1] + assert sent["messages"][0]["content"][1]["image_url"]["url"] == "https://example.test/a.png" + assert sent["reasoning_effort"] == "low" + assert sent["tools"][0]["function"]["name"] == "inspect" + if stream: + completed = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: {")] + response_id = next(event["response"]["id"] for event in completed if event.get("type") == "response.completed") + else: + response_id = response.json()["id"] + followup = local.post("/v1/responses", json={"model": FLASH, "previous_response_id": response_id, "input": "continue"}) + assert followup.status_code == 200 + assert captured[-1]["messages"][-2]["reasoning_content"] == "kept reasoning" + assert "previous_response_id" not in captured[-1] + finally: + asyncio.run(client.aclose()) diff --git a/tests/test_providers.py b/tests/test_providers.py index 352f7e2..cbf0d26 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -11,9 +11,12 @@ ProviderEntry, add_provider, load_providers, + resolve_upstream_model, remove_provider, select_preferred_model, ) +from uncommon_route.model_map import detect_provider +from uncommon_route.router.config import PROVIDER_MODEL_CAPABILITIES, PROVIDER_MODEL_PRICING from uncommon_route.router.types import RoutingFeatures, Tier @@ -41,12 +44,59 @@ 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-m3.1-flash", + "minimax/minimax-m3", + ] 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" + + @pytest.mark.parametrize( + "base_url", + [ + "https://api.minimax.io/v1", + "https://api.minimaxi.com/v1", + ], + ) + def test_detect_minimax_regional_endpoints(self, base_url: str) -> None: + assert detect_provider(base_url) == ("minimax", False) + + def test_minimax_provider_model_metadata(self) -> None: + m3_pricing = PROVIDER_MODEL_PRICING["minimax/minimax-m3"] + assert m3_pricing.input_price == 0.60 + assert m3_pricing.output_price == 2.40 + assert m3_pricing.cached_input_price == 0.12 + assert m3_pricing.for_usage(1000, "priority").output_price == 3.60 + flash = PROVIDER_MODEL_PRICING["minimax/minimax-m3.1-flash"] + assert (flash.input_price, flash.output_price, flash.cached_input_price) == (0.15, 0.60, 0.015) + assert PROVIDER_MODEL_CAPABILITIES["minimax/minimax-m3.1-flash"].max_output_tokens == 524_288 + assert m3_pricing.cache_write_price is None + + m27_pricing = PROVIDER_MODEL_PRICING["minimax/minimax-m2.7"] + assert m27_pricing.cached_input_price == 0.06 + assert m27_pricing.cache_write_price == 0.375 + + m3_capabilities = PROVIDER_MODEL_CAPABILITIES["minimax/minimax-m3"] + assert m3_capabilities.context_window == 1_000_000 + assert m3_capabilities.input_modalities == ("text", "image", "video") + assert m3_capabilities.thinking_modes == ("adaptive", "disabled") + assert m3_capabilities.vision is True + assert m3_capabilities.reasoning is True + + m27_capabilities = PROVIDER_MODEL_CAPABILITIES["minimax/minimax-m2.7"] + assert m27_capabilities.context_window == 204_800 + assert m27_capabilities.input_modalities == ("text",) + assert m27_capabilities.thinking_modes == ("always_on",) + assert m27_capabilities.vision is False + assert m27_capabilities.reasoning is True + def test_add_provider_custom_models(self) -> None: cfg = add_provider( "custom", @@ -81,7 +131,9 @@ def test_keyed_models(self) -> None: keyed = cfg.keyed_models() assert "deepseek/deepseek-chat" in keyed assert "deepseek/deepseek-reasoner" in keyed - assert "minimax/minimax-m2.5" in keyed + assert "minimax/minimax-m3" in keyed + assert "minimax/minimax-m3.1-flash" in keyed + assert "minimax/minimax-m2.7" not in keyed def test_get_for_model(self) -> None: add_provider("deepseek", "sk-1") diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 08427f0..a3e7d5b 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,140 @@ 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 TestProviderModelMetadata: + def test_minimax_byok_metadata_is_available_to_virtual_routing( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + routed: dict[str, object] = {} + estimated_tiers: list[str] = [] + + def fake_estimate_cost( + _model: str, + _input_tokens: int, + _output_tokens: int, + service_tier: str = "standard", + ) -> float: + estimated_tiers.append(service_tier) + return 0.001 + + def fake_route(*_args, **kwargs) -> RoutingDecision: + routed["pricing"] = kwargs["pricing"] + routed["capabilities"] = kwargs["model_capabilities"] + return RoutingDecision( + model="minimax/minimax-m3", + tier=Tier.COMPLEX, + capability_lane=CapabilityLane.VISION, + served_quality=ServedQuality.PREMIUM, + served_quality_target=ServedQuality.PREMIUM, + served_quality_floor=ServedQuality.PREMIUM, + continuity_quality_floor=kwargs["routing_features"].continuity_quality_floor, + mode=RoutingMode.AUTO, + confidence=0.9, + method="provider metadata test", + reasoning="provider metadata test", + cost_estimate=0.001, + baseline_cost=0.002, + savings=0.5, + routing_features=kwargs["routing_features"], + ) + + captured: dict[str, object] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content.decode("utf-8")) + return httpx.Response( + 200, + json={ + "id": "chatcmpl_minimax_metadata", + "object": "chat.completion", + "created": 1, + "model": "MiniMax-M3", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + headers={"content-type": "application/json"}, + ) + + providers = ProvidersConfig(providers={ + "minimax": ProviderEntry( + name="minimax", + api_key="test-key", + base_url="https://api.minimax.io/v1", + models=["minimax/minimax-m3", "minimax/minimax-m2.7"], + ), + }) + async_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + monkeypatch.setattr("uncommon_route.proxy._get_client", lambda: async_client) + monkeypatch.setattr("uncommon_route.proxy._estimate_cost", fake_estimate_cost) + monkeypatch.setattr("uncommon_route.proxy.route", fake_route) + + try: + app = create_app( + upstream="https://primary.example/v1", + providers_config=providers, + spend_control=SpendControl(storage=InMemorySpendControlStorage()), + ) + client = TestClient(app, raise_server_exceptions=False) + resp = client.post("/v1/chat/completions", json={ + "model": "uncommon-route/auto", + "service_tier": "priority", + "messages": [{"role": "user", "content": "describe this image"}], + }) + + assert resp.status_code == 200 + pricing = routed["pricing"] + capabilities = routed["capabilities"] + assert isinstance(pricing, dict) + assert isinstance(capabilities, dict) + assert pricing["minimax/minimax-m3"].input_price == 0.60 + assert pricing["minimax/minimax-m3"].output_price == 2.40 + assert pricing["minimax/minimax-m3"].cached_input_price == 0.12 + assert pricing["minimax/minimax-m3"].cache_write_price is None + assert pricing["minimax/minimax-m2.7"].cache_write_price == 0.375 + assert capabilities["minimax/minimax-m3"].vision is True + assert capabilities["minimax/minimax-m2.7"].thinking_modes == ("always_on",) + assert captured["body"]["model"] == "MiniMax-M3" + assert captured["body"]["service_tier"] == "priority" + assert "priority" in estimated_tiers + finally: + asyncio.run(async_client.aclose()) + + 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/cache_support.py b/uncommon_route/cache_support.py index 78de6a8..3416fd0 100644 --- a/uncommon_route/cache_support.py +++ b/uncommon_route/cache_support.py @@ -205,6 +205,7 @@ def parse_usage_metrics( content: bytes, model: str, pricing: dict[str, ModelPricing], + service_tier: str = "standard", ) -> UsageMetrics | None: try: data = json.loads(content) @@ -214,6 +215,9 @@ def parse_usage_metrics( usage = data.get("usage") or {} if not isinstance(usage, dict): return None + effective_service_tier = str( + data.get("service_tier") or usage.get("service_tier") or service_tier or "standard" + ).strip().lower() prompt_details = usage.get("prompt_tokens_details") or usage.get("input_tokens_details") or {} output_details = usage.get("completion_tokens_details") or usage.get("output_tokens_details") or {} @@ -268,19 +272,22 @@ def parse_usage_metrics( actual_cost: float | None = None input_cost_multiplier = 1.0 if mp is not None: + effective_pricing = mp.for_usage(input_tokens_total, effective_service_tier) actual_cost = estimate_usage_cost( input_tokens_uncached=input_tokens_uncached, output_tokens=output_tokens, cache_read_input_tokens=cache_read_input_tokens, cache_write_input_tokens=cache_write_input_tokens, pricing=mp, + service_tier=effective_service_tier, ) - baseline_input_cost = (input_tokens_total / 1_000_000) * mp.input_price + baseline_input_cost = (input_tokens_total / 1_000_000) * effective_pricing.input_price effective_input_cost = estimate_input_cost( input_tokens_uncached=input_tokens_uncached, cache_read_input_tokens=cache_read_input_tokens, cache_write_input_tokens=cache_write_input_tokens, pricing=mp, + service_tier=effective_service_tier, ) if baseline_input_cost > 0: input_cost_multiplier = max(0.05, min(2.0, effective_input_cost / baseline_input_cost)) @@ -317,6 +324,7 @@ def parse_stream_usage_metrics( chunks: list[bytes], model: str, pricing: dict[str, ModelPricing], + service_tier: str = "standard", ) -> UsageMetrics | None: if not chunks: return None @@ -330,6 +338,7 @@ def parse_stream_usage_metrics( fallback_output_tokens = 0 fallback_prompt_tokens = 0 fallback_has_content = False + effective_service_tier = service_tier for line in text.splitlines(): if not line.startswith("data:"): @@ -341,9 +350,16 @@ def parse_stream_usage_metrics( data = json.loads(payload) except json.JSONDecodeError: continue + if isinstance(data, dict) and data.get("service_tier"): + effective_service_tier = str(data["service_tier"]) if isinstance(data, dict) and isinstance(data.get("usage"), dict): anthropic_usage.update(data["usage"]) - parsed = parse_usage_metrics(json.dumps({"usage": anthropic_usage}).encode("utf-8"), model, pricing) + parsed = parse_usage_metrics( + json.dumps({"usage": anthropic_usage}).encode("utf-8"), + model, + pricing, + effective_service_tier, + ) if parsed is not None: latest = parsed continue @@ -352,6 +368,8 @@ def parse_stream_usage_metrics( message = data.get("message") if isinstance(message, dict) and isinstance(message.get("usage"), dict): anthropic_usage.update(message["usage"]) + if isinstance(message, dict) and message.get("service_tier"): + effective_service_tier = str(message["service_tier"]) if data.get("type") == "message_delta" and isinstance(data.get("usage"), dict): anthropic_usage.update(data["usage"]) if anthropic_usage: @@ -359,6 +377,7 @@ def parse_stream_usage_metrics( json.dumps({"usage": anthropic_usage}).encode("utf-8"), model, pricing, + effective_service_tier, ) if parsed is not None: latest = parsed @@ -387,6 +406,7 @@ def parse_stream_usage_metrics( cache_read_input_tokens=0, cache_write_input_tokens=0, pricing=mp, + service_tier=effective_service_tier, ) latest = UsageMetrics( input_tokens_total=fallback_prompt_tokens, @@ -410,7 +430,10 @@ def estimate_input_cost( cache_read_input_tokens: int, cache_write_input_tokens: int, pricing: ModelPricing, + service_tier: str = "standard", ) -> float: + total_input_tokens = input_tokens_uncached + cache_read_input_tokens + cache_write_input_tokens + pricing = pricing.for_usage(total_input_tokens, service_tier) cached_read_price = ( pricing.cached_input_price if pricing.cached_input_price is not None @@ -435,12 +458,16 @@ def estimate_usage_cost( cache_read_input_tokens: int, cache_write_input_tokens: int, pricing: ModelPricing, + service_tier: str = "standard", ) -> float: + total_input_tokens = input_tokens_uncached + cache_read_input_tokens + cache_write_input_tokens + pricing = pricing.for_usage(total_input_tokens, service_tier) return estimate_input_cost( input_tokens_uncached=input_tokens_uncached, cache_read_input_tokens=cache_read_input_tokens, cache_write_input_tokens=cache_write_input_tokens, pricing=pricing, + service_tier=service_tier, ) + ((output_tokens / 1_000_000) * pricing.output_price) diff --git a/uncommon_route/minimax.py b/uncommon_route/minimax.py new file mode 100644 index 0000000..a6067cf --- /dev/null +++ b/uncommon_route/minimax.py @@ -0,0 +1,60 @@ +"""MiniMax request controls, scoped to verified transports and exact model IDs.""" + +from urllib.parse import urlparse + + +FLASH = "minimax-m3.1-flash" +EFFORTS = {"low", "medium", "high", "xhigh", "max"} + + +def is_direct_minimax(url: str) -> bool: + return urlparse(url).hostname in {"api.minimax.io", "api.minimaxi.com"} + + +def apply_minimax_controls(body: dict, url: str, *, anthropic: bool) -> None: + """Normalize incompatible router controls without changing message history.""" + model = str(body.get("model", "")).lower().split("/", 1)[-1] + direct = is_direct_minimax(url) + openrouter_flash = ( + urlparse(url).hostname == "openrouter.ai" and model == f"{FLASH}:free" + ) + if not direct and not openrouter_flash: + return + if (direct and model not in {FLASH, "minimax-m3"}) or (not direct and not openrouter_flash): + return + if direct: + body["model"] = "MiniMax-M3.1-Flash" if model == FLASH else "MiniMax-M3" + if model == "minimax-m3": + # M3 supports thinking on/off but does not tune depth. + body.pop("reasoning_effort", None) + if isinstance(body.get("output_config"), dict): + body["output_config"].pop("effort", None) + if isinstance(body.get("reasoning"), dict): + body["reasoning"].pop("effort", None) + return + + body.pop("service_tier", None) + if body.get("reasoning_split") is False: + body.pop("reasoning_split") + output_config = body.get("output_config") or {} + reasoning = body.get("reasoning") or {} + effort = ( + output_config.get("effort") if anthropic else body.get("reasoning_effort") + ) or reasoning.get("effort") or "max" + if effort not in EFFORTS: + effort = "max" + if openrouter_flash: + body.pop("thinking", None) + body.pop("reasoning_effort", None) + # Keep the full reasoning response available for tool follow-ups. + body["reasoning"] = {**reasoning, "effort": effort, "exclude": False} + body["reasoning"].pop("enabled", None) + elif anthropic: + body["thinking"] = {"type": "adaptive"} + body["output_config"] = {**output_config, "effort": effort} + body.pop("reasoning_effort", None) + body.pop("reasoning", None) + else: + body["thinking"] = {"type": "adaptive"} + body["reasoning_effort"] = effort + body.pop("reasoning", None) diff --git a/uncommon_route/model_map.py b/uncommon_route/model_map.py index f84cc3d..4be7e5c 100644 --- a/uncommon_route/model_map.py +++ b/uncommon_route/model_map.py @@ -25,7 +25,7 @@ import math import re import time -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path import httpx @@ -48,6 +48,7 @@ "api.anthropic.com": "anthropic", "api.deepseek.com": "deepseek", "api.minimax.io": "minimax", + "api.minimaxi.com": "minimax", "api.minimax.chat": "minimax", "generativelanguage.googleapis.com": "google", "api.x.ai": "xai", @@ -218,6 +219,23 @@ def is_routable_chat_model(model_id: str) -> bool: return not any(marker in core for marker in _NON_CHAT_MODEL_MARKERS) +def ordinary_model_choices(model_ids: list[str]) -> list[str]: + """Keep current MiniMax text choices; explicit saved IDs remain resolvable.""" + current = {"minimax-m3.1-flash", "minimax-m3.1-flash:free", "minimax-m3"} + choices = [ + model for model in model_ids + if not re.match(r"(?:minimax-m\d|minimax-text-01|minimax-vl-01)", _core(model.lower())) + or not is_routable_chat_model(model) + or _core(model.lower()) in current + ] + # Stable ordering preserves other providers while putting Flash before M3. + positions = [i for i, model in enumerate(choices) if _core(model.lower()) in current] + ordered = sorted((choices[i] for i in positions), key=lambda model: "flash" not in model.lower()) + for index, model in zip(positions, ordered): + choices[index] = model + return choices + + def _superseded_routing_models(model_ids: set[str]) -> set[str]: """Return routable IDs to hide when their live successor is available.""" return { @@ -394,6 +412,17 @@ async def discover(self, api_key: str | None = None) -> int: pricing, has_explicit_pricing=isinstance(m.get("pricing"), dict) and bool(m.get("pricing")), ) + if _core(model_id.lower()) in {"minimax-m3", "minimax-m3.1-flash", "minimax-m3.1-flash:free"}: + # Gateway limits and modalities come from that gateway's catalog. + modalities = tuple((m.get("architecture") or {}).get("input_modalities") or ()) + capabilities = replace( + capabilities, + vision="image" in modalities, + reasoning=True, + context_window=m.get("context_length"), + max_output_tokens=(m.get("top_provider") or {}).get("max_completion_tokens"), + input_modalities=modalities, + ) provider_name = _provider_prefix(model_id) or m.get("owned_by", "unknown") self._pool[model_id] = DiscoveredModel( @@ -568,7 +597,7 @@ def routable_models(self) -> list[str]: for model in self.available_models if is_routable_chat_model(model) ] - return filter_superseded_routing_models(routable) + return ordinary_model_choices(filter_superseded_routing_models(routable)) def get_pricing(self, model_id: str) -> ModelPricing | None: """Look up pricing for a single model (internal or upstream ID).""" @@ -679,7 +708,7 @@ def mapping_table(self) -> list[dict[str, str | bool | None]]: def pool_table(self) -> list[dict]: """Full model pool for the new dashboard — provider-grouped view.""" rows: list[dict] = [] - for model_id in sorted(self._pool.keys()): + for model_id in ordinary_model_choices(sorted(self._pool.keys())): dm = self._pool[model_id] rows.append({ "id": dm.id, diff --git a/uncommon_route/providers.py b/uncommon_route/providers.py index 338c3de..28b44bc 100644 --- a/uncommon_route/providers.py +++ b/uncommon_route/providers.py @@ -12,8 +12,10 @@ "minimax": { "api_key": "eyJ...", "base_url": "https://api.minimax.io/v1", - "models": ["minimax/minimax-m2.5"], - "plan": "coding-plan" + "models": [ + "minimax/minimax-m3.1-flash", + "minimax/minimax-m3" + ] }, "deepseek": { "api_key": "sk-...", @@ -48,7 +50,7 @@ } PROVIDER_MODELS: dict[str, list[str]] = { - "minimax": ["minimax/minimax-m2.5"], + "minimax": ["minimax/minimax-m3.1-flash", "minimax/minimax-m3"], "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 +59,18 @@ "moonshot": ["moonshot/kimi-k2.5"], } +UPSTREAM_MODEL_IDS: dict[str, dict[str, str]] = { + "minimax": { + "minimax/minimax-m3.1-flash": "MiniMax-M3.1-Flash", + "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..629b1cb 100644 --- a/uncommon_route/proxy.py +++ b/uncommon_route/proxy.py @@ -59,6 +59,8 @@ BASELINE_MODEL, DEFAULT_CONFIG, DEFAULT_MODEL_PRICING, + PROVIDER_MODEL_CAPABILITIES, + PROVIDER_MODEL_PRICING, VIRTUAL_MODEL_IDS, routing_mode_from_model, virtual_model_entries, @@ -106,10 +108,12 @@ ProvidersConfig, add_provider, load_providers, + resolve_upstream_model, remove_provider, verify_key, ) -from uncommon_route.model_map import ModelMapper +from uncommon_route.model_map import ModelMapper, ordinary_model_choices +from uncommon_route.minimax import apply_minimax_controls, is_direct_minimax from uncommon_route.routing_config_store import RoutingConfigStore from uncommon_route.scene_store import SceneConfig, SceneStore, _serialize_scene from uncommon_route.connections_store import ConnectionsStore, mask_api_key, resolve_primary_connection @@ -315,11 +319,17 @@ def _get_pricing() -> dict[str, ModelPricing]: return _active_pricing or DEFAULT_MODEL_PRICING -def _estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float: +def _estimate_cost( + model: str, + input_tokens: int, + output_tokens: int, + service_tier: str = "standard", +) -> float: """Compute dollar cost from token counts using the model pricing table.""" mp = _get_pricing().get(model) if mp is None: return 0.0 + mp = mp.for_usage(input_tokens, service_tier) return (input_tokens / 1_000_000) * mp.input_price + (output_tokens / 1_000_000) * mp.output_price @@ -877,6 +887,7 @@ async def _run_task( "max_tokens": task.max_tokens, "stream": False, } + apply_minimax_controls(payload, target_chat_url, anthropic=False) try: resp = await client.post(target_chat_url, json=payload, headers=headers) except (httpx.ConnectError, httpx.TimeoutException): @@ -914,7 +925,11 @@ 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) + if is_direct_minimax(provider_entry.base_url) + else model_id + ) elif self._upstream_chat: target_chat_url = self._upstream_chat upstream_model = self._mapper.resolve(model_id) @@ -2846,6 +2861,14 @@ def _refresh_active_pricing() -> None: dynamic = _mapper.dynamic_pricing if dynamic: merged.update(dynamic) + keyed_models = _providers.keyed_models() + for model_id in keyed_models: + entry = _providers.get_for_model(model_id) + if entry is None or not is_direct_minimax(entry.base_url): + continue + provider_pricing = PROVIDER_MODEL_PRICING.get(model_id) + if provider_pricing is not None: + merged[model_id] = provider_pricing _active_pricing = merged import copy @@ -2854,7 +2877,16 @@ def _refresh_active_pricing() -> None: if dynamic_caps: merged_caps = dict(updated.model_capabilities) merged_caps.update(dynamic_caps) - updated.model_capabilities = merged_caps + else: + merged_caps = dict(updated.model_capabilities) + for model_id in keyed_models: + entry = _providers.get_for_model(model_id) + if entry is None or not is_direct_minimax(entry.base_url): + continue + provider_capabilities = PROVIDER_MODEL_CAPABILITIES.get(model_id) + if provider_capabilities is not None: + merged_caps[model_id] = provider_capabilities + updated.model_capabilities = merged_caps _routing_config = updated def _reload_providers() -> ProvidersConfig: @@ -2862,8 +2894,11 @@ def _reload_providers() -> ProvidersConfig: _providers = load_providers() if isinstance(_semantic, UpstreamSemanticCompressor): _semantic.rebind_providers(_providers) + _refresh_active_pricing() return _providers + _refresh_active_pricing() + def _current_connection_payload() -> dict[str, Any]: effective = resolve_primary_connection( cli_upstream=_cli_upstream_override, @@ -3088,7 +3123,7 @@ def _build_selector_preview(body: dict[str, Any], request: Request) -> dict[str, ) ctx_features = extract_context_features(body, step_type, prompt) user_keyed = _providers.keyed_models() or None - base_available_models = _mapper.routable_models if _mapper.discovered else list(DEFAULT_MODEL_PRICING.keys()) + base_available_models = _mapper.routable_models if _mapper.discovered else ordinary_model_choices(list(DEFAULT_MODEL_PRICING)) if user_keyed: base_available_models = _merge_available_models(base_available_models, sorted(user_keyed)) available_models = _circuit_breaker.filter_available(base_available_models) @@ -3934,6 +3969,7 @@ async def _handle_chat_core( model = (body.get("model") or "").strip().lower() is_streaming = body.get("stream", False) + request_service_tier = str(body.get("service_tier") or "standard").strip().lower() response_model = str(body.pop("_client_requested_model", "") or model).strip() recursion_guarded = _recursion_guard_enabled(request) @@ -4121,7 +4157,7 @@ async def _handle_chat_core( step_type = routing_features.step_type user_keyed = _providers.keyed_models() or None try: - base_available_models = _mapper.routable_models if _mapper.discovered else list(DEFAULT_MODEL_PRICING.keys()) + base_available_models = _mapper.routable_models if _mapper.discovered else ordinary_model_choices(list(DEFAULT_MODEL_PRICING)) if user_keyed: base_available_models = _merge_available_models(base_available_models, sorted(user_keyed)) route_available_models = _circuit_breaker.filter_available(base_available_models) @@ -4146,6 +4182,7 @@ async def _handle_chat_core( _active_scene.primary, input_token_estimate, scene_output_budget, + request_service_tier, ) scene_baseline = _estimate_baseline_cost( input_token_estimate, @@ -4403,6 +4440,7 @@ async def _handle_chat_core( selected_model, input_tokens_after, effective_output_tokens, + request_service_tier, ) baseline_cost = _estimate_baseline_cost( input_tokens_before if input_tokens_before > 0 else input_tokens_after, @@ -4560,7 +4598,12 @@ async def _handle_chat_core( full_text = f"{system_prompt or ''} {prompt}".strip() input_tokens_before = estimate_tokens(full_text) if full_text else 0 input_tokens_after = input_tokens_before - estimated_cost = _estimate_cost(selected_model, input_tokens_after, max_tokens) + estimated_cost = _estimate_cost( + selected_model, + input_tokens_after, + max_tokens, + request_service_tier, + ) baseline_cost = estimated_cost main_estimated_cost = estimated_cost effective_output_tokens = max_tokens @@ -4600,7 +4643,12 @@ async def _handle_chat_core( def _estimated_total_cost_for(model_name: str) -> tuple[float, float]: token_input = input_tokens_after if input_tokens_after > 0 else input_tokens_before - main_cost = _estimate_cost(model_name, token_input, effective_output_tokens) + main_cost = _estimate_cost( + model_name, + token_input, + effective_output_tokens, + request_service_tier, + ) total_cost = main_cost + (sidechannel_estimated_cost if is_virtual else 0.0) return main_cost, total_cost @@ -4635,7 +4683,13 @@ 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) + if is_direct_minimax(attempt_provider_entry.base_url) + else model_name + ) + else: resolved_model = _mapper.resolve(model_name) attempt_upstream_body["model"] = resolved_model @@ -4715,6 +4769,12 @@ def _prepare_attempt(model_name: str) -> dict[str, Any]: upstream_provider=_mapper.provider, ) + apply_minimax_controls( + attempt_transport_body, + attempt_target_chat_url, + anthropic=attempt_native_anthropic_transport, + ) + if attempt_provider_entry: if attempt_native_anthropic_transport: attempt_headers.pop("authorization", None) @@ -5610,7 +5670,12 @@ async def anthropic_native_sse() -> AsyncGenerator[bytes, None]: for ev in converter.finish(): yield ev await _record_stream_success( - parse_stream_usage_metrics(stream_chunks, selected_model, _get_pricing()), + parse_stream_usage_metrics( + stream_chunks, + selected_model, + _get_pricing(), + request_service_tier, + ), stream_chunks=stream_chunks, ) except Exception: @@ -5649,7 +5714,12 @@ async def anthropic_sse() -> AsyncGenerator[bytes, None]: for ev in converter.finish(): yield ev await _record_stream_success( - parse_stream_usage_metrics(stream_chunks, selected_model, _get_pricing()), + parse_stream_usage_metrics( + stream_chunks, + selected_model, + _get_pricing(), + request_service_tier, + ), stream_chunks=stream_chunks, ) except Exception: @@ -5677,7 +5747,12 @@ async def sse_passthrough() -> AsyncGenerator[bytes, None]: _mark_current_attempt_first_token() yield _normalize_reasoning_content_chunk(chunk) await _record_stream_success( - parse_stream_usage_metrics(stream_chunks, selected_model, _get_pricing()), + parse_stream_usage_metrics( + stream_chunks, + selected_model, + _get_pricing(), + request_service_tier, + ), stream_chunks=stream_chunks, ) except Exception: @@ -5751,7 +5826,12 @@ async def sse_passthrough() -> AsyncGenerator[bytes, None]: tps: float | None = None usage_metrics: UsageMetrics | None = None if resp.status_code == 200: - usage_metrics = parse_usage_metrics(resp.content, selected_model, _get_pricing()) + usage_metrics = parse_usage_metrics( + resp.content, + selected_model, + _get_pricing(), + request_service_tier, + ) if usage_metrics is not None: actual_cost = ( usage_metrics.actual_cost diff --git a/uncommon_route/responses_compat.py b/uncommon_route/responses_compat.py index 706dd80..c3bf200 100644 --- a/uncommon_route/responses_compat.py +++ b/uncommon_route/responses_compat.py @@ -20,14 +20,16 @@ def responses_to_openai_chat_request( ) -> tuple[dict[str, Any], list[dict[str, Any]]]: """Convert a Responses API request into a chat completions request.""" + preserve = str(raw.get("model") or "").lower().startswith(("minimax/", "minimax-m")) messages = json.loads(json.dumps(previous_messages or [])) if previous_messages: - input_messages = _responses_input_to_messages(raw.get("input")) + input_messages = _responses_input_to_messages(raw.get("input"), preserve=preserve) messages.extend(input_messages) else: initial_messages = _build_initial_messages( instructions=raw.get("instructions"), input_value=raw.get("input"), + preserve=preserve, ) messages.extend(initial_messages) @@ -37,6 +39,13 @@ def responses_to_openai_chat_request( "stream": bool(raw.get("stream")), } + if preserve: + for field in ("reasoning", "service_tier"): + if field in raw: + body[field] = raw[field] + if "max_output_tokens" in raw: + body["max_tokens"] = raw["max_output_tokens"] + if raw.get("prompt_cache_key"): body["prompt_cache_key"] = raw.get("prompt_cache_key") @@ -117,6 +126,8 @@ def __init__(self, *, request_body: dict[str, Any], response_id: str) -> None: self._model = str(request_body.get("model") or "") self._chat_response_id = "" self._text_parts: list[str] = [] + self._reasoning_parts: list[str] = [] + self._reasoning_details: list[dict[str, Any]] = [] self._usage: dict[str, Any] | None = None self._tool_calls: dict[int, dict[str, Any]] = {} @@ -162,6 +173,10 @@ def _consume_chunk(self, payload: dict[str, Any]) -> None: return choice = choices[0] if isinstance(choices[0], dict) else {} delta = choice.get("delta") if isinstance(choice.get("delta"), dict) else {} + if isinstance(delta.get("reasoning_content"), str): + self._reasoning_parts.append(delta["reasoning_content"]) + if isinstance(delta.get("reasoning_details"), list): + self._reasoning_details.extend(delta["reasoning_details"]) content = delta.get("content") if isinstance(content, str) and content: self._text_parts.append(content) @@ -230,6 +245,11 @@ def _build_output_items(self) -> tuple[dict[str, Any] | None, list[dict[str, Any if assistant_message is not None and normalized_tool_calls: assistant_message["tool_calls"] = normalized_tool_calls output_items.extend(tool_items) + if assistant_message is not None: + if self._reasoning_parts: + assistant_message["reasoning_content"] = "".join(self._reasoning_parts) + if self._reasoning_details: + assistant_message["reasoning_details"] = self._reasoning_details return assistant_message, output_items def _build_events(self, output_items: list[dict[str, Any]]) -> list[bytes]: @@ -426,14 +446,14 @@ def _build_events(self, output_items: list[dict[str, Any]]) -> list[bytes]: return events -def _build_initial_messages(*, instructions: Any, input_value: Any) -> list[dict[str, Any]]: +def _build_initial_messages(*, instructions: Any, input_value: Any, preserve: bool = False) -> list[dict[str, Any]]: messages: list[dict[str, Any]] = [] system_parts: list[str] = [] instructions_text = str(instructions or "").strip() if instructions_text: system_parts.append(instructions_text) - for message in _responses_input_to_messages(input_value): + for message in _responses_input_to_messages(input_value, preserve=preserve): role = str(message.get("role") or "") content = message.get("content") text = content if isinstance(content, str) else "" @@ -447,26 +467,41 @@ def _build_initial_messages(*, instructions: Any, input_value: Any) -> list[dict return messages -def _responses_input_to_messages(input_value: Any) -> list[dict[str, Any]]: +def _responses_input_to_messages(input_value: Any, *, preserve: bool = False) -> list[dict[str, Any]]: if input_value is None: return [] if isinstance(input_value, str): return [{"role": "user", "content": input_value}] if isinstance(input_value, dict): - return _responses_input_item_to_messages(input_value) + return _responses_input_item_to_messages(input_value, preserve=preserve) messages: list[dict[str, Any]] = [] if isinstance(input_value, list): for item in input_value: - messages.extend(_responses_input_item_to_messages(item)) + converted = _responses_input_item_to_messages(item, preserve=preserve) + if ( + preserve and converted and messages + and "reasoning_content" in messages[-1] + and messages[-1].get("content") is None + and not messages[-1].get("tool_calls") + and converted[0].get("role") == "assistant" + ): + messages[-1].update(converted[0]) + messages.extend(converted[1:]) + else: + messages.extend(converted) return messages -def _responses_input_item_to_messages(item: Any) -> list[dict[str, Any]]: +def _responses_input_item_to_messages(item: Any, *, preserve: bool = False) -> list[dict[str, Any]]: if not isinstance(item, dict): return [] item_type = str(item.get("type") or "message") + if preserve and item_type == "reasoning": + return [{"role": "assistant", "content": None, "reasoning_content": "\n".join( + str(part.get("text") or "") for part in item.get("summary", []) + )}] if item_type == "function_call_output": return [ { @@ -499,7 +534,25 @@ def _responses_input_item_to_messages(item: Any) -> list[dict[str, Any]]: if item_type == "message" or role: chat_role = "system" if role in {"developer", "system"} else (role or "user") content = _responses_content_to_text(item.get("content")) + if preserve and isinstance(item.get("content"), list): + blocks = [] + for block in item["content"]: + kind = block.get("type") + if kind in {"input_text", "output_text", "text"}: + blocks.append({"type": "text", "text": block.get("text", "")}) + elif kind in {"input_image", "input_video"}: + key = "image_url" if kind == "input_image" else "video_url" + value = block.get(key, block.get("file_id", "")) + blocks.append({"type": key, key: value if isinstance(value, dict) else {"url": value}}) + else: + blocks.append(block) + if any(block.get("type") != "text" for block in blocks): + content = blocks message: dict[str, Any] = {"role": chat_role, "content": content} + if preserve: + for key in ("reasoning_content", "reasoning_details"): + if key in item: + message[key] = item[key] tool_calls = _responses_message_tool_calls(item) if tool_calls: message["role"] = "assistant" @@ -658,6 +711,9 @@ def _openai_message_to_responses_output(message: dict[str, Any]) -> tuple[dict[s ) if assistant_message is not None and normalized_tool_calls: assistant_message["tool_calls"] = normalized_tool_calls + for key in ("reasoning_content", "reasoning_details"): + if assistant_message is not None and key in message: + assistant_message[key] = message[key] return assistant_message, output_items diff --git a/uncommon_route/router/config.py b/uncommon_route/router/config.py index f3863f1..a98b2f4 100644 --- a/uncommon_route/router/config.py +++ b/uncommon_route/router/config.py @@ -6,6 +6,7 @@ ModeConfig, ModelCapabilities, ModelPricing, + ModelPricingTier, RoutingConfig, RoutingMode, ScoringConfig, @@ -42,6 +43,50 @@ "anthropic/claude-opus-4-7": ModelPricing(5.00, 25.00, cached_input_price=0.50, cache_write_price=6.25), } +PROVIDER_MODEL_PRICING: dict[str, ModelPricing] = { + "minimax/minimax-m3.1-flash": ModelPricing(0.15, 0.60, cached_input_price=0.015), + "minimax/minimax-m3": ModelPricing( + # Upper-band estimates: no unverified input-token boundary is assumed. + 0.60, + 2.40, + cached_input_price=0.12, + pricing_tiers=(ModelPricingTier("priority", 0.90, 3.60, cached_input_price=0.18),), + ), + "minimax/minimax-m2.7": ModelPricing( + 0.30, + 1.20, + cached_input_price=0.06, + cache_write_price=0.375, + ), +} + +PROVIDER_MODEL_CAPABILITIES: dict[str, ModelCapabilities] = { + "minimax/minimax-m3.1-flash": ModelCapabilities( + tool_calling=True, + vision=True, + reasoning=True, + context_window=1_000_000, + max_output_tokens=524_288, + input_modalities=("text", "image", "video"), + thinking_modes=("adaptive",), + ), + "minimax/minimax-m3": ModelCapabilities( + tool_calling=True, + vision=True, + reasoning=True, + context_window=1_000_000, + input_modalities=("text", "image", "video"), + thinking_modes=("adaptive", "disabled"), + ), + "minimax/minimax-m2.7": ModelCapabilities( + tool_calling=True, + reasoning=True, + context_window=204_800, + input_modalities=("text",), + thinking_modes=("always_on",), + ), +} + BASELINE_MODEL = "anthropic/claude-opus-4-7" VIRTUAL_MODEL_IDS: dict[RoutingMode, str] = { diff --git a/uncommon_route/router/selector.py b/uncommon_route/router/selector.py index 9803192..7f42b60 100644 --- a/uncommon_route/router/selector.py +++ b/uncommon_route/router/selector.py @@ -94,7 +94,7 @@ def _calc_cost( *, input_cost_multiplier: float = 1.0, ) -> float: - mp = _pricing_for_model(model, pricing) + mp = _pricing_for_model(model, pricing).for_usage(input_tokens) effective_multiplier = max(0.1, min(2.0, input_cost_multiplier)) return ( (input_tokens / 1_000_000) * mp.input_price * effective_multiplier diff --git a/uncommon_route/router/types.py b/uncommon_route/router/types.py index 4a9a319..a2dbf17 100644 --- a/uncommon_route/router/types.py +++ b/uncommon_route/router/types.py @@ -60,6 +60,10 @@ class ModelCapabilities: free: bool = False local: bool = False responses: bool = False + context_window: int | None = None + max_output_tokens: int | None = None + input_modalities: tuple[str, ...] = () + thinking_modes: tuple[str, ...] = () @dataclass(frozen=True, slots=True) @@ -416,12 +420,50 @@ class TierConfig: hard_pin: bool = False +@dataclass(frozen=True, slots=True) +class ModelPricingTier: + service_tier: str + input_price: float + output_price: float + cached_input_price: float | None = None + cache_write_price: float | None = None + input_tokens_lte: int | None = None + input_tokens_gt: int | None = None + + def matches(self, input_tokens: int, service_tier: str) -> bool: + if self.service_tier != service_tier: + return False + if self.input_tokens_lte is not None and input_tokens > self.input_tokens_lte: + return False + if self.input_tokens_gt is not None and input_tokens <= self.input_tokens_gt: + return False + return True + + @dataclass(frozen=True, slots=True) class ModelPricing: input_price: float # per 1M tokens output_price: float # per 1M tokens cached_input_price: float | None = None # per 1M cached-read tokens cache_write_price: float | None = None # per 1M cache-write / cache-create tokens + pricing_tiers: tuple[ModelPricingTier, ...] = () + + def for_usage(self, input_tokens: int, service_tier: str = "standard") -> ModelPricing: + normalized_tier = str(service_tier or "standard").strip().lower() + available_tiers = {tier.service_tier for tier in self.pricing_tiers} + tier_candidates = [normalized_tier] + if normalized_tier not in available_tiers and "standard" in available_tiers: + tier_candidates.append("standard") + for tier_name in tier_candidates: + for tier in self.pricing_tiers: + if tier.matches(max(0, input_tokens), tier_name): + return ModelPricing( + input_price=tier.input_price, + output_price=tier.output_price, + cached_input_price=tier.cached_input_price, + cache_write_price=tier.cache_write_price, + ) + return self @dataclass(frozen=True, slots=True) diff --git a/uncommon_route/stats.py b/uncommon_route/stats.py index e59ba87..dce6b4e 100644 --- a/uncommon_route/stats.py +++ b/uncommon_route/stats.py @@ -256,6 +256,8 @@ def _cache_savings(r: RouteRecord) -> float: pricing = _get_stats_pricing().get(r.model) if pricing is None: return 0.0 + billed_input_tokens = r.usage_input_tokens or r.input_tokens_after + pricing = pricing.for_usage(billed_input_tokens) cached_input_price = pricing.cached_input_price if pricing.cached_input_price is not None else pricing.input_price cache_write_price = pricing.cache_write_price if pricing.cache_write_price is not None else pricing.input_price read_delta = ((pricing.input_price - cached_input_price) * r.cache_read_input_tokens) / 1_000_000 @@ -269,8 +271,11 @@ def _compaction_savings(r: RouteRecord) -> float: pricing = _get_stats_pricing().get(r.model) if pricing is None: return 0.0 - reduced_tokens = r.input_tokens_before - r.input_tokens_after - return (reduced_tokens / 1_000_000) * pricing.input_price + before_pricing = pricing.for_usage(r.input_tokens_before) + after_pricing = pricing.for_usage(r.input_tokens_after) + before_cost = (r.input_tokens_before / 1_000_000) * before_pricing.input_price + after_cost = (r.input_tokens_after / 1_000_000) * after_pricing.input_price + return max(0.0, before_cost - after_cost) class RouteStats: