From bcee009917ac924e5d2f693d601ad4a88d3e9a6d Mon Sep 17 00:00:00 2001 From: jax-novita Date: Tue, 18 Aug 2026 16:16:45 +0800 Subject: [PATCH] Add Novita AI as an LLM provider (#422) * Add Novita as an LLM provider Registers Novita (novita.ai) as an OpenAI-routed provider, following the same pattern as Requesty/Atlas Cloud/SiliconFlow: a base_url + API key env var entry in _OPENAI_ROUTED_PROVIDERS, a handful of model registry entries (DeepSeek/Qwen/GLM), onboarding wizard support (constants/steps/wizard/ helpers), a key validator using the auth-preflight sentinel pattern (Novita's /v1/models endpoint returns the public catalog even for an invalid key, so auth must be checked via a chat completion instead), and a host-to-provider mapping entry for error attribution. * Recommend Novita's current flagship models The models listed for Novita were older ids that no longer reflect what the platform leads with. Point the recommendations at the three current flagships instead, each verified against api.novita.ai: moonshotai/kimi-k3 1M context, native vision zai-org/glm-5.2 1M context, long-horizon agentic work deepseek/deepseek-v4-flash-0731 1M context, cheapest of the three Context windows, output limits, input modalities and pricing were taken from the live /openai/v1/models response rather than carried over. * Keep branch CI workflow files unchanged (no workflow OAuth scope) Co-authored-by: multica-agent * ci: restore workflow files to match main --------- Co-authored-by: jax-novita Co-authored-by: multica-agent Co-authored-by: Dinos Papakostas Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> --- .env.example | 1 + EvoScientist/config/onboard/constants.py | 1 + EvoScientist/config/onboard/helpers.py | 6 +++ EvoScientist/config/onboard/steps.py | 4 ++ EvoScientist/config/onboard/validators.py | 51 +++++++++++++++++++++++ EvoScientist/config/onboard/wizard.py | 1 + EvoScientist/config/settings.py | 4 ++ EvoScientist/llm/errors.py | 1 + EvoScientist/llm/models.py | 6 +-- EvoScientist/llm/registry.py | 6 +++ tests/test_llm.py | 27 ++++++++++++ tests/test_onboard.py | 45 ++++++++++++++++++++ 12 files changed, 150 insertions(+), 3 deletions(-) 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) # =============================================================================