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
8 changed files with 395 additions and 1 deletions
+1
View File
@@ -20,6 +20,7 @@ class ProviderType(str, Enum):
OLLAMA = "ollama"
ASTRAFLOW = "astraflow"
ASTRAFLOW_CN = "astraflow_cn"
ATLAS = "atlas"
@dataclass(frozen=True)
+2
View File
@@ -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",
+147
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
+2
View File
@@ -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,