Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<p align="center">
<img src="assets/readme/workflow-compile-static-pass.png" alt="Workflow compile static resource manifest" width="860" />
</p>

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
Expand Down Expand Up @@ -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.

---
Expand Down
20 changes: 20 additions & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。环境变量优先级更高。

---
Expand Down
Binary file added assets/readme/workflow-compile-static-pass.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/rath/config/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion src/rath/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/rath/flow/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/rath/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions src/rath/llm/atlascloud.py
Original file line number Diff line number Diff line change
@@ -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",
]
2 changes: 1 addition & 1 deletion src/rath/llm/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
15 changes: 15 additions & 0 deletions tests/config/test_provider_from_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion tests/config/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion tests/flow/test_compile_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions tests/llm/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.
Expand Down