feat: add Atlas Cloud as LLM/AI provider (#2279)

* feat: add Atlas Cloud as OpenAI-compatible LLM provider

- Add Atlas Cloud env vars to .env.example (ATLAS_API_KEY, ATLAS_BASE_URL)
- Add docs/ATLAS-CLOUD-GUIDE.md with configuration, model list, and usage example
- Atlas Cloud provides 59+ LLM models via OpenAI-compatible API at https://api.atlascloud.ai/v1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(atlascloud): add Atlas Cloud provider implementation

Wire Atlas Cloud in as a first-class OpenAI-compatible LLM provider,
complementing the existing .env.example/docs entries.

- src/llm/providers/atlas.py: AtlasProvider adapter (base_url
  https://api.atlascloud.ai/v1, default model deepseek-ai/deepseek-v4-pro);
  floors max_tokens to 512 for reasoning models; reads ATLAS_API_KEY
  (falls back to ATLASCLOUD_API_KEY), ATLAS_BASE_URL, ATLAS_MODEL
- src/llm/core/types.py: add ProviderType.ATLAS
- providers __init__/resolver: export + register AtlasProvider
- tests: test_atlas_provider.py + resolver coverage for "atlas"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaszhu-hue 2026-06-19 04:29:11 +08:00 committed by GitHub
parent ceca28852e
commit 71792fda81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 395 additions and 1 deletions

View File

@ -30,6 +30,13 @@ ASTRAFLOW_CN_API_KEY=
# ASTRAFLOW_CN_MODEL=gpt-4o-mini # ASTRAFLOW_CN_MODEL=gpt-4o-mini
# ASTRAFLOW_CN_BASE_URL=https://api.modelverse.cn/v1 # 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) ─────────────────────────────── # ─── ECC agent data (multi-harness isolation) ───────────────────────────────
# Memory hooks (sessions, learned skills, aliases, metrics). Default: ~/.claude # Memory hooks (sessions, learned skills, aliases, metrics). Default: ~/.claude
# Use a separate root when running ECC in Cursor alongside Claude Code: # Use a separate root when running ECC in Cursor alongside Claude Code:

69
docs/ATLAS-CLOUD-GUIDE.md Normal file
View File

@ -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=<your-atlascloud-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
<details>
<summary>All Atlas Cloud LLM models (59+)</summary>
- **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`
</details>
## 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.

View File

@ -20,6 +20,7 @@ class ProviderType(str, Enum):
OLLAMA = "ollama" OLLAMA = "ollama"
ASTRAFLOW = "astraflow" ASTRAFLOW = "astraflow"
ASTRAFLOW_CN = "astraflow_cn" ASTRAFLOW_CN = "astraflow_cn"
ATLAS = "atlas"
@dataclass(frozen=True) @dataclass(frozen=True)

View File

@ -1,6 +1,7 @@
"""Provider adapters for multiple LLM backends.""" """Provider adapters for multiple LLM backends."""
from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider
from llm.providers.atlas import AtlasProvider
from llm.providers.claude import ClaudeProvider from llm.providers.claude import ClaudeProvider
from llm.providers.openai import OpenAIProvider from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider from llm.providers.ollama import OllamaProvider
@ -9,6 +10,7 @@ from llm.providers.resolver import get_provider, register_provider
__all__ = ( __all__ = (
"AstraflowCNProvider", "AstraflowCNProvider",
"AstraflowProvider", "AstraflowProvider",
"AtlasProvider",
"ClaudeProvider", "ClaudeProvider",
"OpenAIProvider", "OpenAIProvider",
"OllamaProvider", "OllamaProvider",

147
src/llm/providers/atlas.py Normal file
View File

@ -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

View File

@ -8,6 +8,7 @@ from pathlib import Path
from llm.core.interface import LLMProvider from llm.core.interface import LLMProvider
from llm.core.types import ProviderType from llm.core.types import ProviderType
from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider from llm.providers.astraflow import AstraflowCNProvider, AstraflowProvider
from llm.providers.atlas import AtlasProvider
from llm.providers.claude import ClaudeProvider from llm.providers.claude import ClaudeProvider
from llm.providers.openai import OpenAIProvider from llm.providers.openai import OpenAIProvider
from llm.providers.ollama import OllamaProvider from llm.providers.ollama import OllamaProvider
@ -16,6 +17,7 @@ from llm.providers.ollama import OllamaProvider
_PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = { _PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = {
ProviderType.ASTRAFLOW: AstraflowProvider, ProviderType.ASTRAFLOW: AstraflowProvider,
ProviderType.ASTRAFLOW_CN: AstraflowCNProvider, ProviderType.ASTRAFLOW_CN: AstraflowCNProvider,
ProviderType.ATLAS: AtlasProvider,
ProviderType.CLAUDE: ClaudeProvider, ProviderType.CLAUDE: ClaudeProvider,
ProviderType.OPENAI: OpenAIProvider, ProviderType.OPENAI: OpenAIProvider,
ProviderType.OLLAMA: OllamaProvider, ProviderType.OLLAMA: OllamaProvider,

View File

@ -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"})]

View File

@ -1,6 +1,6 @@
import pytest import pytest
from llm.core.types import ProviderType 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: class TestGetProvider:
@ -29,6 +29,11 @@ class TestGetProvider:
assert isinstance(provider, AstraflowCNProvider) assert isinstance(provider, AstraflowCNProvider)
assert provider.provider_type == ProviderType.ASTRAFLOW_CN 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): def test_get_provider_by_enum(self):
provider = get_provider(ProviderType.CLAUDE) provider = get_provider(ProviderType.CLAUDE)
assert isinstance(provider, ClaudeProvider) assert isinstance(provider, ClaudeProvider)