Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions raven/cli/onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def _t(en: str, zh: str) -> str:
"label": "MiniMax (open-source partner)",
"label_zh": "MiniMax(开源合作伙伴)",
},
{"name": "minimaxi", "label": "MiniMax CN", "label_zh": "MiniMax CN(国内站)"},
{"name": "deepseek", "label": "DeepSeek", "label_zh": "DeepSeek"},
{"name": "zai", "label": "Z.ai (Zhipu)", "label_zh": "Z.ai(智谱)"},
{"name": "dashscope", "label": "DashScope", "label_zh": "阿里云百炼"},
Expand Down Expand Up @@ -881,6 +882,12 @@ def _verify_provider(provider: str, *, skip_test: bool = False) -> tuple[bool, s
else:
console.print(_t(" [dim]⏳ Verifying your API key…[/dim]", " [dim]⏳ 正在验证 API Key…[/dim]"))
result = probe(provider)
# Named before the verdict: the address is resolved from the config, the
# registry default or LiteLLM, so on a failure it is the one thing the user
# cannot otherwise see.
probed_base = result.get("api_base")
if probed_base:
console.print(_t(f" [dim]Endpoint: {probed_base}[/dim]", f" [dim]接入地址:{probed_base}[/dim]"))
if result["ok"]:
models = result.get("models_count")
suffix = _t(f" ({models} models available)", f"(共 {models} 个可用模型)") if models else ""
Expand Down
1 change: 1 addition & 0 deletions raven/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ def _merge_renamed_sections(cls, data: Any) -> Any:
gemini: GeminiProviderConfig = Field(default_factory=GeminiProviderConfig) # Google Gemini / Vertex AI
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
minimaxi: ProviderConfig = Field(default_factory=ProviderConfig)
minimax_global: ProviderConfig = Field(default_factory=ProviderConfig)
minimax_cn: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
Expand Down
14 changes: 11 additions & 3 deletions raven/config/update_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1089,8 +1089,10 @@ def test_provider(
3. Map status code → keyword (see ``_HTTP_STATUS_MAP``). Unknown codes
render as ``http_{code}``. Network errors → ``network_error``.

Returns a dict, never raises. ``transport`` is injectable so unit tests
can mount an ``httpx.MockTransport`` without touching real network.
Returns a dict, never raises. It carries ``api_base`` -- the address the
probe actually contacted -- whenever one was resolved. ``transport`` is
injectable so unit tests can mount an ``httpx.MockTransport`` without
touching real network.
"""
name = canonical_provider_name(name)

Expand Down Expand Up @@ -1235,7 +1237,13 @@ def test_provider(
if spec and spec.name in {"minimax_global", "minimax_cn"} and api_key:
headers["x-api-key"] = api_key

result = _probe_models_endpoint(url, headers, timeout_s=timeout_s, transport=transport)
# Carried back so a caller can name the address it just probed: the
# resolution above (endpoint -> spec default -> LiteLLM) is not visible from
# the config alone, so a wrong endpoint is otherwise invisible on failure.
result = {
**_probe_models_endpoint(url, headers, timeout_s=timeout_s, transport=transport),
"api_base": api_base,
}
if derived_api_base and result.get("status") == "http_404":
# The address LiteLLM sends completions to is not always where the
# catalogue lives -- DeepSeek's is `/beta`, which has no `/models`. A 404
Expand Down
16 changes: 16 additions & 0 deletions raven/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,22 @@ def claims(self, model: str) -> bool:
# /v1/models before any LiteLLM call resolves an endpoint.
default_api_base="https://api.minimax.io/v1",
),
ProviderSpec(
name="minimaxi",
keywords=("minimaxi",),
env_key="MINIMAX_API_KEY",
display_name="MiniMax CN",
via_driver="minimax",
skip_prefixes=("minimax/", "minimaxi/", "openrouter/"),
env_extras=(("MINIMAX_API_BASE", "{api_base}"),),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: this carries the CN address through process-global state, not through the provider instance. _setup_env applies env_extras with os.environ.setdefault, while LiteLLMProvider.api_base remains None. In a process with both bindings (which ProviderPool supports), constructing CN first makes a later international MiniMax provider read the CN base; conversely, a pre-existing international MINIMAX_API_BASE prevents the CN binding from selecting its own base. I reproduced both cases with MinimaxChatConfig.get_api_base. Please pass the selected regional base per provider/request rather than using shared ambient state.

is_gateway=False,
is_local=False,
detect_by_key_prefix="",
detect_by_base_keyword="",
strip_model_prefix=False,
model_overrides=(),
default_api_base="https://api.minimaxi.com/v1",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: this changes only the /models preflight default. The runtime construction path calls Config.get_api_base, which intentionally does not return defaults for direct providers, and this spec has no MINIMAX_API_BASE env extra, so LiteLLMProvider.api_base remains None. LiteLLM then sends the subsequent test message and real chats to its international default, https://api.minimax.io/v1; a China key can pass this probe and still fail the real request. Conversely, minimax is the sole unqualified API-key entry, and MiniMax's regional setup documentation says international users use api.minimax.io while China users use api.minimaxi.com, so replacing the shared default sends international preflights to the wrong region. Please make the API-key region selection explicit or account-aware and propagate the selected base into the actual provider, with tests for both regional flows.

),
ProviderSpec(
name="minimax_global",
client="minimax_oauth",
Expand Down
9 changes: 9 additions & 0 deletions tests/data/wire_model_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@
"vendorx/zz-probe-1": "vendorx/zz-probe-1",
"zz-probe-1": "zz-probe-1"
},
"minimaxi": {
"minimax/zz-probe-1": "minimax/zz-probe-1",
"minimaxi-probe": "minimax/minimaxi-probe",
"minimaxi/vendorx/zz-probe-1": "minimax/vendorx/zz-probe-1",
"minimaxi/zz-probe-1": "minimax/zz-probe-1",
"openrouter/zz-probe-1": "openrouter/zz-probe-1",
"vendorx/zz-probe-1": "vendorx/zz-probe-1",
"zz-probe-1": "zz-probe-1"
},
"moonshot": {
"moonshot-probe": "moonshot/moonshot-probe",
"moonshot/vendorx/zz-probe-1": "moonshot/vendorx/zz-probe-1",
Expand Down
46 changes: 42 additions & 4 deletions tests/test_cli_onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3288,23 +3288,29 @@ def test_the_vendor_step_offers_litellm_names_the_picker_does_not_already_list()
assert result["count"] > 50


def test_minimax_precedes_deepseek_and_carries_the_open_source_partner_marker() -> None:
def test_the_minimax_rows_precede_deepseek_and_carry_the_open_source_partner_marker() -> None:
"""A deliberate placement, so a later reordering cannot drop it silently.

"open-source partner" rather than a bare "partner": in a list of vendors the
short form reads as paid placement. Only the API-key entry is marked -- the
OAuth ones are the same vendor and already carry "(OAuth)".
short form reads as paid placement. Only the international API-key entry is
marked -- the CN row is the same vendor one host over, and the OAuth ones
already carry "(OAuth)".
"""
from raven.cli.onboard_commands import _CURATED_GROUPS

api_key_group = next(g for g in _CURATED_GROUPS if g["kind"] == "api_key")
names = [entry["name"] for entry in api_key_group["providers"]]
assert names.index("minimax") == names.index("deepseek") - 1
assert names.index("minimaxi") == names.index("minimax") + 1, "the CN row left its vendor's side"
assert names.index("minimaxi") == names.index("deepseek") - 1

minimax = api_key_group["providers"][names.index("minimax")]
assert minimax["label"] == "MiniMax (open-source partner)"
assert minimax["label_zh"] == "MiniMax(开源合作伙伴)"

minimaxi = api_key_group["providers"][names.index("minimaxi")]
assert minimaxi["label"] == "MiniMax CN"
assert "partner" not in minimaxi["label"]

oauth_group = next(g for g in _CURATED_GROUPS if g["kind"] == "oauth")
for entry in oauth_group["providers"]:
assert "partner" not in entry["label"], entry["label"]
Expand Down Expand Up @@ -4475,6 +4481,38 @@ def _cancelled(*a: Any, **kw: Any) -> Any:
)


def test_the_probed_endpoint_is_named_whenever_one_was_resolved(
tmp_env: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A vendor like MiniMax ships an address and stores none, so the wizard
probes a URL the user never typed. Failing silently against it left the one
field worth checking invisible in exactly the case that needs it."""
base = "https://api.minimax.io/v1"

monkeypatch.setattr(
"raven.config.update_providers.test_provider",
lambda *a, **kw: {"ok": True, "status": "valid", "models_count": 2, "api_base": base},
)
ok, status, _models = onboard_commands._verify_provider("minimax")
assert (ok, status) == (True, "valid")
assert base in " ".join(capsys.readouterr().out.split())

monkeypatch.setattr(
"raven.config.update_providers.test_provider",
lambda *a, **kw: {"ok": False, "status": "invalid_key", "api_base": base, "error": "401"},
)
ok, status, _models = onboard_commands._verify_provider("minimax")
assert (ok, status) == (False, "invalid_key")
assert base in " ".join(capsys.readouterr().out.split()), "a failed probe hid the address it tried"

monkeypatch.setattr(
"raven.config.update_providers.test_provider",
lambda *a, **kw: {"ok": True, "status": "valid", "models_count": 1},
)
onboard_commands._verify_provider("minimax")
assert "Endpoint" not in capsys.readouterr().out, "no address resolved must print no field"


@pytest.mark.parametrize(
("flags", "expected"),
[
Expand Down
25 changes: 25 additions & 0 deletions tests/test_config_update_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,31 @@ def handler(request: httpx.Request) -> httpx.Response:
assert result["http_status"] == 200


def test_test_provider_reports_the_address_it_probed(cfg_path: Path) -> None:
"""MiniMax stores no api_base of its own -- the registry default is what the
probe resolves and contacts, so a caller that wants to name the endpoint has
no other way to learn which one that was."""
from raven.providers.registry import find_by_name

_seed_key(cfg_path, name="minimax", key="sk-minimax")
expected = find_by_name("minimax").default_api_base
seen: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
seen.append(str(request.url))
return httpx.Response(200, json={"data": [{"id": "MiniMax-M2"}]})

result = probe_provider("minimax", config_path=cfg_path, transport=_mock_transport(handler))
assert result["api_base"] == expected
assert seen == [expected.rstrip("/") + "/models"]

def refuse(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={})

refused = probe_provider("minimax", config_path=cfg_path, transport=_mock_transport(refuse))
assert refused["api_base"] == expected, "a failed probe must still name the address it tried"


def test_test_provider_200_extracts_model_ids(cfg_path: Path) -> None:
_seed_key(cfg_path)

Expand Down
35 changes: 32 additions & 3 deletions tests/test_provider_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"dashscope",
"moonshot",
"minimax",
"minimaxi",
"minimax_global",
"minimax_cn",
"hosted_vllm",
Expand All @@ -39,9 +40,9 @@
}


def test_registry_has_exactly_21_providers() -> None:
assert len(PROVIDERS) == 21
assert len(EXPECTED_PROVIDER_NAMES) == 21
def test_registry_has_exactly_22_providers() -> None:
assert len(PROVIDERS) == 22
assert len(EXPECTED_PROVIDER_NAMES) == 22


def test_registry_provider_name_set_is_pinned() -> None:
Expand Down Expand Up @@ -108,6 +109,34 @@ def test_registry_and_schema_declare_the_same_providers() -> None:
assert {spec.name for spec in PROVIDERS} == set(ProvidersConfig.model_fields)


def test_the_two_minimax_api_key_sections_reach_different_hosts(monkeypatch: pytest.MonkeyPatch) -> None:
"""One vendor, two deployments, and a key issued for one that the other
refuses -- so which section a user picks has to decide where the request
lands. LiteLLM's minimax driver defaults to the international host, which is
why the CN section carries its own address into the environment instead of
leaving the driver to guess.
"""
import os

from raven.providers.litellm_provider import LiteLLMProvider

intl = find_by_name("minimax")
cn = find_by_name("minimaxi")
assert intl.default_api_base == "https://api.minimax.io/v1"
assert cn.default_api_base == "https://api.minimaxi.com/v1"
assert cn.model_prefix == "minimax", "LiteLLM has no minimaxi driver to route to"

monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
monkeypatch.delenv("MINIMAX_API_BASE", raising=False)
LiteLLMProvider(api_key="sk-cn", default_model="minimaxi/MiniMax-M2.1", provider_name="minimaxi")
assert os.environ["MINIMAX_API_BASE"] == "https://api.minimaxi.com/v1"

monkeypatch.delenv("MINIMAX_API_KEY", raising=False)
monkeypatch.delenv("MINIMAX_API_BASE", raising=False)
LiteLLMProvider(api_key="sk-intl", default_model="minimax/MiniMax-M2.1", provider_name="minimax")
assert "MINIMAX_API_BASE" not in os.environ, "the international section must leave the driver's default alone"


# Direct providers seeded in the model picker (issue #100). Each must expose a
# non-empty default_model drawn from its curated shortlist, so the onboarding
# fallback and the picker stay in sync and no provider defaults to empty.
Expand Down
Loading