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 <github@multica.ai>

* ci: restore workflow files to match main

---------

Co-authored-by: jax-novita <jax-novita@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Dinos Papakostas <dinospk1999@gmail.com>
Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
This commit is contained in:
jax-novita
2026-08-18 16:16:45 +08:00
committed by GitHub
parent 47c8da3e5b
commit bcee009917
12 changed files with 150 additions and 3 deletions
+1
View File
@@ -20,6 +20,7 @@ SILICONFLOW_API_KEY= # siliconflow.cn
OPENROUTER_API_KEY= # openrouter.ai OPENROUTER_API_KEY= # openrouter.ai
REQUESTY_API_KEY= # requesty.ai REQUESTY_API_KEY= # requesty.ai
ATLASCLOUD_API_KEY= # atlascloud.ai ATLASCLOUD_API_KEY= # atlascloud.ai
NOVITA_API_KEY= # novita.ai
# Custom endpoints (optional) # Custom endpoints (optional)
CUSTOM_OPENAI_API_KEY= # OpenAI-compatible endpoint CUSTOM_OPENAI_API_KEY= # OpenAI-compatible endpoint
+1
View File
@@ -33,6 +33,7 @@ VALID_PROVIDERS: frozenset[str] = frozenset(
"openrouter", "openrouter",
"atlascloud", "atlascloud",
"requesty", "requesty",
"novita",
"custom-openai", "custom-openai",
"custom-anthropic", "custom-anthropic",
} }
+6
View File
@@ -23,6 +23,7 @@ from .validators import (
validate_kimi_key, validate_kimi_key,
validate_minimax_key, validate_minimax_key,
validate_moonshot_key, validate_moonshot_key,
validate_novita_key,
validate_nvidia_key, validate_nvidia_key,
validate_openai_key, validate_openai_key,
validate_openrouter_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", ""), config.requesty_api_key or os.environ.get("REQUESTY_API_KEY", ""),
validate_requesty_key, validate_requesty_key,
), ),
"novita": (
"Novita",
config.novita_api_key or os.environ.get("NOVITA_API_KEY", ""),
validate_novita_key,
),
"deepseek": ( "deepseek": (
"DeepSeek", "DeepSeek",
config.deepseek_api_key or os.environ.get("DEEPSEEK_API_KEY", ""), config.deepseek_api_key or os.environ.get("DEEPSEEK_API_KEY", ""),
+4
View File
@@ -322,6 +322,10 @@ def _step_provider(
title="Requesty (aggregator — OpenAI, Anthropic, Gemini, xAI, etc.)", title="Requesty (aggregator — OpenAI, Anthropic, Gemini, xAI, etc.)",
value="requesty", value="requesty",
), ),
Choice(
title="Novita (aggregator — DeepSeek, Qwen, GLM, etc.)",
value="novita",
),
Choice( Choice(
title="OpenAI-compatible (third-party OpenAI endpoint)", title="OpenAI-compatible (third-party OpenAI endpoint)",
value="custom-openai", value="custom-openai",
+51
View File
@@ -423,6 +423,57 @@ def validate_requesty_key(api_key: str) -> tuple[bool, str]:
return False, f"Error: {e}" 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]: def validate_deepseek_key(api_key: str) -> tuple[bool, str]:
"""Validate a DeepSeek API key by making a test request. """Validate a DeepSeek API key by making a test request.
+1
View File
@@ -120,6 +120,7 @@ _PROVIDER_KEY_ATTR = {
"openrouter": "openrouter_api_key", "openrouter": "openrouter_api_key",
"atlascloud": "atlascloud_api_key", "atlascloud": "atlascloud_api_key",
"requesty": "requesty_api_key", "requesty": "requesty_api_key",
"novita": "novita_api_key",
"deepseek": "deepseek_api_key", "deepseek": "deepseek_api_key",
"zhipu": "zhipu_api_key", "zhipu": "zhipu_api_key",
"zhipu-code": "zhipu_api_key", "zhipu-code": "zhipu_api_key",
+4
View File
@@ -172,6 +172,7 @@ class EvoScientistConfig:
openrouter_api_key: str = "" openrouter_api_key: str = ""
atlascloud_api_key: str = "" atlascloud_api_key: str = ""
requesty_api_key: str = "" requesty_api_key: str = ""
novita_api_key: str = ""
deepseek_api_key: str = "" deepseek_api_key: str = ""
zhipu_api_key: str = "" zhipu_api_key: str = ""
volcengine_api_key: str = "" volcengine_api_key: str = ""
@@ -802,6 +803,7 @@ _ENV_MAPPINGS = {
"openrouter_api_key": "OPENROUTER_API_KEY", "openrouter_api_key": "OPENROUTER_API_KEY",
"atlascloud_api_key": "ATLASCLOUD_API_KEY", "atlascloud_api_key": "ATLASCLOUD_API_KEY",
"requesty_api_key": "REQUESTY_API_KEY", "requesty_api_key": "REQUESTY_API_KEY",
"novita_api_key": "NOVITA_API_KEY",
"deepseek_api_key": "DEEPSEEK_API_KEY", "deepseek_api_key": "DEEPSEEK_API_KEY",
"zhipu_api_key": "ZHIPU_API_KEY", "zhipu_api_key": "ZHIPU_API_KEY",
"volcengine_api_key": "VOLCENGINE_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 os.environ["ATLASCLOUD_API_KEY"] = config.atlascloud_api_key
if config.requesty_api_key and not os.environ.get("REQUESTY_API_KEY"): if config.requesty_api_key and not os.environ.get("REQUESTY_API_KEY"):
os.environ["REQUESTY_API_KEY"] = config.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"): if config.deepseek_api_key and not os.environ.get("DEEPSEEK_API_KEY"):
os.environ["DEEPSEEK_API_KEY"] = config.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"): if config.zhipu_api_key and not os.environ.get("ZHIPU_API_KEY"):
+1
View File
@@ -187,6 +187,7 @@ _HOST_TO_PROVIDER: dict[str, str] = {
"api.minimaxi.com": "minimax", "api.minimaxi.com": "minimax",
"api.kimi.com": "kimi", # kimi-coding shares this host "api.kimi.com": "kimi", # kimi-coding shares this host
"openrouter.ai": "openrouter", "openrouter.ai": "openrouter",
"api.novita.ai": "novita",
} }
+3 -3
View File
@@ -3,9 +3,9 @@
This module provides a unified interface for creating chat model instances This module provides a unified interface for creating chat model instances
with support for multiple providers (Anthropic, OpenAI, Google GenAI, Atlas with support for multiple providers (Anthropic, OpenAI, Google GenAI, Atlas
Cloud, MiniMax (Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, Requesty, Cloud, MiniMax (Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, Requesty,
ZhipuAI, Volcengine, DashScope, DashScope-Code, DeepSeek, Ollama, and custom Novita, ZhipuAI, Volcengine, DashScope, DashScope-Code, DeepSeek, Ollama, and
OpenAI/Anthropic-compatible endpoints) and convenient short names for common custom OpenAI/Anthropic-compatible endpoints) and convenient short names for
models. common models.
""" """
from __future__ import annotations from __future__ import annotations
+6
View File
@@ -21,6 +21,7 @@ _ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai/v1"
_MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1" _MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"
_KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/" _KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"
_REQUESTY_BASE_URL = "https://router.requesty.ai/v1" _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. # Providers routed through the OpenAI provider with a custom base_url.
# Maps provider name → (base_url or None, env var for API key). # 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": (_DASHSCOPE_BASE_URL, "DASHSCOPE_API_KEY"),
"dashscope-code": (_DASHSCOPE_CODE_BASE_URL, "DASHSCOPE_API_KEY"), "dashscope-code": (_DASHSCOPE_CODE_BASE_URL, "DASHSCOPE_API_KEY"),
"requesty": (_REQUESTY_BASE_URL, "REQUESTY_API_KEY"), "requesty": (_REQUESTY_BASE_URL, "REQUESTY_API_KEY"),
"novita": (_NOVITA_BASE_URL, "NOVITA_API_KEY"),
"custom-openai": ( "custom-openai": (
None, None,
"CUSTOM_OPENAI_API_KEY", "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"), ("gemini-3.5-flash", "google/gemini-3.5-flash", "requesty"),
("grok-4.3", "xai/grok-4.3", "requesty"), ("grok-4.3", "xai/grok-4.3", "requesty"),
("grok-build-0.1", "xai/grok-build-0.1", "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 # OpenRouter
("claude-fable-5", "anthropic/claude-fable-5", "openrouter"), ("claude-fable-5", "anthropic/claude-fable-5", "openrouter"),
("claude-opus-5", "anthropic/claude-opus-5", "openrouter"), ("claude-opus-5", "anthropic/claude-opus-5", "openrouter"),
+27
View File
@@ -48,6 +48,7 @@ class TestModelsRegistry:
assert "moonshot" in providers assert "moonshot" in providers
assert "kimi-coding" in providers assert "kimi-coding" in providers
assert "atlascloud" in providers assert "atlascloud" in providers
assert "novita" in providers
def test_entries_are_valid_tuples(self): def test_entries_are_valid_tuples(self):
"""Test that _MODEL_ENTRIES contains valid (name, model_id, provider) tuples.""" """Test that _MODEL_ENTRIES contains valid (name, model_id, provider) tuples."""
@@ -72,6 +73,7 @@ class TestModelsRegistry:
"moonshot", "moonshot",
"kimi-coding", "kimi-coding",
"atlascloud", "atlascloud",
"novita",
} }
for entry in _MODEL_ENTRIES: for entry in _MODEL_ENTRIES:
assert len(entry) == 3, f"Entry {entry} doesn't have 3 elements" 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") atlas_models = get_models_for_provider("atlascloud")
assert ("qwen3.5-27b", "qwen/qwen3.5-27b") in atlas_models assert ("qwen3.5-27b", "qwen/qwen3.5-27b") in atlas_models
assert get_models_for_provider("atlas") == [] 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["base_url"] == "https://router.requesty.ai/v1"
assert call_kwargs["api_key"] == "rq-key-123" 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") @patch("EvoScientist.llm.models.init_chat_model")
def test_requesty_anthropic_prompt_cache_enabled_by_default( def test_requesty_anthropic_prompt_cache_enabled_by_default(
self, mock_init, monkeypatch self, mock_init, monkeypatch
+45
View File
@@ -429,6 +429,51 @@ class TestValidateAtlasCloudKey:
assert "inconclusive" in msg.lower() 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) # Test Step Functions (Mocked questionary)
# ============================================================================= # =============================================================================