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. + +

+ Workflow compile static resource manifest +

+ 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.