diff --git a/.env.example b/.env.example index ff09942..407ed0c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/EvoScientist/config/onboard/constants.py b/EvoScientist/config/onboard/constants.py index 2f33cd9..134476e 100644 --- a/EvoScientist/config/onboard/constants.py +++ b/EvoScientist/config/onboard/constants.py @@ -30,6 +30,7 @@ VALID_PROVIDERS: frozenset[str] = frozenset( "nvidia", "siliconflow", "openrouter", + "atlascloud", "requesty", "custom-openai", "custom-anthropic", diff --git a/EvoScientist/config/onboard/helpers.py b/EvoScientist/config/onboard/helpers.py index 0426fca..dc789bd 100644 --- a/EvoScientist/config/onboard/helpers.py +++ b/EvoScientist/config/onboard/helpers.py @@ -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", ""), diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py index 88fbbcd..ac3f164 100644 --- a/EvoScientist/config/onboard/steps.py +++ b/EvoScientist/config/onboard/steps.py @@ -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", diff --git a/EvoScientist/config/onboard/validators.py b/EvoScientist/config/onboard/validators.py index a61ac3d..0335c44 100644 --- a/EvoScientist/config/onboard/validators.py +++ b/EvoScientist/config/onboard/validators.py @@ -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. diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py index 9445e8b..ace7758 100644 --- a/EvoScientist/config/onboard/wizard.py +++ b/EvoScientist/config/onboard/wizard.py @@ -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", diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 842d6fd..6f987a1 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -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"): diff --git a/EvoScientist/llm/errors.py b/EvoScientist/llm/errors.py index d1f1114..4efa264 100644 --- a/EvoScientist/llm/errors.py +++ b/EvoScientist/llm/errors.py @@ -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", diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 6d1fa6a..50f1610 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -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"), diff --git a/tests/test_config.py b/tests/test_config.py index 0301547..ad912e9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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( diff --git a/tests/test_llm.py b/tests/test_llm.py index 6b31380..fb5c935 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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.""" diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 2d7c233..dcc8c88 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -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) # =============================================================================