diff --git a/.env.example b/.env.example index 407ed0c..1830474 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,7 @@ SILICONFLOW_API_KEY= # siliconflow.cn OPENROUTER_API_KEY= # openrouter.ai REQUESTY_API_KEY= # requesty.ai ATLASCLOUD_API_KEY= # atlascloud.ai +NOVITA_API_KEY= # novita.ai # Custom endpoints (optional) CUSTOM_OPENAI_API_KEY= # OpenAI-compatible endpoint diff --git a/EvoScientist/config/onboard/constants.py b/EvoScientist/config/onboard/constants.py index ed4fad6..7284a8f 100644 --- a/EvoScientist/config/onboard/constants.py +++ b/EvoScientist/config/onboard/constants.py @@ -33,6 +33,7 @@ VALID_PROVIDERS: frozenset[str] = frozenset( "openrouter", "atlascloud", "requesty", + "novita", "custom-openai", "custom-anthropic", } diff --git a/EvoScientist/config/onboard/helpers.py b/EvoScientist/config/onboard/helpers.py index ac76ce4..413b3a7 100644 --- a/EvoScientist/config/onboard/helpers.py +++ b/EvoScientist/config/onboard/helpers.py @@ -23,6 +23,7 @@ from .validators import ( validate_kimi_key, validate_minimax_key, validate_moonshot_key, + validate_novita_key, validate_nvidia_key, validate_openai_key, validate_openrouter_key, @@ -82,6 +83,11 @@ def _provider_key_info(config: EvoScientistConfig, provider: str): config.requesty_api_key or os.environ.get("REQUESTY_API_KEY", ""), validate_requesty_key, ), + "novita": ( + "Novita", + config.novita_api_key or os.environ.get("NOVITA_API_KEY", ""), + validate_novita_key, + ), "deepseek": ( "DeepSeek", config.deepseek_api_key or os.environ.get("DEEPSEEK_API_KEY", ""), diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py index 14f1264..b38e915 100644 --- a/EvoScientist/config/onboard/steps.py +++ b/EvoScientist/config/onboard/steps.py @@ -322,6 +322,10 @@ def _step_provider( title="Requesty (aggregator — OpenAI, Anthropic, Gemini, xAI, etc.)", value="requesty", ), + Choice( + title="Novita (aggregator — DeepSeek, Qwen, GLM, etc.)", + value="novita", + ), Choice( title="OpenAI-compatible (third-party OpenAI endpoint)", value="custom-openai", diff --git a/EvoScientist/config/onboard/validators.py b/EvoScientist/config/onboard/validators.py index f6e90ac..3d8e59d 100644 --- a/EvoScientist/config/onboard/validators.py +++ b/EvoScientist/config/onboard/validators.py @@ -423,6 +423,57 @@ def validate_requesty_key(api_key: str) -> tuple[bool, str]: return False, f"Error: {e}" +def validate_novita_key(api_key: str) -> tuple[bool, str]: + """Validate a Novita API key against the router's auth layer. + + Like Requesty and Atlas Cloud, Novita's ``/v1/models`` endpoint returns + HTTP 200 (the public model catalog) even for a missing or invalid key, so + it cannot be used to check a key (verified against the live endpoint). We + instead issue a minimal ``/v1/chat/completions`` request with a + deliberately nonexistent sentinel model: auth is resolved before the + model, so a valid key doesn't depend on any real model staying available + upstream. + + - invalid/missing key → 401/403 (confirmed against the live endpoint); + - valid key → 200 or 404 (model-not-found, auth passed), mirroring the + Requesty/Atlas Cloud sentinel pattern; + - 429 (rate-limit) / 5xx (service incident) leave validity unknown, so a + transient outage doesn't reject a good key. + + Returns: + Tuple of (is_valid, message). + """ + if not api_key: + return True, "Skipped (no key provided)" + + try: + import httpx + + resp = httpx.post( + "https://api.novita.ai/openai/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "novita/auth-preflight", + "messages": [{"role": "user", "content": "ping"}], + "max_tokens": 1, + }, + timeout=10, + ) + if resp.status_code in (200, 404): + return True, "Valid" + if resp.status_code in (401, 403): + return False, "Invalid API key" + return False, f"Validation inconclusive (HTTP {resp.status_code})" + except Exception as e: + classified = _classify_validation_error(e) + if classified is not None: + return classified + return False, f"Error: {e}" + + def validate_deepseek_key(api_key: str) -> tuple[bool, str]: """Validate a DeepSeek API key by making a test request. diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py index 65a19bb..42bca28 100644 --- a/EvoScientist/config/onboard/wizard.py +++ b/EvoScientist/config/onboard/wizard.py @@ -120,6 +120,7 @@ _PROVIDER_KEY_ATTR = { "openrouter": "openrouter_api_key", "atlascloud": "atlascloud_api_key", "requesty": "requesty_api_key", + "novita": "novita_api_key", "deepseek": "deepseek_api_key", "zhipu": "zhipu_api_key", "zhipu-code": "zhipu_api_key", diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index ed2b49f..50ac77f 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -172,6 +172,7 @@ class EvoScientistConfig: openrouter_api_key: str = "" atlascloud_api_key: str = "" requesty_api_key: str = "" + novita_api_key: str = "" deepseek_api_key: str = "" zhipu_api_key: str = "" volcengine_api_key: str = "" @@ -802,6 +803,7 @@ _ENV_MAPPINGS = { "openrouter_api_key": "OPENROUTER_API_KEY", "atlascloud_api_key": "ATLASCLOUD_API_KEY", "requesty_api_key": "REQUESTY_API_KEY", + "novita_api_key": "NOVITA_API_KEY", "deepseek_api_key": "DEEPSEEK_API_KEY", "zhipu_api_key": "ZHIPU_API_KEY", "volcengine_api_key": "VOLCENGINE_API_KEY", @@ -986,6 +988,8 @@ def apply_config_to_env(config: EvoScientistConfig) -> None: os.environ["ATLASCLOUD_API_KEY"] = config.atlascloud_api_key if config.requesty_api_key and not os.environ.get("REQUESTY_API_KEY"): os.environ["REQUESTY_API_KEY"] = config.requesty_api_key + if config.novita_api_key and not os.environ.get("NOVITA_API_KEY"): + os.environ["NOVITA_API_KEY"] = config.novita_api_key if config.deepseek_api_key and not os.environ.get("DEEPSEEK_API_KEY"): os.environ["DEEPSEEK_API_KEY"] = config.deepseek_api_key if config.zhipu_api_key and not os.environ.get("ZHIPU_API_KEY"): diff --git a/EvoScientist/llm/errors.py b/EvoScientist/llm/errors.py index 4efa264..9675ddd 100644 --- a/EvoScientist/llm/errors.py +++ b/EvoScientist/llm/errors.py @@ -187,6 +187,7 @@ _HOST_TO_PROVIDER: dict[str, str] = { "api.minimaxi.com": "minimax", "api.kimi.com": "kimi", # kimi-coding shares this host "openrouter.ai": "openrouter", + "api.novita.ai": "novita", } diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index a4d66af..89c3758 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -3,9 +3,9 @@ This module provides a unified interface for creating chat model instances with support for multiple providers (Anthropic, OpenAI, Google GenAI, Atlas Cloud, MiniMax (Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, Requesty, -ZhipuAI, Volcengine, DashScope, DashScope-Code, DeepSeek, Ollama, and custom -OpenAI/Anthropic-compatible endpoints) and convenient short names for common -models. +Novita, ZhipuAI, Volcengine, DashScope, DashScope-Code, DeepSeek, Ollama, and +custom OpenAI/Anthropic-compatible endpoints) and convenient short names for +common models. """ from __future__ import annotations diff --git a/EvoScientist/llm/registry.py b/EvoScientist/llm/registry.py index 8ada027..6cbd541 100644 --- a/EvoScientist/llm/registry.py +++ b/EvoScientist/llm/registry.py @@ -21,6 +21,7 @@ _ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai/v1" _MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1" _KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/" _REQUESTY_BASE_URL = "https://router.requesty.ai/v1" +_NOVITA_BASE_URL = "https://api.novita.ai/openai/v1" # Providers routed through the OpenAI provider with a custom base_url. # Maps provider name → (base_url or None, env var for API key). @@ -35,6 +36,7 @@ _OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { "dashscope": (_DASHSCOPE_BASE_URL, "DASHSCOPE_API_KEY"), "dashscope-code": (_DASHSCOPE_CODE_BASE_URL, "DASHSCOPE_API_KEY"), "requesty": (_REQUESTY_BASE_URL, "REQUESTY_API_KEY"), + "novita": (_NOVITA_BASE_URL, "NOVITA_API_KEY"), "custom-openai": ( None, "CUSTOM_OPENAI_API_KEY", @@ -155,6 +157,10 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [ ("gemini-3.5-flash", "google/gemini-3.5-flash", "requesty"), ("grok-4.3", "xai/grok-4.3", "requesty"), ("grok-build-0.1", "xai/grok-build-0.1", "requesty"), + # Novita (aggregator — OpenAI-compatible, Kimi/GLM/DeepSeek, etc.) + ("kimi-k3", "moonshotai/kimi-k3", "novita"), + ("glm-5.2", "zai-org/glm-5.2", "novita"), + ("deepseek-v4-flash", "deepseek/deepseek-v4-flash-0731", "novita"), # OpenRouter ("claude-fable-5", "anthropic/claude-fable-5", "openrouter"), ("claude-opus-5", "anthropic/claude-opus-5", "openrouter"), diff --git a/tests/test_llm.py b/tests/test_llm.py index a8f5c4d..1ca552a 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -48,6 +48,7 @@ class TestModelsRegistry: assert "moonshot" in providers assert "kimi-coding" in providers assert "atlascloud" in providers + assert "novita" in providers def test_entries_are_valid_tuples(self): """Test that _MODEL_ENTRIES contains valid (name, model_id, provider) tuples.""" @@ -72,6 +73,7 @@ class TestModelsRegistry: "moonshot", "kimi-coding", "atlascloud", + "novita", } for entry in _MODEL_ENTRIES: assert len(entry) == 3, f"Entry {entry} doesn't have 3 elements" @@ -98,6 +100,8 @@ class TestModelsRegistry: atlas_models = get_models_for_provider("atlascloud") assert ("qwen3.5-27b", "qwen/qwen3.5-27b") in atlas_models assert get_models_for_provider("atlas") == [] + novita_models = get_models_for_provider("novita") + assert ("kimi-k3", "moonshotai/kimi-k3") in novita_models # ============================================================================= @@ -449,6 +453,29 @@ class TestThirdPartyRouting: assert call_kwargs["base_url"] == "https://router.requesty.ai/v1" assert call_kwargs["api_key"] == "rq-key-123" + @patch("EvoScientist.llm.models.init_chat_model") + def test_novita_routes_through_openai(self, mock_init, monkeypatch): + """Novita provider should route through OpenAI with correct base_url.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("NOVITA_API_KEY", "novita-key-123") + + get_chat_model("kimi-k3", provider="novita") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["model"] == "moonshotai/kimi-k3" + assert call_kwargs["model_provider"] == "openai" + assert call_kwargs["base_url"] == "https://api.novita.ai/openai/v1" + assert call_kwargs["api_key"] == "novita-key-123" + + def test_novita_host_maps_to_provider(self): + """Provider error envelopes should identify Novita by host.""" + from EvoScientist.llm.errors import _lookup_host_or_compat + + assert ( + _lookup_host_or_compat("https://api.novita.ai/openai/v1", "openai") + == "novita" + ) + @patch("EvoScientist.llm.models.init_chat_model") def test_requesty_anthropic_prompt_cache_enabled_by_default( self, mock_init, monkeypatch diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 444da48..ef7efb8 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -429,6 +429,51 @@ class TestValidateAtlasCloudKey: assert "inconclusive" in msg.lower() +class TestValidateNovitaKey: + def test_empty_key_skipped(self): + from EvoScientist.config.onboard.validators import validate_novita_key + + is_valid, msg = validate_novita_key("") + assert is_valid is True + assert "Skipped" in msg + + @pytest.mark.parametrize("status", [200, 404]) + def test_accepts_authenticated_sentinel_statuses(self, status): + from EvoScientist.config.onboard.validators import validate_novita_key + + with patch("httpx.post") as mock_post: + mock_post.return_value.status_code = status + is_valid, msg = validate_novita_key("novita-key") + + assert is_valid is True + assert msg == "Valid" + payload = mock_post.call_args.kwargs["json"] + assert payload["model"] == "novita/auth-preflight" + assert payload["max_tokens"] == 1 + + @pytest.mark.parametrize("status", [401, 403]) + def test_auth_rejection_is_invalid(self, status): + from EvoScientist.config.onboard.validators import validate_novita_key + + with patch("httpx.post") as mock_post: + mock_post.return_value.status_code = status + is_valid, msg = validate_novita_key("bad-key") + + assert is_valid is False + assert msg == "Invalid API key" + + @pytest.mark.parametrize("status", [400, 429, 500, 503]) + def test_transient_status_is_inconclusive(self, status): + from EvoScientist.config.onboard.validators import validate_novita_key + + with patch("httpx.post") as mock_post: + mock_post.return_value.status_code = status + is_valid, msg = validate_novita_key("novita-key") + + assert is_valid is False + assert "inconclusive" in msg.lower() + + # ============================================================================= # Test Step Functions (Mocked questionary) # =============================================================================