Add Atlas Cloud LLM provider (#388)
* Add Atlas Cloud LLM provider * Add Atlas Cloud onboarding support * fix(validators): update atlascloud key validation to handle insufficient balance case --------- Co-authored-by: binyangzhu000-sudo <224954946+binyangzhu000-sudo@users.noreply.github.com> Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
This commit is contained in:
+2
-1
@@ -18,7 +18,8 @@ KIMI_API_KEY= # kimi.com/code (Kimi 代码计划)
|
||||
# Aggregator platforms (optional)
|
||||
SILICONFLOW_API_KEY= # siliconflow.cn
|
||||
OPENROUTER_API_KEY= # openrouter.ai
|
||||
REQUESTY_API_KEY= # requesty.ai — get a key at app.requesty.ai/api-keys (docs: docs.requesty.ai)
|
||||
REQUESTY_API_KEY= # requesty.ai
|
||||
ATLASCLOUD_API_KEY= # atlascloud.ai
|
||||
|
||||
# Custom endpoints (optional)
|
||||
CUSTOM_OPENAI_API_KEY= # OpenAI-compatible endpoint
|
||||
|
||||
@@ -30,6 +30,7 @@ VALID_PROVIDERS: frozenset[str] = frozenset(
|
||||
"nvidia",
|
||||
"siliconflow",
|
||||
"openrouter",
|
||||
"atlascloud",
|
||||
"requesty",
|
||||
"custom-openai",
|
||||
"custom-anthropic",
|
||||
|
||||
@@ -15,6 +15,7 @@ from ..settings import EvoScientistConfig
|
||||
from .style import QMARK, WIZARD_STYLE, console
|
||||
from .validators import (
|
||||
validate_anthropic_key,
|
||||
validate_atlascloud_key,
|
||||
validate_dashscope_code_key,
|
||||
validate_dashscope_key,
|
||||
validate_deepseek_key,
|
||||
@@ -71,6 +72,11 @@ def _provider_key_info(config: EvoScientistConfig, provider: str):
|
||||
config.openrouter_api_key or os.environ.get("OPENROUTER_API_KEY", ""),
|
||||
validate_openrouter_key,
|
||||
),
|
||||
"atlascloud": (
|
||||
"Atlas Cloud",
|
||||
config.atlascloud_api_key or os.environ.get("ATLASCLOUD_API_KEY", ""),
|
||||
validate_atlascloud_key,
|
||||
),
|
||||
"requesty": (
|
||||
"Requesty",
|
||||
config.requesty_api_key or os.environ.get("REQUESTY_API_KEY", ""),
|
||||
|
||||
@@ -296,6 +296,10 @@ def _step_provider(
|
||||
title="OpenRouter (aggregator — Grok, Gemini, Qwen, etc.)",
|
||||
value="openrouter",
|
||||
),
|
||||
Choice(
|
||||
title="Atlas Cloud (aggregator — DeepSeek, Qwen, etc.)",
|
||||
value="atlascloud",
|
||||
),
|
||||
Choice(
|
||||
title="Requesty (aggregator — OpenAI, Anthropic, Gemini, xAI, etc.)",
|
||||
value="requesty",
|
||||
|
||||
@@ -321,6 +321,49 @@ def validate_openrouter_key(api_key: str) -> tuple[bool, str]:
|
||||
return False, f"Error: {e}"
|
||||
|
||||
|
||||
def validate_atlascloud_key(api_key: str) -> tuple[bool, str]:
|
||||
"""Validate an Atlas Cloud key with a nonexistent sentinel model.
|
||||
|
||||
The probe deliberately targets a nonexistent sentinel model. A 404 means
|
||||
authentication passed and model resolution failed; 200 also confirms
|
||||
authentication if the sentinel unexpectedly resolves. A 401/403 means the
|
||||
key was rejected. Other statuses remain inconclusive until verified.
|
||||
"""
|
||||
if not api_key:
|
||||
return True, "Skipped (no key provided)"
|
||||
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.post(
|
||||
"https://api.atlascloud.ai/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "atlascloud/auth-preflight",
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 1,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code in (200, 404):
|
||||
return True, "Valid"
|
||||
# Atlas checks account balance before model resolution: a valid key
|
||||
# on an uncredited account gets 402 from the sentinel probe.
|
||||
if resp.status_code == 402:
|
||||
return True, "Valid (insufficient balance — top up to use)"
|
||||
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_requesty_key(api_key: str) -> tuple[bool, str]:
|
||||
"""Validate a Requesty API key against the router's auth layer.
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ _PROVIDER_KEY_ATTR = {
|
||||
"google-genai": "google_api_key",
|
||||
"siliconflow": "siliconflow_api_key",
|
||||
"openrouter": "openrouter_api_key",
|
||||
"atlascloud": "atlascloud_api_key",
|
||||
"requesty": "requesty_api_key",
|
||||
"deepseek": "deepseek_api_key",
|
||||
"zhipu": "zhipu_api_key",
|
||||
|
||||
@@ -166,6 +166,7 @@ class EvoScientistConfig:
|
||||
minimax_base_url: str = ""
|
||||
siliconflow_api_key: str = ""
|
||||
openrouter_api_key: str = ""
|
||||
atlascloud_api_key: str = ""
|
||||
requesty_api_key: str = ""
|
||||
deepseek_api_key: str = ""
|
||||
zhipu_api_key: str = ""
|
||||
@@ -761,6 +762,7 @@ _ENV_MAPPINGS = {
|
||||
"minimax_base_url": "MINIMAX_BASE_URL",
|
||||
"siliconflow_api_key": "SILICONFLOW_API_KEY",
|
||||
"openrouter_api_key": "OPENROUTER_API_KEY",
|
||||
"atlascloud_api_key": "ATLASCLOUD_API_KEY",
|
||||
"requesty_api_key": "REQUESTY_API_KEY",
|
||||
"deepseek_api_key": "DEEPSEEK_API_KEY",
|
||||
"zhipu_api_key": "ZHIPU_API_KEY",
|
||||
@@ -939,6 +941,8 @@ def apply_config_to_env(config: EvoScientistConfig) -> None:
|
||||
os.environ["SILICONFLOW_API_KEY"] = config.siliconflow_api_key
|
||||
if config.openrouter_api_key and not os.environ.get("OPENROUTER_API_KEY"):
|
||||
os.environ["OPENROUTER_API_KEY"] = config.openrouter_api_key
|
||||
if config.atlascloud_api_key and not os.environ.get("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"):
|
||||
os.environ["REQUESTY_API_KEY"] = config.requesty_api_key
|
||||
if config.deepseek_api_key and not os.environ.get("DEEPSEEK_API_KEY"):
|
||||
|
||||
@@ -176,6 +176,7 @@ def _redact_api_keys(message: str) -> str:
|
||||
_HOST_TO_PROVIDER: dict[str, str] = {
|
||||
"api.openai.com": "openai",
|
||||
"api.anthropic.com": "anthropic",
|
||||
"api.atlascloud.ai": "atlascloud",
|
||||
"api.deepseek.com": "deepseek",
|
||||
"api.moonshot.cn": "moonshot",
|
||||
"api.siliconflow.cn": "siliconflow",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""LLM model configuration based on LangChain init_chat_model.
|
||||
|
||||
This module provides a unified interface for creating chat model instances
|
||||
with support for multiple providers (Anthropic, OpenAI, Google GenAI, MiniMax
|
||||
(Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, Requesty, ZhipuAI,
|
||||
Volcengine, DashScope, DashScope-Code, DeepSeek, Ollama, and custom
|
||||
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.
|
||||
"""
|
||||
@@ -46,6 +46,7 @@ _VOLCENGINE_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
_DASHSCOPE_CODE_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"
|
||||
|
||||
_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"
|
||||
@@ -107,6 +108,7 @@ def _is_deepseek_endpoint(base_url: str | None) -> bool:
|
||||
# Providers routed through the OpenAI provider with a custom base_url.
|
||||
# Maps provider name → (base_url or None, env var for API key).
|
||||
_OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = {
|
||||
"atlascloud": (_ATLASCLOUD_BASE_URL, "ATLASCLOUD_API_KEY"),
|
||||
"moonshot": (_MOONSHOT_BASE_URL, "MOONSHOT_API_KEY"),
|
||||
"siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"),
|
||||
"zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"),
|
||||
@@ -171,6 +173,8 @@ _MODEL_ENTRIES: list[tuple[str, str, str]] = [
|
||||
("gpt-5.4", "gpt-5.4", "custom-openai"),
|
||||
("gpt-5.3-codex", "gpt-5.3-codex", "custom-openai"),
|
||||
("gpt-5-mini", "gpt-5-mini", "custom-openai"),
|
||||
# Atlas Cloud (OpenAI-compatible)
|
||||
("qwen3.5-27b", "qwen/qwen3.5-27b", "atlascloud"),
|
||||
# Anthropic (current generation)
|
||||
("claude-fable-5", "claude-fable-5", "anthropic"),
|
||||
("claude-opus-5", "claude-opus-5", "anthropic"),
|
||||
|
||||
@@ -57,6 +57,7 @@ def temp_config_dir(tmp_path, monkeypatch):
|
||||
for key in [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ATLASCLOUD_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"EVOSCIENTIST_DEFAULT_MODE",
|
||||
"EVOSCIENTIST_WORKSPACE_DIR",
|
||||
@@ -87,6 +88,7 @@ def clean_env(monkeypatch):
|
||||
for key in [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ATLASCLOUD_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"EVOSCIENTIST_DEFAULT_MODE",
|
||||
"EVOSCIENTIST_WORKSPACE_DIR",
|
||||
@@ -713,6 +715,7 @@ class TestApplyConfigToEnv:
|
||||
config = EvoScientistConfig(
|
||||
anthropic_api_key="config-ant-key",
|
||||
openai_api_key="config-oai-key",
|
||||
atlascloud_api_key="config-atlas-key",
|
||||
tavily_api_key="config-tav-key",
|
||||
)
|
||||
|
||||
@@ -720,6 +723,7 @@ class TestApplyConfigToEnv:
|
||||
|
||||
assert os.environ.get("ANTHROPIC_API_KEY") == "config-ant-key"
|
||||
assert os.environ.get("OPENAI_API_KEY") == "config-oai-key"
|
||||
assert os.environ.get("ATLASCLOUD_API_KEY") == "config-atlas-key"
|
||||
assert os.environ.get("TAVILY_API_KEY") == "config-tav-key"
|
||||
|
||||
def test_does_not_override_existing_env(self, monkeypatch):
|
||||
@@ -738,6 +742,7 @@ class TestApplyConfigToEnv:
|
||||
|
||||
assert os.environ.get("ANTHROPIC_API_KEY") is None
|
||||
assert os.environ.get("OPENAI_API_KEY") is None
|
||||
assert os.environ.get("ATLASCLOUD_API_KEY") is None
|
||||
assert os.environ.get("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE") is None
|
||||
|
||||
def test_openrouter_anthropic_prompt_cache_opt_out_applied(
|
||||
|
||||
@@ -46,6 +46,7 @@ class TestModelsRegistry:
|
||||
assert "deepseek" in providers
|
||||
assert "moonshot" in providers
|
||||
assert "kimi-coding" in providers
|
||||
assert "atlascloud" in providers
|
||||
|
||||
def test_entries_are_valid_tuples(self):
|
||||
"""Test that _MODEL_ENTRIES contains valid (name, model_id, provider) tuples."""
|
||||
@@ -68,6 +69,7 @@ class TestModelsRegistry:
|
||||
"deepseek",
|
||||
"moonshot",
|
||||
"kimi-coding",
|
||||
"atlascloud",
|
||||
}
|
||||
for entry in _MODEL_ENTRIES:
|
||||
assert len(entry) == 3, f"Entry {entry} doesn't have 3 elements"
|
||||
@@ -91,6 +93,9 @@ class TestModelsRegistry:
|
||||
assert len(openrouter_models) > 0
|
||||
siliconflow_models = get_models_for_provider("siliconflow")
|
||||
assert len(siliconflow_models) > 0
|
||||
atlas_models = get_models_for_provider("atlascloud")
|
||||
assert ("qwen3.5-27b", "qwen/qwen3.5-27b") in atlas_models
|
||||
assert get_models_for_provider("atlas") == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -390,6 +395,30 @@ def _sdk_retry_supports_status_codes_override() -> bool:
|
||||
|
||||
|
||||
class TestThirdPartyRouting:
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_atlascloud_routes_through_openai(self, mock_init, monkeypatch):
|
||||
"""Atlas Cloud should use OpenAI-compatible routing with its default URL."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("ATLASCLOUD_API_KEY", "atlas-key-123")
|
||||
|
||||
get_chat_model("qwen3.5-27b", provider="atlascloud")
|
||||
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs["model"] == "qwen/qwen3.5-27b"
|
||||
assert call_kwargs["model_provider"] == "openai"
|
||||
assert call_kwargs["base_url"] == "https://api.atlascloud.ai/v1"
|
||||
assert call_kwargs["api_key"] == "atlas-key-123"
|
||||
assert "reasoning" not in call_kwargs
|
||||
|
||||
def test_atlascloud_host_maps_to_provider(self):
|
||||
"""Provider error envelopes should identify Atlas Cloud by host."""
|
||||
from EvoScientist.llm.errors import _lookup_host_or_compat
|
||||
|
||||
assert (
|
||||
_lookup_host_or_compat("https://api.atlascloud.ai/v1", "openai")
|
||||
== "atlascloud"
|
||||
)
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_siliconflow_routes_through_openai(self, mock_init, monkeypatch):
|
||||
"""SiliconFlow provider should route through OpenAI with correct base_url."""
|
||||
|
||||
@@ -373,6 +373,62 @@ class TestChoiceValidator:
|
||||
assert "one of" in str(exc_info.value.message)
|
||||
|
||||
|
||||
class TestValidateAtlasCloudKey:
|
||||
def test_empty_key_skipped(self):
|
||||
from EvoScientist.config.onboard.validators import validate_atlascloud_key
|
||||
|
||||
is_valid, msg = validate_atlascloud_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_atlascloud_key
|
||||
|
||||
with patch("httpx.post") as mock_post:
|
||||
mock_post.return_value.status_code = status
|
||||
is_valid, msg = validate_atlascloud_key("atlas-key")
|
||||
|
||||
assert is_valid is True
|
||||
assert msg == "Valid"
|
||||
payload = mock_post.call_args.kwargs["json"]
|
||||
assert payload["model"] == "atlascloud/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_atlascloud_key
|
||||
|
||||
with patch("httpx.post") as mock_post:
|
||||
mock_post.return_value.status_code = status
|
||||
is_valid, msg = validate_atlascloud_key("bad-key")
|
||||
|
||||
assert is_valid is False
|
||||
assert msg == "Invalid API key"
|
||||
|
||||
def test_insufficient_balance_means_valid_key(self):
|
||||
"""402 fires before model resolution, so auth passed — key is valid."""
|
||||
from EvoScientist.config.onboard.validators import validate_atlascloud_key
|
||||
|
||||
with patch("httpx.post") as mock_post:
|
||||
mock_post.return_value.status_code = 402
|
||||
is_valid, msg = validate_atlascloud_key("atlas-key")
|
||||
|
||||
assert is_valid is True
|
||||
assert "insufficient balance" in msg.lower()
|
||||
|
||||
@pytest.mark.parametrize("status", [400, 429, 500, 503])
|
||||
def test_transient_status_is_inconclusive(self, status):
|
||||
from EvoScientist.config.onboard.validators import validate_atlascloud_key
|
||||
|
||||
with patch("httpx.post") as mock_post:
|
||||
mock_post.return_value.status_code = status
|
||||
is_valid, msg = validate_atlascloud_key("atlas-key")
|
||||
|
||||
assert is_valid is False
|
||||
assert "inconclusive" in msg.lower()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Step Functions (Mocked questionary)
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user