diff --git a/.env.example b/.env.example index 8169a46b..9195bd4d 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,13 @@ ASTRAFLOW_CN_API_KEY= # ASTRAFLOW_CN_MODEL=gpt-4o-mini # ASTRAFLOW_CN_BASE_URL=https://api.modelverse.cn/v1 +# --- Optional: Atlas Cloud (OpenAI-compatible, 59+ LLM models) --------------- +# Full-modal AI inference platform — LLM / image / video generation +# Docs: https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=everything-claude-code +ATLAS_API_KEY= +# ATLAS_BASE_URL=https://api.atlascloud.ai/v1 +# ATLAS_MODEL=anthropic/claude-sonnet-4.6 + # ─── ECC agent data (multi-harness isolation) ─────────────────────────────── # Memory hooks (sessions, learned skills, aliases, metrics). Default: ~/.claude # Use a separate root when running ECC in Cursor alongside Claude Code: diff --git a/docs/ATLAS-CLOUD-GUIDE.md b/docs/ATLAS-CLOUD-GUIDE.md new file mode 100644 index 00000000..9a919d18 --- /dev/null +++ b/docs/ATLAS-CLOUD-GUIDE.md @@ -0,0 +1,69 @@ +# Atlas Cloud — LLM Provider Guide + +[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=everything-claude-code) is a full-modal AI inference platform providing an OpenAI-compatible API for 59+ LLM models, image generation, and video generation. + +## Configuration + +Set the following environment variables to use Atlas Cloud as your LLM backend: + +```bash +ATLAS_API_KEY= +ATLAS_BASE_URL=https://api.atlascloud.ai/v1 +``` + +Or copy from `.env.example`: + +```bash +cp .env.example .env +# Then fill in ATLAS_API_KEY +``` + +## Install + +ECC can install its managed surfaces into any OpenAI-compatible backend. To use Atlas Cloud with Claude Code (or any ECC-managed harness), set the base URL and API key: + +```bash +export ATLAS_API_KEY=your-key-here +export ATLAS_BASE_URL=https://api.atlascloud.ai/v1 +``` + +## Available Models + +
+All Atlas Cloud LLM models (59+) + +- **Anthropic**: `anthropic/claude-haiku-4.5-20251001`, `anthropic/claude-opus-4.8`, `anthropic/claude-sonnet-4.6` +- **OpenAI**: `openai/gpt-5.4`, `openai/gpt-5.5` +- **Google Gemini**: `google/gemini-3.1-flash-lite`, `google/gemini-3.1-pro-preview`, `google/gemini-3.5-flash` +- **Qwen**: `qwen/qwen2.5-7b-instruct`, `Qwen/Qwen3-235B-A22B-Instruct-2507`, `qwen/qwen3-235b-a22b-thinking-2507`, `qwen/qwen3-30b-a3b`, `Qwen/Qwen3-30B-A3B-Instruct-2507`, `qwen/qwen3-30b-a3b-thinking-2507`, `qwen/qwen3-32b`, `qwen/qwen3-8b`, `Qwen/Qwen3-Coder`, `qwen/qwen3-coder-next`, `qwen/qwen3-max-2026-01-23`, `Qwen/Qwen3-Next-80B-A3B-Instruct`, `Qwen/Qwen3-Next-80B-A3B-Thinking`, `Qwen/Qwen3-VL-235B-A22B-Instruct`, `qwen/qwen3-vl-235b-a22b-thinking`, `qwen/qwen3-vl-30b-a3b-instruct`, `qwen/qwen3-vl-30b-a3b-thinking`, `qwen/qwen3-vl-8b-instruct`, `qwen/qwen3.5-122b-a10b`, `qwen/qwen3.5-27b`, `qwen/qwen3.5-35b-a3b`, `qwen/qwen3.5-397b-a17b`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-plus` +- **DeepSeek**: `deepseek-ai/deepseek-ocr`, `deepseek-ai/deepseek-r1-0528`, `deepseek-ai/DeepSeek-V3-0324`, `deepseek-ai/DeepSeek-V3.1`, `deepseek-ai/DeepSeek-V3.1-Terminus`, `deepseek-ai/deepseek-v3.2`, `deepseek-ai/DeepSeek-V3.2-Exp`, `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` +- **Kimi**: `moonshotai/Kimi-K2-Instruct`, `moonshotai/Kimi-K2-Instruct-0905`, `moonshotai/Kimi-K2-Thinking`, `moonshotai/kimi-k2.5`, `moonshotai/kimi-k2.6` +- **GLM**: `zai-org/GLM-4.6`, `zai-org/glm-4.7`, `zai-org/glm-5`, `zai-org/glm-5-turbo`, `zai-org/glm-5.1`, `zai-org/glm-5v-turbo` +- **MiniMax**: `MiniMaxAI/MiniMax-M2`, `minimaxai/minimax-m2.1`, `minimaxai/minimax-m2.5`, `minimaxai/minimax-m2.7` +- **xAI**: `xai/grok-4.3` +- **KAT**: `kwaipilot/kat-coder-pro-v2` +- **Other**: `owl` + +
+ +## Usage Example + +```python +from openai import OpenAI +import os + +client = OpenAI( + api_key=os.environ["ATLAS_API_KEY"], + base_url=os.environ.get("ATLAS_BASE_URL", "https://api.atlascloud.ai/v1"), +) + +response = client.chat.completions.create( + model="anthropic/claude-sonnet-4.6", + messages=[{"role": "user", "content": "Hello from ECC + Atlas Cloud!"}], +) +print(response.choices[0].message.content) +``` + +## Get API Credits + +Visit [Atlas Cloud Coding Plan](https://www.atlascloud.ai/console/coding-plan) for API credits. diff --git a/src/llm/core/types.py b/src/llm/core/types.py index 07e788bf..84eb7cf8 100644 --- a/src/llm/core/types.py +++ b/src/llm/core/types.py @@ -20,6 +20,7 @@ class ProviderType(str, Enum): OLLAMA = "ollama" ASTRAFLOW = "astraflow" ASTRAFLOW_CN = "astraflow_cn" + ATLAS = "atlas" @dataclass(frozen=True) diff --git a/src/llm/providers/__init__.py b/src/llm/providers/__init__.py index 6775f8ca..3549d1b8 100644 --- a/src/llm/providers/__init__.py +++ b/src/llm/providers/__init__.py @@ -1,6 +1,7 @@ """Provider adapters for multiple LLM backends.""" from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider +from llm.providers.atlas import AtlasProvider from llm.providers.claude import ClaudeProvider from llm.providers.openai import OpenAIProvider from llm.providers.ollama import OllamaProvider @@ -9,6 +10,7 @@ from llm.providers.resolver import get_provider, register_provider __all__ = ( "AstraflowCNProvider", "AstraflowProvider", + "AtlasProvider", "ClaudeProvider", "OpenAIProvider", "OllamaProvider", diff --git a/src/llm/providers/atlas.py b/src/llm/providers/atlas.py new file mode 100644 index 00000000..b266bb61 --- /dev/null +++ b/src/llm/providers/atlas.py @@ -0,0 +1,147 @@ +"""Atlas Cloud OpenAI-compatible provider adapter.""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from openai import OpenAI + +from llm.core.interface import ( + AuthenticationError, + ContextLengthError, + LLMProvider, + RateLimitError, +) +from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, ToolCall +from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR + +ATLAS_BASE_URL = "https://api.atlascloud.ai/v1" +DEFAULT_ATLAS_MODEL = "deepseek-ai/deepseek-v4-pro" +# Reasoning models need enough headroom for their thinking budget plus the answer. +DEFAULT_ATLAS_MAX_TOKENS = 512 + + +def _parse_tool_arguments(raw_arguments: str | None) -> dict[str, Any]: + if not raw_arguments: + return {} + + try: + arguments = json.loads(raw_arguments) + except json.JSONDecodeError: + return {"raw": raw_arguments} + + if isinstance(arguments, dict): + return arguments + return {"value": arguments} + + +class AtlasProvider(LLMProvider): + """Atlas Cloud endpoint using OpenAI-compatible chat completions. + + Atlas Cloud (https://atlascloud.ai) exposes 300+ hosted models behind a + single OpenAI-compatible API, so it reuses the same chat-completions flow as + the other OpenAI-compatible adapters in this package. + """ + + provider_type = ProviderType.ATLAS + # ``.env.example`` documents ATLAS_API_KEY; ATLASCLOUD_API_KEY is the name used + # by the Atlas Cloud SDK/skill, so accept either for convenience. + api_key_env = "ATLAS_API_KEY" + fallback_api_key_env = "ATLASCLOUD_API_KEY" + base_url_env = "ATLAS_BASE_URL" + model_env = "ATLAS_MODEL" + default_base_url = ATLAS_BASE_URL + + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + default_model: str | None = None, + ) -> None: + self.api_key = ( + api_key + or os.environ.get(self.api_key_env) + or os.environ.get(self.fallback_api_key_env) + or "" + ) + self.base_url = base_url or os.environ.get(self.base_url_env, self.default_base_url) + env_model = os.environ.get(self.model_env) + self.default_model = default_model or env_model or DEFAULT_ATLAS_MODEL + self.client = OpenAI(api_key=self.api_key, base_url=self.base_url, _enforce_credentials=False) + self._models = [ + ModelInfo( + name=self.default_model, + provider=self.provider_type, + supports_tools=True, + supports_vision=False, + ) + ] + + def generate(self, llm_input: LLMInput) -> LLMOutput: + try: + params: dict[str, Any] = { + "model": llm_input.model or self.default_model, + "messages": [msg.to_dict() for msg in llm_input.messages], + } + if llm_input.temperature != 1.0: + params["temperature"] = llm_input.temperature + # Atlas reasoning models spend tokens on a thinking budget before the + # answer, so floor max_tokens to avoid truncated/empty completions. + max_tokens = llm_input.max_tokens + if max_tokens is None or max_tokens < DEFAULT_ATLAS_MAX_TOKENS: + max_tokens = DEFAULT_ATLAS_MAX_TOKENS + params["max_tokens"] = max_tokens + if llm_input.tools: + params["tools"] = [tool.to_openai_tool() for tool in llm_input.tools] + + response = self.client.chat.completions.create(**params) + if not response.choices or response.choices[0].message is None: + raise ValueError(EMPTY_FILTERED_RESPONSE_ERROR) + choice = response.choices[0] + + tool_calls = None + if choice.message.tool_calls: + tool_calls = [ + ToolCall( + id=tc.id or "", + name=tc.function.name, + arguments=_parse_tool_arguments(tc.function.arguments), + ) + for tc in choice.message.tool_calls + ] + + usage = None + if response.usage: + usage = { + "prompt_tokens": response.usage.prompt_tokens, + "completion_tokens": response.usage.completion_tokens, + "total_tokens": response.usage.total_tokens, + } + + return LLMOutput( + content=choice.message.content or "", + tool_calls=tool_calls, + model=response.model, + usage=usage, + stop_reason=choice.finish_reason, + ) + except Exception as e: + msg = str(e) + if "401" in msg or "authentication" in msg.lower(): + raise AuthenticationError(msg, provider=self.provider_type) from e + if "429" in msg or "rate_limit" in msg.lower(): + raise RateLimitError(msg, provider=self.provider_type) from e + if "context" in msg.lower() and "length" in msg.lower(): + raise ContextLengthError(msg, provider=self.provider_type) from e + raise + + def list_models(self) -> list[ModelInfo]: + return self._models.copy() + + def validate_config(self) -> bool: + return bool(self.api_key) + + def get_default_model(self) -> str: + return self.default_model diff --git a/src/llm/providers/resolver.py b/src/llm/providers/resolver.py index 0e3d1b23..f8a5075e 100644 --- a/src/llm/providers/resolver.py +++ b/src/llm/providers/resolver.py @@ -8,6 +8,7 @@ from pathlib import Path from llm.core.interface import LLMProvider from llm.core.types import ProviderType from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider +from llm.providers.atlas import AtlasProvider from llm.providers.claude import ClaudeProvider from llm.providers.openai import OpenAIProvider from llm.providers.ollama import OllamaProvider @@ -16,6 +17,7 @@ from llm.providers.ollama import OllamaProvider _PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = { ProviderType.ASTRAFLOW: AstraflowProvider, ProviderType.ASTRAFLOW_CN: AstraflowCNProvider, + ProviderType.ATLAS: AtlasProvider, ProviderType.CLAUDE: ClaudeProvider, ProviderType.OPENAI: OpenAIProvider, ProviderType.OLLAMA: OllamaProvider, diff --git a/tests/test_atlas_provider.py b/tests/test_atlas_provider.py new file mode 100644 index 00000000..404e8f70 --- /dev/null +++ b/tests/test_atlas_provider.py @@ -0,0 +1,161 @@ +from types import SimpleNamespace + +from llm.core.types import LLMInput, Message, ProviderType, Role, ToolCall, ToolDefinition +from llm.providers.atlas import ATLAS_BASE_URL, DEFAULT_ATLAS_MAX_TOKENS, DEFAULT_ATLAS_MODEL, AtlasProvider + + +def _tool() -> ToolDefinition: + return ToolDefinition( + name="search", + description="Search", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + ) + + +class _Completions: + def __init__(self, response: SimpleNamespace) -> None: + self.params = None + self.response = response + + def create(self, **params): + self.params = params + return self.response + + +class _Client: + def __init__(self, response: SimpleNamespace) -> None: + self.completions = _Completions(response) + self.chat = SimpleNamespace(completions=self.completions) + + +def _response(**overrides) -> SimpleNamespace: + message = SimpleNamespace(content="ok", tool_calls=None) + choice = SimpleNamespace(message=message, finish_reason="stop") + defaults = { + "choices": [choice], + "model": DEFAULT_ATLAS_MODEL, + "usage": SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3), + } + defaults.update(overrides) + return SimpleNamespace(**defaults) + + +def test_atlas_provider_defaults_to_atlas_cloud_endpoint(monkeypatch): + monkeypatch.delenv("ATLAS_API_KEY", raising=False) + monkeypatch.delenv("ATLASCLOUD_API_KEY", raising=False) + monkeypatch.delenv("ATLAS_BASE_URL", raising=False) + monkeypatch.delenv("ATLAS_MODEL", raising=False) + + provider = AtlasProvider() + + assert provider.provider_type == ProviderType.ATLAS + assert provider.base_url == ATLAS_BASE_URL + assert provider.get_default_model() == DEFAULT_ATLAS_MODEL + assert provider.validate_config() is False + + +def test_atlas_provider_reads_env_overrides(monkeypatch): + monkeypatch.setenv("ATLAS_API_KEY", "atlas-key") + monkeypatch.setenv("ATLAS_MODEL", "deepseek-ai/deepseek-v3.2") + + provider = AtlasProvider() + + assert provider.get_default_model() == "deepseek-ai/deepseek-v3.2" + assert provider.validate_config() is True + + +def test_atlas_provider_accepts_atlascloud_api_key_fallback(monkeypatch): + monkeypatch.delenv("ATLAS_API_KEY", raising=False) + monkeypatch.setenv("ATLASCLOUD_API_KEY", "atlascloud-key") + + provider = AtlasProvider() + + assert provider.api_key == "atlascloud-key" + assert provider.validate_config() is True + + +def test_atlas_provider_generates_openai_compatible_chat_completion(): + provider = AtlasProvider(api_key="test", default_model=DEFAULT_ATLAS_MODEL) + client = _Client(_response(model=DEFAULT_ATLAS_MODEL)) + provider.client = client + + output = provider.generate( + LLMInput( + messages=[Message(role=Role.USER, content="hi")], + max_tokens=1024, + tools=[_tool()], + ) + ) + + assert output.content == "ok" + assert output.model == DEFAULT_ATLAS_MODEL + assert output.usage == {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3} + assert client.completions.params["model"] == DEFAULT_ATLAS_MODEL + assert client.completions.params["max_tokens"] == 1024 + assert "temperature" not in client.completions.params + assert client.completions.params["tools"] == [ + { + "type": "function", + "function": { + "name": "search", + "description": "Search", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}}, + "strict": True, + }, + } + ] + + +def test_atlas_provider_floors_max_tokens_for_reasoning_models(): + provider = AtlasProvider(api_key="test") + client = _Client(_response()) + provider.client = client + + # No max_tokens supplied -> floored to the reasoning default. + provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")])) + assert client.completions.params["max_tokens"] == DEFAULT_ATLAS_MAX_TOKENS + + # Too-small max_tokens is also raised to the floor. + provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], max_tokens=16)) + assert client.completions.params["max_tokens"] == DEFAULT_ATLAS_MAX_TOKENS + + +def test_atlas_provider_forwards_non_default_temperature(): + provider = AtlasProvider(api_key="test") + client = _Client(_response()) + provider.client = client + + provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")], temperature=0.2)) + + assert client.completions.params["temperature"] == 0.2 + + +def test_atlas_provider_parses_tool_calls(): + provider = AtlasProvider(api_key="test") + tool_call = SimpleNamespace( + id="call_1", + function=SimpleNamespace(name="search", arguments='{"query":"atlas"}'), + ) + message = SimpleNamespace(content="", tool_calls=[tool_call]) + client = _Client(_response(choices=[SimpleNamespace(message=message, finish_reason="tool_calls")], usage=None)) + provider.client = client + + output = provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")])) + + assert output.tool_calls == [ToolCall(id="call_1", name="search", arguments={"query": "atlas"})] + assert output.usage is None + + +def test_atlas_provider_preserves_malformed_tool_arguments(): + provider = AtlasProvider(api_key="test") + tool_call = SimpleNamespace( + id="call_1", + function=SimpleNamespace(name="search", arguments="{not-json"), + ) + message = SimpleNamespace(content="", tool_calls=[tool_call]) + client = _Client(_response(choices=[SimpleNamespace(message=message, finish_reason="tool_calls")])) + provider.client = client + + output = provider.generate(LLMInput(messages=[Message(role=Role.USER, content="hi")])) + + assert output.tool_calls == [ToolCall(id="call_1", name="search", arguments={"raw": "{not-json"})] diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 29f743e4..e47a7282 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -1,6 +1,6 @@ import pytest from llm.core.types import ProviderType -from llm.providers import AstraflowCNProvider, AstraflowProvider, ClaudeProvider, OpenAIProvider, OllamaProvider, get_provider +from llm.providers import AstraflowCNProvider, AstraflowProvider, AtlasProvider, ClaudeProvider, OpenAIProvider, OllamaProvider, get_provider class TestGetProvider: @@ -29,6 +29,11 @@ class TestGetProvider: assert isinstance(provider, AstraflowCNProvider) assert provider.provider_type == ProviderType.ASTRAFLOW_CN + def test_get_atlas_provider(self): + provider = get_provider("atlas") + assert isinstance(provider, AtlasProvider) + assert provider.provider_type == ProviderType.ATLAS + def test_get_provider_by_enum(self): provider = get_provider(ProviderType.CLAUDE) assert isinstance(provider, ClaudeProvider)