diff --git a/README.md b/README.md
index 0e990d7..ca4b67d 100644
--- a/README.md
+++ b/README.md
@@ -243,6 +243,14 @@ def forward(self, session: Session) -> Session:
A workflow can chain agents, fork sessions, compress context, call tools, dispatch to child workflows, and return a new session. Because the input and output are both `Session`, workflows can be nested without inventing a new state format at each layer.
+### Workflow Compile — Static Before Runtime
+
+`workflow.compile()` gives large agent systems an inspection and preflight layer before execution. It turns a nested workflow into a concrete `ResourceManifest`, so teams can see every reachable `AgentParam`, provider, memory binding, and dynamic `Selector` point in one place. That manifest makes README diagrams, CI checks, credential validation, and deterministic memory lifecycle management straightforward while preserving the normal `workflow(session)` execution path.
+
+
+
+
+
For routing that depends on the conversation at runtime, `flow.Selector` is an LLM-backed router over self-describing workflows (each carries a `description`). It returns the next workflow to run, or a no-op `flow.EmptyWorkflow` when the task is done — so `if` / `while` stay plain Python:
```python
@@ -284,6 +292,26 @@ export OPENAI_BASE_URL=https://your-gateway/v1
export OPENAI_DEFAULT_MODEL=your-model-name
```
+Atlas Cloud can also be selected as an OpenAI-compatible provider:
+
+```bash
+export ATLASCLOUD_API_KEY=ak-...
+```
+
+```json
+{
+ "llm": {
+ "default_provider": "atlascloud",
+ "providers": {
+ "atlascloud": {
+ "provider_kind": "atlascloud",
+ "model": "qwen/qwen3.5-flash"
+ }
+ }
+ }
+}
+```
+
You can also configure providers in `~/.openrath/config.json`. Environment variables take precedence.
---
diff --git a/README_zh.md b/README_zh.md
index 3082295..6c04891 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -284,6 +284,26 @@ export OPENAI_BASE_URL=https://your-gateway/v1
export OPENAI_DEFAULT_MODEL=your-model-name
```
+Atlas Cloud 也可以作为 OpenAI 兼容 provider 选择:
+
+```bash
+export ATLASCLOUD_API_KEY=ak-...
+```
+
+```json
+{
+ "llm": {
+ "default_provider": "atlascloud",
+ "providers": {
+ "atlascloud": {
+ "provider_kind": "atlascloud",
+ "model": "qwen/qwen3.5-flash"
+ }
+ }
+ }
+}
+```
+
你也可以在 `~/.openrath/config.json` 中配置 providers。环境变量优先级更高。
---
diff --git a/assets/readme/workflow-compile-static-pass.png b/assets/readme/workflow-compile-static-pass.png
new file mode 100644
index 0000000..5ded824
Binary files /dev/null and b/assets/readme/workflow-compile-static-pass.png differ
diff --git a/src/rath/config/env.py b/src/rath/config/env.py
index b1a7f26..8995ab9 100644
--- a/src/rath/config/env.py
+++ b/src/rath/config/env.py
@@ -187,6 +187,17 @@ def env_reference_markdown() -> str:
)
)
+# Atlas Cloud OpenAI-compatible
+_register(EnvSpec("ATLASCLOUD_API_KEY", EnvKind.SECRET, "Atlas Cloud api key"))
+_register(EnvSpec("ATLAS_CLOUD_API_KEY", EnvKind.SECRET, "Atlas Cloud api key alias"))
+_register(
+ EnvSpec(
+ "ATLASCLOUD_DEFAULT_MODEL",
+ EnvKind.ROUTING,
+ "default model for the Atlas Cloud client",
+ )
+)
+
# Azure OpenAI
_register(EnvSpec("AZURE_OPENAI_ENDPOINT", EnvKind.ROUTING, "Azure OpenAI endpoint"))
_register(EnvSpec("AZURE_OPENAI_API_KEY", EnvKind.SECRET, "Azure OpenAI api key"))
diff --git a/src/rath/config/schema.py b/src/rath/config/schema.py
index 8e13ef7..5ccddde 100644
--- a/src/rath/config/schema.py
+++ b/src/rath/config/schema.py
@@ -37,7 +37,7 @@ class LLMProviderConfig(BaseModel):
to ``extra="allow"``.
"""
- provider_kind: Literal["openai", "anthropic", "litellm"] = "openai"
+ provider_kind: Literal["openai", "anthropic", "litellm", "atlascloud"] = "openai"
model: str | None = None
api_key: str | None = None
base_url: str | None = None
diff --git a/src/rath/flow/compile.py b/src/rath/flow/compile.py
index bf688a9..8be0690 100644
--- a/src/rath/flow/compile.py
+++ b/src/rath/flow/compile.py
@@ -235,6 +235,10 @@ def _credential_resolves(kind: str, provider: Provider) -> bool:
# LiteLLM defers credential resolution to its own per-vendor env
# lookups; a missing rath-level key is not necessarily an error.
return True
+ if kind == "atlascloud":
+ from rath.llm.atlascloud import resolve_atlascloud_api_key
+
+ return bool(resolve_atlascloud_api_key(provider))
# openai-compatible (default)
from rath.llm.openai.client import _resolve_api_key, _resolve_base_url
diff --git a/src/rath/llm/__init__.py b/src/rath/llm/__init__.py
index ad245f1..1c06d54 100644
--- a/src/rath/llm/__init__.py
+++ b/src/rath/llm/__init__.py
@@ -17,6 +17,9 @@
# registry must be defined before adapters call register_chat_client. The
# concrete client classes are re-exported here so users can ``from rath.llm
# import RathOpenAIChatClient`` regardless of the underlying layout.
+from rath.llm import (
+ atlascloud as _atlascloud, # noqa: F401 -- registration side-effect
+)
from rath.llm.anthropic import (
RathAnthropicChatClient,
build_anthropic_kwargs,
diff --git a/src/rath/llm/atlascloud.py b/src/rath/llm/atlascloud.py
new file mode 100644
index 0000000..a3235ed
--- /dev/null
+++ b/src/rath/llm/atlascloud.py
@@ -0,0 +1,51 @@
+"""Atlas Cloud OpenAI-compatible adapter registration."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+from rath.config.env import env_value
+from rath.llm.credentials import resolve_credential
+from rath.llm.openai.client import RathOpenAIChatClient
+from rath.llm.provider import Provider
+from rath.llm.registry import register_chat_client
+
+ATLASCLOUD_BASE_URL = "https://api.atlascloud.ai/v1"
+ATLASCLOUD_DEFAULT_MODEL = "qwen/qwen3.5-flash"
+
+
+def bind_atlascloud_provider(provider: Provider) -> Provider:
+ """Fill Atlas Cloud defaults before using the OpenAI-compatible client."""
+ return replace(
+ provider,
+ provider_kind="openai",
+ base_url=provider.base_url or ATLASCLOUD_BASE_URL,
+ api_key=resolve_atlascloud_api_key(provider),
+ model=provider.model or env_value("ATLASCLOUD_DEFAULT_MODEL") or ATLASCLOUD_DEFAULT_MODEL,
+ )
+
+
+def resolve_atlascloud_api_key(provider: Provider) -> str:
+ """Resolve Atlas Cloud credentials from Provider or env."""
+ return resolve_credential(
+ provider.api_key,
+ env_value("ATLASCLOUD_API_KEY"),
+ env_value("ATLAS_CLOUD_API_KEY"),
+ )
+
+
+def atlascloud_chat_client(provider: Provider) -> RathOpenAIChatClient:
+ """Construct an OpenAI-compatible chat client configured for Atlas Cloud."""
+ return RathOpenAIChatClient(bind_atlascloud_provider(provider))
+
+
+register_chat_client("atlascloud", atlascloud_chat_client)
+
+
+__all__ = [
+ "ATLASCLOUD_BASE_URL",
+ "ATLASCLOUD_DEFAULT_MODEL",
+ "atlascloud_chat_client",
+ "bind_atlascloud_provider",
+ "resolve_atlascloud_api_key",
+]
diff --git a/src/rath/llm/provider.py b/src/rath/llm/provider.py
index cf5c614..31b345a 100644
--- a/src/rath/llm/provider.py
+++ b/src/rath/llm/provider.py
@@ -68,7 +68,7 @@ class Provider:
# Which adapter :func:`~rath.llm.registry.chat_client_for` constructs when
# no custom executor is passed. ``None`` (default) selects OpenAI-compatible;
# ``"anthropic"`` selects :class:`~rath.llm.RathAnthropicChatClient`.
- provider_kind: Literal["openai", "anthropic", "litellm"] | None = None
+ provider_kind: Literal["openai", "anthropic", "litellm", "atlascloud"] | None = None
def __str__(self) -> str:
return self.model if self.model is not None else "(no model)"
diff --git a/tests/config/test_provider_from_config.py b/tests/config/test_provider_from_config.py
index ec9a732..86c3083 100644
--- a/tests/config/test_provider_from_config.py
+++ b/tests/config/test_provider_from_config.py
@@ -60,6 +60,21 @@ def test_default_provider_used_when_name_omitted(tmp_path: Path) -> None:
assert p.api_key == "sk-ant-default"
+def test_atlascloud_provider_round_trips_into_provider(tmp_path: Path) -> None:
+ store = _build_store_with(
+ tmp_path,
+ atlas=LLMProviderConfig(
+ provider_kind="atlascloud",
+ model="deepseek-ai/deepseek-v4-pro",
+ api_key="ak-from-config",
+ ),
+ )
+ p = Provider.from_config("atlas", store=store)
+ assert p.provider_kind == "atlascloud"
+ assert p.model == "deepseek-ai/deepseek-v4-pro"
+ assert p.api_key == "ak-from-config"
+
+
def test_explicit_kwargs_override_config_fields(tmp_path: Path) -> None:
store = _build_store_with(
tmp_path,
diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py
index f46d3cc..cf81f63 100644
--- a/tests/config/test_schema.py
+++ b/tests/config/test_schema.py
@@ -104,11 +104,13 @@ def test_llm_provider_rejects_unknown_provider_kind() -> None:
LLMProviderConfig(provider_kind="bogus") # type: ignore[arg-type]
-def test_llm_provider_accepts_openai_and_anthropic() -> None:
+def test_llm_provider_accepts_builtin_provider_kinds() -> None:
a = LLMProviderConfig(provider_kind="openai", model="gpt-5")
b = LLMProviderConfig(provider_kind="anthropic", model="claude-opus-4-7")
+ c = LLMProviderConfig(provider_kind="atlascloud", model="qwen/qwen3.5-flash")
assert a.provider_kind == "openai"
assert b.provider_kind == "anthropic"
+ assert c.provider_kind == "atlascloud"
def test_extra_allow_round_trips_unknown_keys() -> None:
diff --git a/tests/flow/test_compile_validate.py b/tests/flow/test_compile_validate.py
index b6bc93e..0a37523 100644
--- a/tests/flow/test_compile_validate.py
+++ b/tests/flow/test_compile_validate.py
@@ -21,7 +21,7 @@
@pytest.fixture(autouse=True)
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]:
monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home"))
- for v in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
+ for v in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ATLASCLOUD_API_KEY"):
monkeypatch.delenv(v, raising=False)
yield
@@ -45,6 +45,13 @@ def test_validate_flags_missing_credentials() -> None:
assert any("credential" in p.lower() or "api" in p.lower() for p in problems)
+def test_validate_accepts_atlascloud_env_key(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("ATLASCLOUD_API_KEY", "ak-test")
+ wf = _One(Provider(provider_kind="atlascloud", model="qwen/qwen3.5-flash"))
+ problems = wf.compile().validate()
+ assert problems == []
+
+
def test_validate_flags_unknown_provider_kind() -> None:
wf = _One(Provider(provider_kind="openai", model="m", api_key="sk"))
cw = wf.compile()
diff --git a/tests/llm/test_registry.py b/tests/llm/test_registry.py
index 5f4d5b2..e8143ce 100644
--- a/tests/llm/test_registry.py
+++ b/tests/llm/test_registry.py
@@ -24,6 +24,7 @@ def _stub_credentials(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""Both built-in clients require an api key to construct; supply dummies."""
monkeypatch.setenv("OPENAI_API_KEY", "test-key-openai")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key-anthropic")
+ monkeypatch.setenv("ATLASCLOUD_API_KEY", "test-key-atlascloud")
# Make sure no leftover env-var base_url steers Azure routing.
for v in (
"OPENAI_BASE_URL",
@@ -50,6 +51,15 @@ def test_anthropic_provider_kind() -> None:
assert isinstance(client, RathAnthropicChatClient)
+def test_atlascloud_provider_kind_uses_openai_compatible_client() -> None:
+ client = chat_client_for(Provider(provider_kind="atlascloud"))
+ assert isinstance(client, RathOpenAIChatClient)
+ assert client.provider.provider_kind == "openai"
+ assert client.provider.base_url == "https://api.atlascloud.ai/v1"
+ assert client.provider.model == "qwen/qwen3.5-flash"
+ assert client._client.api_key == "test-key-atlascloud"
+
+
def test_unknown_provider_kind_raises_value_error() -> None:
# ``provider_kind`` is typed as a Literal but the registry must still
# produce a useful runtime error for callers that bypass the type.