diff --git a/.github/workflows/ci-test-opensandbox.yml b/.github/workflows/ci-test-opensandbox.yml index ffd5524..414c99a 100644 --- a/.github/workflows/ci-test-opensandbox.yml +++ b/.github/workflows/ci-test-opensandbox.yml @@ -10,6 +10,7 @@ on: - 'tests/backends/**' - 'tests/conformance/**' - 'tests/session/**' + - 'scripts/ci_opensandbox_prepare.sh' - 'pyproject.toml' - 'uv.lock' pull_request: @@ -20,6 +21,7 @@ on: - 'tests/backends/**' - 'tests/conformance/**' - 'tests/session/**' + - 'scripts/ci_opensandbox_prepare.sh' - 'pyproject.toml' - 'uv.lock' @@ -30,9 +32,6 @@ jobs: test-opensandbox: name: pytest (opensandbox) runs-on: ubuntu-latest - # OpenSandbox tests require a running server; allow failure in PRs - # until the CI environment is verified stable. - continue-on-error: ${{ github.event_name == 'pull_request' }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 @@ -50,7 +49,10 @@ jobs: export OPENSANDBOX_INSECURE_SERVER=YES uv run opensandbox-server init-config --example docker .sandbox.toml - name: Pre-pull sandbox image - run: docker pull opensandbox/code-interpreter:v1.0.2 + run: | + IMAGE="$(uv run python -c 'from rath.backend.opensandbox import OpenSandboxBackend; print(OpenSandboxBackend._DEFAULT_IMAGE)')" + echo "Pre-pulling ${IMAGE}" + docker pull "${IMAGE}" - name: Start OpenSandbox server run: | export OPENSANDBOX_INSECURE_SERVER=YES @@ -65,9 +67,7 @@ jobs: done echo "OpenSandbox server failed to start" >&2 exit 1 + - name: Warm up OpenSandbox + run: bash scripts/ci_opensandbox_prepare.sh - name: Run OpenSandbox tests - # Backend tests hit a real Docker daemon; the server can race - # stdout capture against exit_code on small jobs. Allow each - # test up to 2 reruns to ride out transient infra flakes - # (pytest-rerunfailures is a dev dep). - run: uv run pytest -m opensandbox --reruns 2 --reruns-delay 2 + run: uv run pytest -m opensandbox diff --git a/.github/workflows/ci-test-openviking.yml b/.github/workflows/ci-test-openviking.yml index b7c2d4c..a26fd86 100644 --- a/.github/workflows/ci-test-openviking.yml +++ b/.github/workflows/ci-test-openviking.yml @@ -42,6 +42,11 @@ jobs: - uses: astral-sh/setup-uv@v5 with: python-version: '3.12' + # When repository secrets are absent we skip uv sync; without this + # the post-job cache prune fails because no cache dir was created. + prune-cache: false + - name: Install dev dependencies + run: uv sync --dev --frozen - name: Check OpenViking credentials id: creds env: @@ -57,7 +62,7 @@ jobs: fi - name: Install OpenViking SDK if: steps.creds.outputs.available == 'true' - run: uv sync --extra openviking --frozen + run: uv sync --dev --extra openviking --frozen - name: Start OpenViking server if: steps.creds.outputs.available == 'true' env: diff --git a/README.md b/README.md index 2f4686e..0e990d7 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,7 @@ python example/01_hello_agent.py | 09 | [`09_memory.py`](example/09_memory.py) | Use the local memory backend to remember, recall, and optionally commit a live turn. | no | | 10 | [`10_provider_variation.py`](example/10_provider_variation.py) | Swap model vendors by changing `Provider`, while keeping Session and Workflow code stable. | yes | | 11 | [`11_dynamic_selector.py`](example/11_dynamic_selector.py) | Route between self-describing workflows with `flow.Selector`: `if` branching and a `while` loop that ends on `flow.EmptyWorkflow`. | yes | +| 12 | [`12_compile.py`](example/12_compile.py) | Statically `compile()` a workflow: inspect its resource manifest, run offline `validate()`, and use the lifecycle context manager. | no | Read [`example/README.md`](example/README.md) for setup details and shared helpers. diff --git a/README_zh.md b/README_zh.md index 7d2ded2..3082295 100644 --- a/README_zh.md +++ b/README_zh.md @@ -311,6 +311,7 @@ python example/01_hello_agent.py | 09 | [`09_memory.py`](example/09_memory.py) | 使用本地 memory 后端进行 remember、recall,并可选地 commit 一个真实回合。 | 否 | | 10 | [`10_provider_variation.py`](example/10_provider_variation.py) | 通过更改 `Provider` 切换模型厂商,同时保持 Session 和 Workflow 代码稳定。 | 是 | | 11 | [`11_dynamic_selector.py`](example/11_dynamic_selector.py) | 使用 `flow.Selector` 在自描述的 workflows 之间路由:`if` 分支和一个在 `flow.EmptyWorkflow` 时结束的 `while` 循环。 | 是 | +| 12 | [`12_compile.py`](example/12_compile.py) | 静态 `compile()` 一个 workflow:查看其资源清单、离线 `validate()`、并使用生命周期上下文管理器。 | 否 | 阅读 [`example/README.md`](example/README.md) 获取设置细节和共享 helpers。 diff --git a/example/12_compile.py b/example/12_compile.py new file mode 100644 index 0000000..2db008d --- /dev/null +++ b/example/12_compile.py @@ -0,0 +1,90 @@ +"""12 · Workflow compile — static resource manifest & lifecycle (no LLM key). + +`Workflow.compile()` is OpenRath's torch-like static pass: before a workflow +runs, it walks the module tree and produces a `ResourceManifest` of every +reachable provider, memory binding, and agent — plus any `Selector` nodes, +recorded as *dynamic* (their runtime routing is decided by the model, never +guessed). Use it to: + + * inspect the static graph (`cw.manifest`, `repr(cw)`), + * fail fast before a run (`cw.validate()` — offline, no model call), + * acquire/release planned resources deterministically (`with wf.compile():`). + +Compiling runs no model and materializes no session, so this needs **no key**. + +Run: + python example/12_compile.py +""" + +from __future__ import annotations + +from rath import flow +from rath.flow.agent_param import AgentParam +from rath.llm import Provider +from rath.session import Session + + +class ResearchTeam(flow.Workflow): + """A tiny nested workflow: a coordinator with two specialist sub-agents.""" + + def __init__(self) -> None: + super().__init__(description="research team") + # Nested Workflow children register into the module tree (torch-like). + self.triage = _Specialist("Triage quickly.", "gpt-5.5") + self.deep = _Specialist("Answer in depth.", "claude-sonnet-4-6") + # A leaf AgentParam registered directly on this workflow. + self.summarizer = AgentParam( + agent_session=Session.from_agent_prompt("Summarize the team's findings."), + provider=Provider(model="gpt-5.5", api_key="sk-example"), + ) + + def forward(self, session: Session) -> Session: # pragma: no cover - demo only + return session + + +class _Specialist(flow.Workflow): + def __init__(self, prompt: str, model: str) -> None: + super().__init__(description=prompt) + self.agent = AgentParam( + agent_session=Session.from_agent_prompt(prompt), + provider=Provider(model=model, api_key="sk-example"), + ) + + def forward(self, session: Session) -> Session: # pragma: no cover - demo only + return session + + +def main() -> None: + team = ResearchTeam() + + # 1) Compile: a static pass over the module tree. No model runs. + compiled = team.compile() + print("Compiled workflow:") + print(repr(compiled)) + + # 2) Inspect the static resource manifest. + manifest = compiled.manifest + print("\nReachable provider models:", manifest.provider_models()) + print("Agents:") + for agent in manifest.agents: + print( + f" - {agent.path}: model={agent.provider.model} memory={agent.has_memory}" + ) + if manifest.dynamic_nodes: + print("Dynamic nodes (runtime-decided):") + for node in manifest.dynamic_nodes: + print(f" - {node.path} [{node.kind}]: {node.reason}") + + # 3) Pre-flight validation — offline, before any run. + problems = compiled.validate() + print("\nvalidate() problems:", problems or "none — ready to run") + + # 4) Deterministic resource lifecycle. Bound memory stores (none here) are + # acquired on enter and released on exit. + with team.compile() as live: + print(f"\nInside lifecycle: {len(live.manifest.agents)} agents ready.") + print("Resources released on exit.") + + +if __name__ == "__main__": + main() diff --git a/example/README.md b/example/README.md index 7869a9a..546c32d 100644 --- a/example/README.md +++ b/example/README.md @@ -41,6 +41,8 @@ python example/01_hello_agent.py | 08 | [08_compress.py](08_compress.py) | `flow.Compressor` to shrink context | yes | | 09 | [09_memory.py](09_memory.py) | `flow.Agent(memory=...)`: remember / recall / commit | **no**\* | | 10 | [10_provider_variation.py](10_provider_variation.py) | swap the LLM vendor via `Provider` | yes | +| 11 | [11_dynamic_selector.py](11_dynamic_selector.py) | `flow.Selector` — LLM-routed `if` / `while` over workflows | yes | +| 12 | [12_compile.py](12_compile.py) | `Workflow.compile()` — static resource manifest, offline `validate()`, lifecycle | **no** | \* 09 runs key-free using the local memory backend; a key only unlocks an optional live turn at the end. @@ -57,6 +59,8 @@ OpenRath borrows PyTorch's shape. The ladder walks the same analogy: | kernel / op | tool (`FlowToolCall`) | 04, 05, 06 | | `nn.Parameter` | `flow.AgentParam` / `Provider` | 01, 10 | | `nn.Module` | `flow.Agent` / `flow.Workflow` | 01, 08 | +| control flow | `flow.Selector` | 11 | +| `torch.compile` | `Workflow.compile()` | 12 | ## Shared helpers (`_shared/`) diff --git a/pyproject.toml b/pyproject.toml index 6f3d2cb..1ac6aee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openrath" -version = "1.2.2" +version = "1.3.0" description = "An open-source, torch-like API framework for dynamic multi-agent workflow." readme = "README.md" requires-python = ">=3.10,<3.14" @@ -34,12 +34,12 @@ litellm = [ "litellm>=1.80,<1.88", ] opensandbox = [ - "opensandbox>=0.1.7", + "opensandbox>=0.1.13", "opensandbox-code-interpreter>=0.1.2", - "opensandbox-server>=0.1.12", + "opensandbox-server>=0.2.1", ] openviking = [ - "openviking>=0.2.6", + "openviking>=0.4.7", ] [tool.ruff] diff --git a/scripts/ci_opensandbox_prepare.sh b/scripts/ci_opensandbox_prepare.sh new file mode 100644 index 0000000..a71557d --- /dev/null +++ b/scripts/ci_opensandbox_prepare.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# CI-only: create and close one sandbox so pytest never hits cold-start create. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${ROOT_DIR}" + +export OPENSANDBOX_INSECURE_SERVER="${OPENSANDBOX_INSECURE_SERVER:-YES}" + +echo "Warming up OpenSandbox (create + close one sandbox)..." +uv run python -c " +from rath.backend import get + +backend = get('opensandbox') +sandbox = backend.open() +try: + print(f'warm-up ok: {sandbox.handle}') +finally: + backend.close(sandbox) +" diff --git a/src/rath/_async/aanthropic.py b/src/rath/_async/aanthropic.py index 3e4f6a1..6b689ec 100644 --- a/src/rath/_async/aanthropic.py +++ b/src/rath/_async/aanthropic.py @@ -10,36 +10,37 @@ from __future__ import annotations -import os from typing import Any from anthropic import AsyncAnthropic from rath._async.aretry import aretry_with_backoff +from rath.config.env import env_value from rath.llm.anthropic.client import ( ANTHROPIC_RETRYABLE, _config_provider_entry, + _resolve_anthropic_base_url, + _resolve_anthropic_key, ) from rath.llm.anthropic.create_kwargs import build_anthropic_kwargs from rath.llm.anthropic.normalize import normalize_anthropic_response from rath.llm.chat_request import RathLLMChatRequest from rath.llm.chat_response import RathLLMChatResponse -from rath.llm.credentials import resolve_credential from rath.llm.provider import Provider __all__ = ["RathAnthropicAsyncChatClient"] +# The async client shares the sync resolvers (Provider > env > config); these +# aliases give the async module its own named entry points for tests/clarity. +_resolve_async_anthropic_key = _resolve_anthropic_key +_resolve_async_anthropic_base_url = _resolve_anthropic_base_url + class RathAnthropicAsyncChatClient: """Async client around ``anthropic.AsyncAnthropic().messages.create``.""" def __init__(self, provider: Provider) -> None: - entry = _config_provider_entry() if not provider.api_key else None - key = resolve_credential( - provider.api_key, - os.environ.get("ANTHROPIC_API_KEY"), - getattr(entry, "api_key", None), - ) + key = _resolve_async_anthropic_key(provider) if not key: raise ValueError( "No Anthropic api_key found: Provider.api_key is empty, " @@ -48,11 +49,7 @@ def __init__(self, provider: Provider) -> None: ) self._provider = provider init_kw: dict[str, Any] = {"api_key": key} - bu = resolve_credential( - provider.base_url, - os.environ.get("ANTHROPIC_BASE_URL"), - getattr(entry, "base_url", None), - ) + bu = _resolve_async_anthropic_base_url(provider) if bu: init_kw["base_url"] = bu self._client = AsyncAnthropic(**init_kw) @@ -65,7 +62,7 @@ async def acomplete(self, req: RathLLMChatRequest) -> RathLLMChatResponse: """Run ``messages.create`` (async) and normalize the response.""" default_model = ( self._provider.model - or os.environ.get("ANTHROPIC_DEFAULT_MODEL") + or env_value("ANTHROPIC_DEFAULT_MODEL") or getattr(_config_provider_entry(), "model", None) ) kwargs = build_anthropic_kwargs(req, default_model=default_model) diff --git a/src/rath/_async/aopenai.py b/src/rath/_async/aopenai.py index bbc0d7d..e79cdb6 100644 --- a/src/rath/_async/aopenai.py +++ b/src/rath/_async/aopenai.py @@ -17,7 +17,6 @@ from __future__ import annotations -import os from typing import Any, AsyncIterator from openai import ( @@ -30,6 +29,7 @@ ) from rath._async.aretry import aretry_with_backoff +from rath.config.env import env_value from rath.llm.chat_request import RathLLMChatRequest from rath.llm.chat_response import ( RathLLMChatResponse, @@ -54,8 +54,8 @@ def _resolve_base_url(provider: Provider) -> str: entry = _config_provider_entry() if not provider.base_url else None return resolve_credential( provider.base_url, - os.environ.get("OPENAI_BASE_URL"), - os.environ.get("AZURE_OPENAI_ENDPOINT"), + env_value("OPENAI_BASE_URL"), + env_value("AZURE_OPENAI_ENDPOINT"), getattr(entry, "base_url", None), ) @@ -66,15 +66,15 @@ def _resolve_api_key(provider: Provider, base_url: str) -> str: if _is_azure_endpoint(base_url): return resolve_credential( provider.api_key, - os.environ.get("AZURE_OPENAI_API_KEY"), - os.environ.get("AZURE_API_KEY"), - os.environ.get("OPENAI_API_KEY"), + env_value("AZURE_OPENAI_API_KEY"), + env_value("AZURE_API_KEY"), + env_value("OPENAI_API_KEY"), config_key, ) return resolve_credential( provider.api_key, - os.environ.get("OPENAI_API_KEY"), - os.environ.get("AZURE_OPENAI_API_KEY"), + env_value("OPENAI_API_KEY"), + env_value("AZURE_OPENAI_API_KEY"), config_key, ) @@ -98,8 +98,8 @@ def __init__(self, provider: Provider) -> None: use_azure_legacy = _is_azure_endpoint(base_url) and "/openai/v1" not in base_url if use_azure_legacy: api_version = ( - os.environ.get("OPENAI_API_VERSION") - or os.environ.get("AZURE_OPENAI_API_VERSION") + env_value("OPENAI_API_VERSION") + or env_value("AZURE_OPENAI_API_VERSION") or "2024-10-21" ) self._client = AsyncAzureOpenAI( @@ -121,7 +121,7 @@ async def acomplete(self, req: RathLLMChatRequest) -> RathLLMChatResponse: """Run ``chat.completions.create`` (async) and normalize the response.""" default_model = ( self._provider.model - or os.environ.get("OPENAI_DEFAULT_MODEL") + or env_value("OPENAI_DEFAULT_MODEL") or _config_default_model() ) kwargs = to_create_kwargs(req, default_model=default_model) @@ -143,7 +143,7 @@ async def acomplete_stream( """Yield ``RathLLMStreamDelta`` for each chunk of a streaming completion.""" default_model = ( self._provider.model - or os.environ.get("OPENAI_DEFAULT_MODEL") + or env_value("OPENAI_DEFAULT_MODEL") or _config_default_model() ) kwargs = to_create_kwargs_stream(req, default_model=default_model) diff --git a/src/rath/backend/opensandbox.py b/src/rath/backend/opensandbox.py index 7097d44..0f986c7 100644 --- a/src/rath/backend/opensandbox.py +++ b/src/rath/backend/opensandbox.py @@ -61,6 +61,7 @@ BackendToolFilesRead, BackendToolFilesWrite, ) +from rath.config.env import env_flag, env_value try: from opensandbox import Sandbox as _OSBSandbox @@ -86,9 +87,42 @@ logger = logging.getLogger(__name__) -_STRICT_WORKSPACE_BIND = os.environ.get( - "RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND", "" -).lower() in ("1", "true", "yes") + +def strict_workspace_bind() -> bool: + """Whether to skip the no-volumes retry when a host bind is rejected. + + Read at call time via the central env registry (P2.5) rather than frozen at + import, so a test or late ``os.environ`` change is honored. + """ + return env_flag("RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND") + + +def resolve_opensandbox_domain() -> str | None: + """Resolve the opensandbox service domain: env → backend config → None. + + Precedence: ``OPEN_SANDBOX_DOMAIN`` / legacy ``OPENSANDBOX_DOMAIN`` (via the + registry), then the ``backend`` config section's default (or an + opensandbox-kind) provider ``domain`` (P1.2 wiring). Returns ``None`` when + unset. The SDK / ``~/.sandbox.toml`` may still supply credentials + independently; this only resolves the domain OpenRath knows about. + """ + domain = env_value("OPEN_SANDBOX_DOMAIN") or env_value("OPENSANDBOX_DOMAIN") + if domain: + return domain + try: + from rath.config.store import ConfigStore + + cfg = ConfigStore.load().config.backend + except (FileNotFoundError, RuntimeError): + return None + name = cfg.default_provider + entry = cfg.providers.get(name) if name else None + if entry is None: + for candidate in cfg.providers.values(): + if candidate.backend_kind == "opensandbox": + entry = candidate + break + return entry.domain if entry is not None and entry.domain else None async def _await_maybe_timeout(awaitable, timeout: float | None): @@ -100,6 +134,167 @@ async def _await_maybe_timeout(awaitable, timeout: float | None): raise TimeoutError from exc +# Management API default is 30s — too tight for first sandbox create on a cold +# runner (image pull + container start). ready_timeout covers health polling. +_SANDBOX_CREATE_ATTEMPTS = 3 +_SANDBOX_CREATE_BACKOFF_S = (1.0, 2.0) +_CREATE_REQUEST_TIMEOUT = timedelta(seconds=120) +_CREATE_READY_TIMEOUT = timedelta(seconds=120) +# code.run with no explicit timeout must not block until pytest's 300s marker fires. +_DEFAULT_TOOL_TIMEOUT_S = 90.0 + + +def _execution_stdout_bytes(execution: Any) -> bytes: + return "".join(m.text for m in execution.logs.stdout).encode("utf-8") + + +def _should_retry_command_for_empty_stdout(execution: Any) -> bool: + """Detect opensandbox-server stdout/exit_code capture race on small runners.""" + if execution.error is not None or execution.complete is None: + return False + if _execution_stdout_bytes(execution) or execution.logs.stderr: + return False + exit_code = execution.exit_code + if exit_code is not None and exit_code != 0: + return False + return True + + +def _is_transient_sandbox_create_error(exc: BaseException) -> bool: + """Classify create-time network/timeout failures that are safe to retry.""" + seen: set[int] = set() + cur: BaseException | None = exc + while cur is not None and id(cur) not in seen: + seen.add(id(cur)) + if _SDK_AVAILABLE: + from opensandbox.exceptions import ( + SandboxInternalException, + SandboxReadyTimeoutException, + ) + + if isinstance( + cur, (SandboxInternalException, SandboxReadyTimeoutException) + ): + msg = str(cur).lower() + if any( + token in msg + for token in ("timeout", "connectivity", "network", "readtimeout") + ): + return True + name = type(cur).__name__.lower() + if "timeout" in name or "connect" in name: + return True + cur = cur.__cause__ or cur.__context__ + return False + + +async def _sandbox_create_once( + image: str, + timeout: timedelta, + env: dict[str, str] | None, + entrypoint: list[str], + volumes: list | None, +) -> Any: + from opensandbox.config import ConnectionConfig + + connection_config = ConnectionConfig(request_timeout=_CREATE_REQUEST_TIMEOUT) + return await _OSBSandbox.create( + image, + timeout=timeout, + env=env, + entrypoint=entrypoint, + volumes=volumes, + connection_config=connection_config, + ready_timeout=_CREATE_READY_TIMEOUT, + ) + + +async def _sandbox_create_with_transient_retry( + image: str, + timeout: timedelta, + env: dict[str, str] | None, + entrypoint: list[str], + volumes: list | None, +) -> Any: + last_exc: BaseException | None = None + for attempt in range(_SANDBOX_CREATE_ATTEMPTS): + try: + return await _sandbox_create_once(image, timeout, env, entrypoint, volumes) + except BaseException as exc: + last_exc = exc + if ( + attempt + 1 >= _SANDBOX_CREATE_ATTEMPTS + or not _is_transient_sandbox_create_error(exc) + ): + raise + delay = _SANDBOX_CREATE_BACKOFF_S[ + min(attempt, len(_SANDBOX_CREATE_BACKOFF_S) - 1) + ] + logger.warning( + "OpenSandbox create transient failure (attempt %s/%s); " + "retrying in %.1fs: %s", + attempt + 1, + _SANDBOX_CREATE_ATTEMPTS, + delay, + exc, + ) + await asyncio.sleep(delay) + assert last_exc is not None + raise last_exc + + +def _command_stdout_rerun_allowed(cmd_str: str) -> bool: + """Only ``print(...)`` probes are safe to re-run on the stdout/exit_code race.""" + return "print(" in cmd_str + + +async def _run_command_with_stdout_retry( + native: Any, + cmd_str: str, + opts: Any, + call_timeout: float | None, +) -> Any: + execution = await _await_maybe_timeout( + native.commands.run(cmd_str, opts=opts), + call_timeout, + ) + # Only re-run read-only probes (``print(...)``). Mutating commands such as + # ``write_text`` must never execute twice — that race caused 'abb' != 'ab' + # in stream FIFO conformance when a retry fired on empty stdout. + if _command_stdout_rerun_allowed( + cmd_str + ) and _should_retry_command_for_empty_stdout(execution): + logger.debug( + "OpenSandbox command returned success with empty stdout; retrying once" + ) + execution = await _await_maybe_timeout( + native.commands.run(cmd_str, opts=opts), + call_timeout, + ) + return execution + + +async def _run_code_with_retry( + ci: Any, + source: str, + language: str, + call_timeout: float | None, +) -> Any: + effective = call_timeout if call_timeout is not None else _DEFAULT_TOOL_TIMEOUT_S + for attempt in range(2): + try: + return await _await_maybe_timeout( + ci.codes.run(source, language=language), + effective, + ) + except TimeoutError: + if attempt == 0: + logger.debug("OpenSandbox code.run timed out; retrying once") + continue + raise + raise RuntimeError("unreachable code path in _run_code_with_retry") + + def bind_workspace_volumes_from_spec( spec: BackendSandboxSpec | None, sandbox_root: str, @@ -174,18 +369,18 @@ async def _create_sandbox_with_optional_bind_fallback( """Create sandbox; on bind rejection, retry once with ``volumes=None``.""" try: - native = await _OSBSandbox.create( + native = await _sandbox_create_with_transient_retry( image, - timeout=timeout, - env=env, - entrypoint=entrypoint, - volumes=volumes, + timeout, + env, + entrypoint, + volumes, ) return native, volumes except BaseException as exc: if ( not volumes - or _STRICT_WORKSPACE_BIND + or strict_workspace_bind() or not _likely_workspace_bind_rejected(exc) ): raise @@ -197,12 +392,12 @@ async def _create_sandbox_with_optional_bind_fallback( exc, exc_info=logger.isEnabledFor(logging.DEBUG), ) - native = await _OSBSandbox.create( + native = await _sandbox_create_with_transient_retry( image, - timeout=timeout, - env=env, - entrypoint=entrypoint, - volumes=None, + timeout, + env, + entrypoint, + None, ) return native, None @@ -218,10 +413,15 @@ class OpenSandboxBackend(Backend): name: ClassVar[str] = "opensandbox" - _DEFAULT_IMAGE: ClassVar[str] = "opensandbox/code-interpreter:v1.0.2" + # code-interpreter v1.1.0 relocated the launcher from + # /opt/opensandbox/code-interpreter.sh (v1.0.2) to + # /opt/code-interpreter/code-interpreter.sh. v1.0.2 is no longer pullable, + # so track the current image + path (a stale default fails at container + # start with exit 127). Callers can still override both via BackendSandboxSpec. + _DEFAULT_IMAGE: ClassVar[str] = "opensandbox/code-interpreter:v1.1.0" _DEFAULT_TIMEOUT: ClassVar[timedelta] = timedelta(minutes=10) _DEFAULT_ENTRYPOINT: ClassVar[tuple[str, ...]] = ( - "/opt/opensandbox/code-interpreter.sh", + "/opt/code-interpreter/code-interpreter.sh", ) _SANDBOX_ROOT: ClassVar[str] = "/workspace" @@ -262,9 +462,8 @@ def is_available(cls) -> bool: """ if not (_SDK_AVAILABLE and _CI_AVAILABLE): return False - if os.environ.get("OPEN_SANDBOX_DOMAIN") or os.environ.get( - "OPENSANDBOX_DOMAIN", - ): + # Domain from env or the backend config section (P2.5). + if resolve_opensandbox_domain(): return True return Path.home().joinpath(".sandbox.toml").exists() @@ -500,11 +699,13 @@ async def _command_run( ), envs=dict(call.env) if call.env is not None else None, ) - execution = await _await_maybe_timeout( - native.commands.run(cmd_str, opts=opts), + execution = await _run_command_with_stdout_retry( + native, + cmd_str, + opts, call.timeout, ) - stdout = "".join(m.text for m in execution.logs.stdout).encode("utf-8") + stdout = _execution_stdout_bytes(execution) stderr = "".join(m.text for m in execution.logs.stderr).encode("utf-8") elapsed_ms = ( float(execution.complete.execution_time_in_millis) @@ -585,8 +786,10 @@ async def _code_run(self, native: Any, call: BackendToolCodeRun) -> CodeResult: else call.code ) ci = await CodeInterpreter.create(native) - execution = await _await_maybe_timeout( - ci.codes.run(source, language=call.language), + execution = await _run_code_with_retry( + ci, + source, + call.language, call.timeout, ) stdout = "".join(m.text for m in execution.logs.stdout).encode("utf-8") @@ -604,9 +807,10 @@ def _join_cmd(cmd: Sequence[str]) -> str: def _wrap_python_for_traceback(code: str) -> str: """Wrap user Python so an uncaught exception always writes a traceback to stderr. - The OpenSandbox v1.0.2 code-interpreter image does not consistently - populate ``Execution.error`` for top-level Python raises. We guarantee - stderr-side surfacing by exec'ing the user source inside a try/except. + The OpenSandbox code-interpreter image does not consistently populate + ``Execution.error`` for top-level Python raises (observed on v1.0.2, still + prudent on v1.1.0). We guarantee stderr-side surfacing by exec'ing the user + source inside a try/except. The original ``raise`` is re-raised so the runtime still observes the failure (exit_code, ``Execution.error``) if it cares to. Source is passed as a base64 blob to avoid quoting edge cases. diff --git a/src/rath/backend/persistence/registry.py b/src/rath/backend/persistence/registry.py index b010812..cddc242 100644 --- a/src/rath/backend/persistence/registry.py +++ b/src/rath/backend/persistence/registry.py @@ -37,6 +37,7 @@ ) from rath.backend.registry import get as backend_get from rath.config.secrets import chmod_user_only +from rath.persistence.atomic import atomic_write_json __all__ = [ "PersistentSandboxRegistry", @@ -181,10 +182,7 @@ def record_remote( "created_at": now.isoformat(), "last_used_at": now.isoformat(), } - path.write_text( - json.dumps(record, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + atomic_write_json(path, record) chmod_user_only(path) return sid @@ -203,10 +201,7 @@ def touch_remote(self, sandbox_id: UUID | str) -> None: logger.warning("touch_remote: %s is unreadable", path, exc_info=True) return data["last_used_at"] = datetime.now(timezone.utc).isoformat() - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + atomic_write_json(path, data) def load_remote(self, sandbox_id: UUID | str) -> RemoteSandboxRecord | None: """Read one remote-sandbox index file. Returns ``None`` when missing.""" diff --git a/src/rath/config/credentials.py b/src/rath/config/credentials.py new file mode 100644 index 0000000..4c71c21 --- /dev/null +++ b/src/rath/config/credentials.py @@ -0,0 +1,83 @@ +"""Split secrets out of the routing config into a separate ``credentials.json``. + +The in-memory :class:`~rath.config.schema.RathConfig` keeps ``api_key`` inline +so callers are unaffected. On disk, secrets live in a sibling +``credentials.json`` (0600) while ``config.json`` holds only routing/presets. +This keeps a project-local ``config.json`` safe to eyeball / share without +leaking keys, and lets the two files carry different permissions. + +The functions here are pure dict transforms so they can be unit-tested without +touching the filesystem; :class:`~rath.config.store.ConfigStore` calls them at +the load/save boundary. + +Secret layout in ``credentials.json``:: + + {"version": 1, "llm": {"providers": {"": ""}}, + "backend": {"providers": {"": ""}}} + +Only sections that hold a ``providers`` mapping with an ``api_key`` field are +considered. Adding a new such section (e.g. ``backend`` in P1.2) needs only a +one-line addition to :data:`SECRET_SECTIONS`. +""" + +from __future__ import annotations + +from typing import Any + +__all__ = [ + "CREDENTIALS_FILENAME", + "SECRET_SECTIONS", + "split_secrets", + "secrets_from_config", +] + +CREDENTIALS_FILENAME = "credentials.json" + +# Config sections whose ``providers[*].api_key`` is a secret to externalize. +SECRET_SECTIONS: tuple[str, ...] = ("llm", "backend") + + +def split_secrets(payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Return ``(config_without_secrets, credentials)`` from a full dump. + + ``payload`` is mutated-safe: a shallow-enough copy is made so the caller's + dict is not modified. Each provider's non-empty ``api_key`` is moved into + the credentials mapping and removed from the config mapping. + """ + import copy + + config = copy.deepcopy(payload) + creds: dict[str, Any] = {} + + for section in SECRET_SECTIONS: + sect = config.get(section) + if not isinstance(sect, dict): + continue + providers = sect.get("providers") + if not isinstance(providers, dict): + continue + for name, entry in providers.items(): + if not isinstance(entry, dict): + continue + key = entry.get("api_key") + if key: + entry.pop("api_key", None) + creds.setdefault(section, {}).setdefault("providers", {})[name] = key + + return config, creds + + +def secrets_from_config(credentials: dict[str, Any]) -> dict[tuple[str, str], str]: + """Flatten a loaded ``credentials.json`` into ``{(section, name): api_key}``.""" + out: dict[tuple[str, str], str] = {} + for section in SECRET_SECTIONS: + sect = credentials.get(section) + if not isinstance(sect, dict): + continue + providers = sect.get("providers") + if not isinstance(providers, dict): + continue + for name, key in providers.items(): + if isinstance(key, str) and key: + out[(section, name)] = key + return out diff --git a/src/rath/config/env.py b/src/rath/config/env.py new file mode 100644 index 0000000..b1a7f26 --- /dev/null +++ b/src/rath/config/env.py @@ -0,0 +1,245 @@ +"""Central registry of every environment variable OpenRath reads. + +Historically each client (openai/anthropic/litellm, sync and async) read +``os.environ.get(...)`` with bare string literals, duplicated line-for-line +across the sync and async paths. This module declares each variable **once** +with its kind, consumers, and default, and offers a single typed read plus a +precedence-preserving :func:`resolve_env`. + +It deliberately does **not** rename or re-prefix any variable — the existing +vendor names (``OPENAI_API_KEY`` etc.) are kept exactly. The registry is a +lookup + documentation + single-read layer, not a new naming scheme. + +Precedence is unchanged: **explicit field > environment > config**. Callers +express that via ``resolve_env(name, *explicit_candidates)``, which returns the +first non-empty value among the explicit candidates and then the env var +(mirroring :func:`rath.llm.credentials.resolve_credential`). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from enum import Enum + +__all__ = [ + "EnvKind", + "EnvSpec", + "get_env_spec", + "env_value", + "env_flag", + "resolve_env", + "env_reference_rows", + "env_reference_markdown", + "all_env_specs", +] + + +class EnvKind(Enum): + """What a variable carries, for docs and secret-hygiene decisions.""" + + SECRET = "secret" # api keys — never log the value + ROUTING = "routing" # base urls, model names, endpoints, home dir + FLAG = "flag" # boolean toggles + + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + + +@dataclass(frozen=True, slots=True) +class EnvSpec: + """Declaration of a single environment variable.""" + + name: str + kind: EnvKind + consumers: str # human-readable "who reads this and for what" + default: str | None = None + aliases: tuple[str, ...] = field(default_factory=tuple) + + +# --- The registry ----------------------------------------------------------- +# Declared once here; see rath.llm.* and rath.backend.opensandbox for consumers. + +_SPECS: dict[str, EnvSpec] = {} + + +def _register(spec: EnvSpec) -> None: + _SPECS[spec.name] = spec + + +def get_env_spec(name: str) -> EnvSpec: + """Return the declared :class:`EnvSpec`, or raise :class:`KeyError`.""" + try: + return _SPECS[name] + except KeyError as e: + raise KeyError( + f"{name!r} is not a declared OpenRath environment variable; " + f"declared: {sorted(_SPECS)}" + ) from e + + +def env_value(name: str, *, environ: dict[str, str] | None = None) -> str | None: + """Return the stripped value of ``name``, or its default, or ``None``. + + Raises :class:`KeyError` if ``name`` is not declared (typo guard). + Whitespace-only values are treated as unset. + """ + spec = get_env_spec(name) + src = os.environ if environ is None else environ + raw = src.get(name) + if raw is not None: + s = raw.strip() + if s: + return s + return spec.default + + +def env_flag(name: str, *, environ: dict[str, str] | None = None) -> bool: + """Interpret ``name`` as a boolean flag (``1/true/yes/on`` → True).""" + val = env_value(name, environ=environ) + if val is None: + return False + return val.lower() in _TRUTHY + + +def resolve_env(name: str, *explicit: str | None) -> str: + """First non-empty among ``explicit`` candidates, then the env var. + + Mirrors :func:`rath.llm.credentials.resolve_credential`, preserving the + documented ``explicit > env`` precedence. Returns ``""`` when nothing + qualifies; callers decide whether that is an error. (Config-file fallback + stays in the caller — the registry only owns the env tier.) + """ + for c in explicit: + if c is not None and c.strip(): + return c.strip() + val = env_value(name) + return val if val is not None else "" + + +def all_env_specs() -> list[EnvSpec]: + """All declared specs, sorted by name.""" + return [_SPECS[n] for n in sorted(_SPECS)] + + +def env_reference_rows() -> list[dict[str, str]]: + """Sorted, JSON-friendly rows for a generated reference table (P2.4).""" + rows: list[dict[str, str]] = [] + for spec in all_env_specs(): + rows.append( + { + "name": spec.name, + "kind": spec.kind.value, + "consumers": spec.consumers, + "default": "" if spec.default is None else spec.default, + } + ) + return rows + + +def env_reference_markdown() -> str: + """Render the env reference as a stable markdown table (feeds the docs). + + Secrets never print a default value (they have none), so the Default + column stays blank for them — no secret material can leak into docs. + """ + header = "| Name | Kind | Consumers | Default |" + sep = "| --- | --- | --- | --- |" + lines = [header, sep] + for row in env_reference_rows(): + lines.append( + f"| `{row['name']}` | {row['kind']} | {row['consumers']} " + f"| {row['default']} |" + ) + return "\n".join(lines) + "\n" + + +# --- Declarations (the single source of truth) ------------------------------ + +# Home / paths +_register( + EnvSpec( + "OPENRATH_HOME", + EnvKind.ROUTING, + "rath.config.paths: overrides the config/data root dir", + ) +) + +# OpenAI-compatible +_register( + EnvSpec( + "OPENAI_API_KEY", EnvKind.SECRET, "OpenAI-compatible chat/embed/vlm api key" + ) +) +_register(EnvSpec("OPENAI_BASE_URL", EnvKind.ROUTING, "OpenAI-compatible base url")) +_register( + EnvSpec( + "OPENAI_DEFAULT_MODEL", + EnvKind.ROUTING, + "default model for OpenAI-compatible clients", + ) +) +_register( + EnvSpec( + "OPENAI_API_VERSION", + EnvKind.ROUTING, + "legacy Azure api_version (client applies a 2024-10-21 fallback)", + ) +) + +# Azure OpenAI +_register(EnvSpec("AZURE_OPENAI_ENDPOINT", EnvKind.ROUTING, "Azure OpenAI endpoint")) +_register(EnvSpec("AZURE_OPENAI_API_KEY", EnvKind.SECRET, "Azure OpenAI api key")) +_register(EnvSpec("AZURE_API_KEY", EnvKind.SECRET, "Azure api key (fallback)")) +_register( + EnvSpec("AZURE_OPENAI_API_VERSION", EnvKind.ROUTING, "Azure api_version (fallback)") +) + +# Anthropic +_register(EnvSpec("ANTHROPIC_API_KEY", EnvKind.SECRET, "Anthropic api key")) +_register(EnvSpec("ANTHROPIC_BASE_URL", EnvKind.ROUTING, "Anthropic base url")) +_register( + EnvSpec( + "ANTHROPIC_DEFAULT_MODEL", + EnvKind.ROUTING, + "default model for the Anthropic client", + ) +) + +# LiteLLM +_register(EnvSpec("LITELLM_API_KEY", EnvKind.SECRET, "LiteLLM api key")) +_register(EnvSpec("LITELLM_API_BASE", EnvKind.ROUTING, "LiteLLM api base url")) +_register( + EnvSpec("LITELLM_MODEL", EnvKind.ROUTING, "default model for the LiteLLM client") +) + +# OpenSandbox backend +_register( + EnvSpec( + "OPEN_SANDBOX_DOMAIN", + EnvKind.ROUTING, + "opensandbox service domain", + aliases=("OPENSANDBOX_DOMAIN",), + ) +) +_register( + EnvSpec( + "OPENSANDBOX_DOMAIN", + EnvKind.ROUTING, + "opensandbox service domain (legacy alias)", + ) +) +_register( + EnvSpec( + "OPEN_SANDBOX_API_KEY", + EnvKind.SECRET, + "opensandbox api key (read by the SDK, not rath directly)", + ) +) +_register( + EnvSpec( + "RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND", + EnvKind.FLAG, + "opensandbox: fail instead of falling back when workspace bind is rejected", + ) +) diff --git a/src/rath/config/schema.py b/src/rath/config/schema.py index 4d56ece..8e13ef7 100644 --- a/src/rath/config/schema.py +++ b/src/rath/config/schema.py @@ -19,6 +19,8 @@ "MemoryConfig", "MCPServerConfig", "MCPConfig", + "BackendProviderConfig", + "BackendConfig", "RathConfig", "SCHEMA_VERSION", ] @@ -114,17 +116,46 @@ class MemoryConfig(BaseModel): model_config = ConfigDict(extra="allow") +class BackendProviderConfig(BaseModel): + """One named entry under ``backend.providers``. + + Gives sandbox backends a config home parallel to ``llm``/``memory`` instead + of relying on environment variables and ``~/.sandbox.toml`` alone. ``domain`` + and ``api_key`` route the ``opensandbox`` backend; ``api_key`` is a secret + and is externalized to ``credentials.json`` on save (see + :mod:`rath.config.credentials`). Backend-specific knobs (image, timeout, …) + stay on ``options`` and round-trip via ``extra="allow"``. + """ + + backend_kind: Literal["local", "opensandbox"] = "opensandbox" + domain: str | None = None + api_key: str | None = None + options: dict[str, object] = Field(default_factory=dict) + + model_config = ConfigDict(extra="allow") + + +class BackendConfig(BaseModel): + """The ``backend`` section: named sandbox-backend presets + the default.""" + + default_provider: str | None = None + providers: dict[str, BackendProviderConfig] = Field(default_factory=dict) + + model_config = ConfigDict(extra="allow") + + class RathConfig(BaseModel): """Top-level on-disk schema. - Sections currently in use: ``llm``, ``mcp``, and ``memory``. Future - sections (e.g. ``backend`` for OpenSandbox routing) can be added without - touching callers because ``extra="allow"`` preserves them on round-trip. + Sections currently in use: ``llm``, ``mcp``, ``memory``, and ``backend``. + Unknown/future sections are preserved on round-trip because + ``extra="allow"``. """ version: int = SCHEMA_VERSION llm: LLMConfig = Field(default_factory=LLMConfig) mcp: MCPConfig = Field(default_factory=MCPConfig) memory: MemoryConfig = Field(default_factory=MemoryConfig) + backend: BackendConfig = Field(default_factory=BackendConfig) model_config = ConfigDict(extra="allow") diff --git a/src/rath/config/store.py b/src/rath/config/store.py index 371ccd6..3b268c5 100644 --- a/src/rath/config/store.py +++ b/src/rath/config/store.py @@ -9,13 +9,18 @@ from __future__ import annotations import json -import tempfile +import logging import threading from pathlib import Path from typing import Any from pydantic import ValidationError +from rath.config.credentials import ( + CREDENTIALS_FILENAME, + secrets_from_config, + split_secrets, +) from rath.config.paths import ( is_project_local, resolve_config_dir, @@ -23,6 +28,7 @@ ) from rath.config.schema import ( SCHEMA_VERSION, + BackendProviderConfig, LLMProviderConfig, MCPServerConfig, MemoryProviderConfig, @@ -34,9 +40,13 @@ ensure_project_gitignore_entry, warn_if_world_readable, ) +from rath.persistence.atomic import atomic_write_json +from rath.persistence.manifest import check_manifest, ensure_manifest __all__ = ["ConfigStore", "ConfigError"] +logger = logging.getLogger(__name__) + class ConfigError(RuntimeError): """Raised on schema-validation failure or corrupt JSON. @@ -74,6 +84,9 @@ def __init__(self, path: Path | None = None) -> None: self.path = (path or resolve_config_path()).resolve() self._raw_unknown: dict[str, Any] = {} self._save_lock = threading.Lock() + # Set by _merge_credentials when a legacy inline api_key is seen, so + # save() can log a one-time migration note as it externalizes secrets. + self._has_inline_secret = False self._data: RathConfig = self._load_or_default() @classmethod @@ -149,30 +162,36 @@ def save(self) -> None: ensure_project_gitignore_entry(Path.cwd()) payload = self._merged_payload() - text = json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=False) + # Externalize secrets: config.json keeps routing/presets, credentials.json + # (0600) holds api_keys. Pure dict split so callers stay unaffected. + config_payload, creds_payload = split_secrets(payload) + if self._has_inline_secret: + logger.info( + "rath.config: migrating inline api_key(s) out of %s into %s; " + "config.json will no longer contain secrets", + self.path.name, + CREDENTIALS_FILENAME, + ) + self._has_inline_secret = False with self._save_lock: - fd = tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=config_dir, - prefix=".config_", - suffix=".tmp", - delete=False, - ) - try: - tmp_path = Path(fd.name) - fd.write(text + "\n") - fd.flush() - fd.close() - tmp_path.replace(self.path) - except BaseException: - fd.close() - tmp_path = Path(fd.name) - tmp_path.unlink(missing_ok=True) - raise + # Atomic writes serialize concurrent same-path replaces and retry + # the Windows sharing-violation window (see rath.persistence.atomic). + atomic_write_json(self.path, config_payload) chmod_user_only(self.path) + creds_path = config_dir / CREDENTIALS_FILENAME + if creds_payload: + creds_payload["version"] = SCHEMA_VERSION + atomic_write_json(creds_path, creds_payload, mode=0o600) + chmod_user_only(creds_path) + + # Record/refresh the root layout manifest at the data root. + try: + ensure_manifest(config_dir) + except OSError: # pragma: no cover -- best-effort, never block a save + logger.debug("could not write layout manifest", exc_info=True) + # Invalidate read cache so next load() picks up the new data with type(self)._cache_lock: type(self)._cache.pop(self.path, None) @@ -247,6 +266,30 @@ def get_memory_provider(self, name: str | None) -> MemoryProviderConfig: f"available: {available}", ) from e + # --- Backend helpers -------------------------------------------------- + + def get_backend_provider(self, name: str | None) -> BackendProviderConfig: + """Return the named backend provider entry. + + ``name=None`` falls back to :attr:`BackendConfig.default_provider`. + Raises :class:`KeyError` with the available names when the lookup + fails. + """ + target = name or self._data.backend.default_provider + if target is None: + raise KeyError( + "no backend provider name given and no backend.default_provider " + f"set in {self.path}", + ) + try: + return self._data.backend.providers[target] + except KeyError as e: + available = sorted(self._data.backend.providers) + raise KeyError( + f"backend provider {target!r} not found in {self.path}; " + f"available: {available}", + ) from e + # --- MCP helpers ------------------------------------------------------ def get_mcp_server(self, name: str) -> MCPServerConfig: @@ -269,6 +312,9 @@ def enabled_mcp_servers(self) -> list[MCPServerConfig]: # --- Internals -------------------------------------------------------- def _load_or_default(self) -> RathConfig: + # Refuse a data root written by a newer OpenRath layout (no-op when the + # manifest is absent — fresh/legacy roots keep working). + check_manifest(self.path.parent) if not self.path.is_file(): return RathConfig() warn_if_world_readable(self.path) @@ -284,11 +330,53 @@ def _load_or_default(self) -> RathConfig: f"{self.path} top-level must be a JSON object, got " f"{type(raw).__name__}", ) + self._merge_credentials(raw) try: return RathConfig.model_validate(raw) except ValidationError as e: raise ConfigError(f"{self.path} failed schema validation: {e}") from e + def _merge_credentials(self, raw: dict[str, Any]) -> None: + """Fill provider ``api_key`` fields from a sibling ``credentials.json``. + + Precedence is **inline > credentials.json**: an ``api_key`` already + present (and non-empty) in ``config.json`` wins, so a legacy single-file + config keeps working unchanged. A non-empty inline key also flips + :attr:`_has_inline_secret`, so the next :meth:`save` migrates it out and + logs a one-time deprecation note. + """ + creds_path = self.path.parent / CREDENTIALS_FILENAME + secrets: dict[tuple[str, str], str] = {} + if creds_path.is_file(): + try: + creds_raw = json.loads(creds_path.read_text(encoding="utf-8")) + if isinstance(creds_raw, dict): + secrets = secrets_from_config(creds_raw) + except json.JSONDecodeError: # pragma: no cover -- corrupt sidecar + logger.warning( + "rath.config: %s is not valid JSON; ignoring", creds_path + ) + + from rath.config.credentials import SECRET_SECTIONS + + for section in SECRET_SECTIONS: + sect = raw.get(section) + if not isinstance(sect, dict): + continue + providers = sect.get("providers") + if not isinstance(providers, dict): + continue + for name, entry in providers.items(): + if not isinstance(entry, dict): + continue + inline = entry.get("api_key") + if inline: + self._has_inline_secret = True + continue # inline wins + merged = secrets.get((section, name)) + if merged: + entry["api_key"] = merged + def _merged_payload(self) -> dict[str, Any]: """Serialize ``self._data`` ensuring known keys are present. diff --git a/src/rath/flow/__init__.py b/src/rath/flow/__init__.py index dd9a695..09506c5 100644 --- a/src/rath/flow/__init__.py +++ b/src/rath/flow/__init__.py @@ -4,6 +4,7 @@ from rath.flow.agent import Agent from rath.flow.agent_param import AgentParam, Provider +from rath.flow.compile import CompiledWorkflow, ResourceManifest from rath.flow.compressor import Compressor from rath.flow.empty import EmptyWorkflow from rath.flow.selector import Selector @@ -17,4 +18,6 @@ "Compressor", "EmptyWorkflow", "Selector", + "CompiledWorkflow", + "ResourceManifest", ] diff --git a/src/rath/flow/agent_param.py b/src/rath/flow/agent_param.py index bdc1ce1..bdbe329 100644 --- a/src/rath/flow/agent_param.py +++ b/src/rath/flow/agent_param.py @@ -5,7 +5,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from types import MappingProxyType from typing import Any, Mapping @@ -14,6 +14,48 @@ from rath.session.session import Session +def resolve_provider_arg( + provider: Provider | str | None = None, + *, + model: str | None = None, + base: Provider | None = None, +) -> Provider: + """Normalize a ``.to()``-style provider argument to a concrete ``Provider``. + + Shared by :meth:`AgentParam.to`, :meth:`Workflow.to`, and ``Session.to``: + + - a :class:`Provider` instance is used as-is (with ``model`` overlaid when + given and its own model is unset); + - a ``str`` is treated as a config provider **name** and resolved lazily via + :meth:`Provider.from_config`; + - ``None`` with ``model=`` builds ``Provider(model=...)``, or overlays + ``model`` onto ``base`` when a base provider is supplied. + + Raises :class:`TypeError` for other types and :class:`ValueError` when there + is nothing to build a provider from. + """ + if isinstance(provider, Provider): + if model is not None and provider.model is None: + return replace(provider, model=model) + return provider + if isinstance(provider, str): + overrides: dict[str, Any] = {} + if model is not None: + overrides["model"] = model + return Provider.from_config(provider, **overrides) + if provider is not None: + raise TypeError( + "provider must be a Provider, a config name (str), or None; " + f"got {type(provider).__name__}" + ) + # provider is None + if base is not None: + return replace(base, model=model) if model is not None else base + if model is not None: + return Provider(model=model) + raise ValueError("nothing to bind: pass a Provider, provider=, or model=") + + def _indent_child_module_repr(body: str, spaces: int = 2) -> str: """Indent a child ``repr`` like ``torch.nn.Module`` (first line unindented).""" @@ -33,6 +75,43 @@ class AgentParam: provider: Provider memory: MemoryStore | None = None + def to( + self, + target: Provider | None = None, + *, + provider: str | None = None, + model: str | None = None, + ) -> "AgentParam": + """Rebind this param's :class:`Provider` (chainable, returns ``self``). + + Type-dispatched, mirroring ``Session.to`` for sandboxes: + + - ``ap.to(Provider(...))`` — bind an explicit provider (positional); + - ``ap.to(provider="name")`` — resolve a config preset lazily; + - ``ap.to(model="m")`` — overlay just the model on the current provider. + + The positional argument accepts only a :class:`Provider`; a bare string + is rejected because — unlike ``Session.to("local")`` (a sandbox backend + name) — the LLM path has no unambiguous string form. Use + ``provider="name"`` for a config preset instead. + """ + if not isinstance(target, (Provider, type(None))): + raise TypeError( + "AgentParam.to() positional argument must be a Provider; " + 'use provider="name" for a config preset' + ) + if target is not None and provider is not None: + raise ValueError( + "pass either a Provider positionally or provider=, not both" + ) + if target is None and provider is None and model is None: + raise ValueError( + "nothing to bind: pass a Provider, provider=, or model=" + ) + arg: Provider | str | None = target if target is not None else provider + self.provider = resolve_provider_arg(arg, model=model, base=self.provider) + return self + @property def data(self) -> Mapping[str, Any]: """Read-only mapping of underlying ``agent_session``, ``provider`` and ``memory``.""" diff --git a/src/rath/flow/compile.py b/src/rath/flow/compile.py new file mode 100644 index 0000000..bf688a9 --- /dev/null +++ b/src/rath/flow/compile.py @@ -0,0 +1,266 @@ +"""Static compilation of a :class:`~rath.flow.workflow.Workflow`. + +``Workflow.compile()`` performs a **static** pass over the module tree (P5.1) +without running the model or materializing any session. It collects a +:class:`ResourceManifest` — every reachable ``AgentParam``'s provider identity, +whether memory is bound, and the agent-prompt session id — and records +``Selector`` nodes as *dynamic* (known-unknown) rather than pretending to know +their runtime routing. + +Soundness is the point: compile never predicts a ``Selector`` branch, a loop +count, or a ``fork``. It answers *what resources can this workflow use* (for +pre-flight validation and deterministic acquire/teardown), not *what will it do*. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rath.flow.agent_param import AgentParam + from rath.flow.workflow import Workflow + from rath.memory.abc import MemoryStore + +from rath.llm.provider import Provider + +__all__ = [ + "AgentResource", + "DynamicNode", + "ResourceManifest", + "collect_manifest", + "CompiledWorkflow", +] + + +@dataclass(frozen=True, slots=True) +class AgentResource: + """One reachable ``AgentParam`` in the compiled module tree.""" + + path: str # dotted path from the root workflow (e.g. "a.p") + provider: Provider + has_memory: bool + agent_session_id: str + + +@dataclass(frozen=True, slots=True) +class DynamicNode: + """A node whose runtime behavior compile cannot statically resolve.""" + + path: str + kind: str # e.g. "selector" + reason: str + + +@dataclass(slots=True) +class ResourceManifest: + """The static resource inventory of a compiled workflow.""" + + agents: list[AgentResource] = field(default_factory=list) + dynamic_nodes: list[DynamicNode] = field(default_factory=list) + + def provider_models(self) -> list[str]: + """Distinct, sorted provider model names reachable in the workflow.""" + return sorted({a.provider.model for a in self.agents if a.provider.model}) + + def provider_kinds(self) -> list[str]: + """Distinct, sorted provider kinds reachable (``None`` -> ``"openai"``).""" + return sorted({a.provider.provider_kind or "openai" for a in self.agents}) + + +def _join(prefix: str, name: str) -> str: + return f"{prefix}.{name}" if prefix else name + + +def collect_manifest(workflow: "Workflow") -> ResourceManifest: + """Walk ``workflow``'s module tree and build its :class:`ResourceManifest`. + + Deterministic pre-order traversal. A ``Selector`` is recorded as a dynamic + node (its router ``AgentParam`` is still collected, since that provider is a + real static dependency), and its runtime routing targets are NOT followed — + they are decided by the model at run time. + """ + from rath.flow.selector import Selector + + manifest = ResourceManifest() + + def _visit(node: "Workflow", prefix: str) -> None: + if isinstance(node, Selector): + manifest.dynamic_nodes.append( + DynamicNode( + path=prefix or type(node).__name__, + kind="selector", + reason=( + "Selector routes to a workflow chosen by the model at " + "runtime; successor set is not statically known" + ), + ) + ) + # Collect this node's own AgentParam leaves (incl. a Selector's router). + for name, ap in node.named_agents(): + manifest.agents.append( + AgentResource( + path=_join(prefix, name), + provider=ap.provider, + has_memory=ap.memory is not None, + agent_session_id=str(ap.agent_session.id), + ) + ) + # Descend into nested workflow children (but not a Selector's dynamic + # routing — a Selector holds no static workflow children anyway). + for name, child in node.named_children(): + _visit(child, _join(prefix, name)) + + _visit(workflow, "") + return manifest + + +class CompiledWorkflow: + """Static, callable wrapper around a :class:`~rath.flow.workflow.Workflow`. + + Produced by :meth:`Workflow.compile`. It is callable exactly like the + workflow — ``cw(session)`` delegates to ``workflow.forward`` — so compiling + is opt-in and non-breaking. It also exposes the static + :class:`ResourceManifest`, the module tree, and a graph ``repr``. + + Compiling runs no model and materializes no session; it only walks the + static module tree (P5.1) to build the manifest. + """ + + __slots__ = ("workflow", "manifest", "_acquired") + + def __init__(self, workflow: "Workflow") -> None: + self.workflow = workflow + self.manifest = collect_manifest(workflow) + self._acquired: list[MemoryStore] = [] # stores acquired by __enter__ + + def __call__(self, session): # type: ignore[no-untyped-def] + return self.workflow(session) + + def __enter__(self) -> "CompiledWorkflow": + """Pre-acquire the planned resources (bound memory stores). + + Acquires one reference on every distinct memory store bound to a + reachable ``AgentParam`` so they stay open for the compiled run and are + released deterministically on exit. Provider is a value (no lifecycle); + sandboxes open lazily per session and are not force-opened here. + """ + acquired: list[MemoryStore] = [] + seen: set[int] = set() + try: + for _path, ap in _reachable_agent_params(self.workflow): + store = ap.memory + if store is not None and id(store) not in seen: + seen.add(id(store)) + store.acquire() + acquired.append(store) + except BaseException: + for store in reversed(acquired): + _safe_release(store) + raise + self._acquired = acquired + return self + + def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[no-untyped-def] + # Release in reverse acquisition order; never mask an in-flight error. + for store in reversed(self._acquired): + _safe_release(store) + self._acquired = [] + + def named_children(self): # type: ignore[no-untyped-def] + """The compiled workflow's registered children (delegates).""" + return self.workflow.named_children() + + def validate(self, *, raise_on_error: bool = False) -> list[str]: + """Pre-flight check every reachable provider (offline; no model call). + + For each agent in the manifest, verify (1) its ``provider_kind`` is a + registered chat-client kind, and (2) a credential resolves for it via + the same Provider → env → config chain the client uses at construction — + without building an SDK client or hitting the network. + + Returns a list of human-readable problems (empty when clean). With + ``raise_on_error=True``, raises :class:`ValueError` if any problem is + found. This lets callers fail fast before a run instead of deep inside + the first completion. + """ + from rath.llm.registry import registered_kinds + + problems: list[str] = [] + kinds = set(registered_kinds()) + for agent in self.manifest.agents: + kind = agent.provider.provider_kind or "openai" + if kind not in kinds: + problems.append( + f"agent {agent.path!r}: unknown provider_kind={kind!r} " + f"(registered: {sorted(kinds)})" + ) + continue + if not _credential_resolves(kind, agent.provider): + problems.append( + f"agent {agent.path!r}: no api credential resolves for " + f"provider_kind={kind!r} (set Provider.api_key, the vendor env " + f"var, or a config provider)" + ) + if raise_on_error and problems: + raise ValueError( + "workflow pre-flight validation failed:\n - " + "\n - ".join(problems) + ) + return problems + + def __repr__(self) -> str: + n_agents = len(self.manifest.agents) + n_dyn = len(self.manifest.dynamic_nodes) + return ( + f"CompiledWorkflow({self.workflow!r}, " + f"agents={n_agents}, dynamic_nodes={n_dyn})" + ) + + +def _credential_resolves(kind: str, provider: Provider) -> bool: + """Whether an api key resolves for ``provider`` under ``kind`` (offline). + + Uses each adapter's pure resolver (Provider -> env -> config), which does + not construct an SDK client or make a network call. LiteLLM resolves creds + from provider-specific env vars internally, so it is treated as always + satisfiable at the pre-flight layer. + """ + try: + if kind == "anthropic": + from rath.llm.anthropic.client import _resolve_anthropic_key + + return bool(_resolve_anthropic_key(provider)) + if kind == "litellm": + # LiteLLM defers credential resolution to its own per-vendor env + # lookups; a missing rath-level key is not necessarily an error. + return True + # openai-compatible (default) + from rath.llm.openai.client import _resolve_api_key, _resolve_base_url + + base_url = _resolve_base_url(provider) + return bool(_resolve_api_key(provider, base_url)) + except Exception: # noqa: BLE001 -- validation must never raise itself + return False + + +def _reachable_agent_params( + workflow: "Workflow", +) -> "Iterator[tuple[str, AgentParam]]": + """Yield ``(path, AgentParam)`` for every agent in the module tree.""" + + def _walk(node: "Workflow", prefix: str) -> "Iterator[tuple[str, AgentParam]]": + for name, ap in node.named_agents(): + yield _join(prefix, name), ap + for name, child in node.named_children(): + yield from _walk(child, _join(prefix, name)) + + yield from _walk(workflow, "") + + +def _safe_release(store) -> None: # type: ignore[no-untyped-def] + """Release a memory store, swallowing errors so teardown never masks.""" + try: + store.release() + except Exception: # noqa: BLE001 -- teardown must not raise + pass diff --git a/src/rath/flow/workflow.py b/src/rath/flow/workflow.py index 6d2d4ef..b54d50f 100644 --- a/src/rath/flow/workflow.py +++ b/src/rath/flow/workflow.py @@ -5,6 +5,7 @@ from typing import Any from rath.flow.agent_param import AgentParam +from rath.llm.provider import Provider from rath.session.session import Session @@ -22,32 +23,86 @@ def _indent_child_module_repr(body: str, spaces: int = 2) -> str: class Workflow: """Collects attached ``AgentParam`` instances and subclasses run sessions here.""" - __slots__ = ("_agents", "description") + __slots__ = ("_agents", "_children", "description") _agents: dict[str, AgentParam] + _children: dict[str, "Workflow"] description: str def __init__(self, description: str = "") -> None: object.__setattr__(self, "_agents", {}) + object.__setattr__(self, "_children", {}) self.description = description def __setattr__(self, name: str, value: Any) -> None: + # torch.nn.Module-like child registration: AgentParam leaves go into + # _agents; nested Workflow/Agent children go into _children so + # compile() can walk a real module tree (see P5). if isinstance(value, AgentParam): agents: dict[str, AgentParam] = object.__getattribute__(self, "_agents") agents[name] = value + elif isinstance(value, Workflow): + children: dict[str, Workflow] = object.__getattribute__(self, "_children") + children[name] = value super().__setattr__(name, value) def __delattr__(self, name: str) -> None: - agents = object.__getattribute__(self, "_agents") - agents.pop(name, None) + object.__getattribute__(self, "_agents").pop(name, None) + object.__getattribute__(self, "_children").pop(name, None) super().__delattr__(name) def named_agents(self) -> tuple[tuple[str, AgentParam], ...]: - """Agent params registered via attribute assignment.""" + """Agent params registered directly on this workflow (sorted by name).""" agents: dict[str, AgentParam] = object.__getattribute__(self, "_agents") return tuple(sorted(agents.items(), key=lambda x: x[0])) + def named_children(self) -> tuple[tuple[str, "Workflow"], ...]: + """Nested ``Workflow``/``Agent`` children registered by attribute (sorted).""" + + children: dict[str, Workflow] = object.__getattribute__(self, "_children") + return tuple(sorted(children.items(), key=lambda x: x[0])) + + def modules(self) -> "list[Workflow]": + """This workflow followed by every descendant (pre-order, depth-first).""" + + out: list[Workflow] = [self] + for _name, child in self.named_children(): + out.extend(child.modules()) + return out + + def to( + self, + target: Provider | None = None, + *, + provider: str | None = None, + model: str | None = None, + ) -> "Workflow": + """Rebind the provider on **every** registered ``AgentParam`` (chainable). + + Fans :meth:`AgentParam.to` out to each agent from :meth:`named_agents`, + so ``workflow.to(Provider(...))`` / ``workflow.to(provider="name")`` / + ``workflow.to(model="m")`` apply uniformly. A workflow with no agents is + a no-op. A bare positional string is rejected (same rule as + :meth:`AgentParam.to`). + """ + agents: dict[str, AgentParam] = object.__getattribute__(self, "_agents") + for ap in agents.values(): + ap.to(target, provider=provider, model=model) + return self + + def compile(self) -> "object": + """Return a :class:`~rath.flow.compile.CompiledWorkflow` for this workflow. + + A static pass over the module tree (P5.1) that builds a resource + manifest for pre-flight validation, deterministic resource lifecycle, + and inspection. Opt-in and non-breaking: the returned object is callable + exactly like this workflow. Runs no model and materializes no session. + """ + from rath.flow.compile import CompiledWorkflow + + return CompiledWorkflow(self) + def forward(self, session: Session) -> Session: """Subclasses orchestrate Sessions (blocking).""" @@ -62,13 +117,12 @@ def __call__(self, session: Session) -> Session: def __repr__(self) -> str: cls_name = type(self).__name__ - agents = self.named_agents() - if not agents: + entries = list(self.named_agents()) + list(self.named_children()) + if not entries: return f"{cls_name}()" lines = [f"{cls_name}("] - for child_name, agent in agents: - sub = repr(agent) - sub = _indent_child_module_repr(sub, 2) + for child_name, node in entries: + sub = _indent_child_module_repr(repr(node), 2) lines.append(f" ({child_name}): {sub}") lines.append(")") return "\n".join(lines) diff --git a/src/rath/llm/anthropic/client.py b/src/rath/llm/anthropic/client.py index 9601fbb..ef8b704 100644 --- a/src/rath/llm/anthropic/client.py +++ b/src/rath/llm/anthropic/client.py @@ -12,7 +12,6 @@ from __future__ import annotations -import os from collections.abc import Iterator from typing import Any @@ -32,6 +31,7 @@ RateLimitError as _AnthropicRateLimitError, ) +from rath.config.env import env_value from rath.llm.anthropic.create_kwargs import ( build_anthropic_kwargs, build_anthropic_stream_kwargs, @@ -76,16 +76,31 @@ def _config_provider_entry() -> Any: return None +def _resolve_anthropic_key(provider: Provider) -> str: + """Resolve Anthropic ``api_key`` from Provider → env → config.""" + entry = _config_provider_entry() if not provider.api_key else None + return resolve_credential( + provider.api_key, + env_value("ANTHROPIC_API_KEY"), + getattr(entry, "api_key", None), + ) + + +def _resolve_anthropic_base_url(provider: Provider) -> str: + """Resolve Anthropic ``base_url`` from Provider → env → config.""" + entry = _config_provider_entry() if not provider.base_url else None + return resolve_credential( + provider.base_url, + env_value("ANTHROPIC_BASE_URL"), + getattr(entry, "base_url", None), + ) + + class RathAnthropicChatClient: """Thin client around ``anthropic.Anthropic`` messages API (sync + streaming).""" def __init__(self, provider: Provider) -> None: - entry = _config_provider_entry() if not provider.api_key else None - key = resolve_credential( - provider.api_key, - os.environ.get("ANTHROPIC_API_KEY"), - getattr(entry, "api_key", None), - ) + key = _resolve_anthropic_key(provider) if not key: raise ValueError( "No Anthropic api_key found: Provider.api_key is empty, " @@ -96,11 +111,7 @@ def __init__(self, provider: Provider) -> None: ) self._provider = provider init_kw: dict[str, Any] = {"api_key": key} - bu = resolve_credential( - provider.base_url, - os.environ.get("ANTHROPIC_BASE_URL"), - getattr(entry, "base_url", None), - ) + bu = _resolve_anthropic_base_url(provider) if bu: init_kw["base_url"] = bu self._client = Anthropic(**init_kw) @@ -119,7 +130,7 @@ def complete(self, req: RathLLMChatRequest) -> RathLLMChatResponse: """ default_model = ( self._provider.model - or os.environ.get("ANTHROPIC_DEFAULT_MODEL") + or env_value("ANTHROPIC_DEFAULT_MODEL") or getattr(_config_provider_entry(), "model", None) ) kwargs = build_anthropic_kwargs(req, default_model=default_model) @@ -144,7 +155,7 @@ def complete_stream(self, req: RathLLMChatRequest) -> Iterator[RathLLMStreamDelt """ default_model = ( self._provider.model - or os.environ.get("ANTHROPIC_DEFAULT_MODEL") + or env_value("ANTHROPIC_DEFAULT_MODEL") or getattr(_config_provider_entry(), "model", None) ) kwargs = build_anthropic_stream_kwargs(req, default_model=default_model) diff --git a/src/rath/llm/embedding.py b/src/rath/llm/embedding.py index b07122c..ee89675 100644 --- a/src/rath/llm/embedding.py +++ b/src/rath/llm/embedding.py @@ -18,7 +18,6 @@ from __future__ import annotations -import os from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any, Sequence @@ -30,6 +29,7 @@ RateLimitError, ) +from rath.config.env import env_value from rath.llm.credentials import resolve_credential from rath.llm.retry import retry_with_backoff @@ -136,17 +136,40 @@ def from_config( return replace(base, **overrides) +def _config_embedding_entry() -> Any: + """First config entry for embeddings: ``embedding_provider`` else default. + + Returns ``None`` when no config file / no suitable entry. Mirrors the + chat clients' config fallback so an ``EmbeddingProvider()`` with no + api_key/env still resolves from ``~/.openrath/config.json`` (P2.3). + """ + try: + from rath.config.store import ConfigStore + + cfg = ConfigStore.load().config.llm + except (FileNotFoundError, RuntimeError): + return None + name = getattr(cfg, "embedding_provider", None) or cfg.default_provider + if name is None: + return None + return cfg.providers.get(name) + + def _resolve_api_key(provider: EmbeddingProvider) -> str: + entry = _config_embedding_entry() if not provider.api_key else None return resolve_credential( provider.api_key, - os.environ.get("OPENAI_API_KEY"), + env_value("OPENAI_API_KEY"), + getattr(entry, "api_key", None), ) def _resolve_base_url(provider: EmbeddingProvider) -> str: + entry = _config_embedding_entry() if not provider.base_url else None return resolve_credential( provider.base_url, - os.environ.get("OPENAI_BASE_URL"), + env_value("OPENAI_BASE_URL"), + getattr(entry, "base_url", None), ) diff --git a/src/rath/llm/litellm/client.py b/src/rath/llm/litellm/client.py index 6a0ef30..4644a0a 100644 --- a/src/rath/llm/litellm/client.py +++ b/src/rath/llm/litellm/client.py @@ -14,7 +14,6 @@ from __future__ import annotations -import os from collections.abc import Iterator from typing import Any @@ -35,6 +34,7 @@ Timeout as _LiteLLMTimeout, ) +from rath.config.env import env_value from rath.llm.chat_request import RathLLMChatRequest from rath.llm.chat_response import RathLLMChatResponse, RathLLMStreamDelta from rath.llm.credentials import resolve_credential @@ -52,6 +52,17 @@ _LiteLLMInternalServerError, ) + +def _resolve_litellm_key(provider: Provider) -> str: + """Resolve LiteLLM ``api_key`` from Provider → env.""" + return resolve_credential(provider.api_key, env_value("LITELLM_API_KEY")) + + +def _resolve_litellm_base(provider: Provider) -> str: + """Resolve LiteLLM ``api_base`` from Provider → env.""" + return resolve_credential(provider.base_url, env_value("LITELLM_API_BASE")) + + __all__ = ["RathLiteLLMChatClient", "LITELLM_RETRYABLE"] @@ -65,16 +76,10 @@ class RathLiteLLMChatClient: """ def __init__(self, provider: Provider) -> None: - key = resolve_credential( - provider.api_key, - os.environ.get("LITELLM_API_KEY"), - ) + key = _resolve_litellm_key(provider) self._provider = provider self._api_key = key or None - bu = resolve_credential( - provider.base_url, - os.environ.get("LITELLM_API_BASE"), - ) + bu = _resolve_litellm_base(provider) self._api_base = bu or None @property @@ -95,7 +100,7 @@ def complete(self, req: RathLLMChatRequest) -> RathLLMChatResponse: Transient errors are retried per :attr:`Provider.retry_max_attempts` / :attr:`Provider.retry_base_seconds`. """ - default_model = self._provider.model or os.environ.get("LITELLM_MODEL") + default_model = self._provider.model or env_value("LITELLM_MODEL") kwargs = to_create_kwargs(req, default_model=default_model) self._inject_litellm_kwargs(kwargs) @@ -117,7 +122,7 @@ def complete_stream(self, req: RathLLMChatRequest) -> Iterator[RathLLMStreamDelt retried; once the iterator starts producing chunks, retries are no longer possible. """ - default_model = self._provider.model or os.environ.get("LITELLM_MODEL") + default_model = self._provider.model or env_value("LITELLM_MODEL") kwargs = to_create_kwargs_stream(req, default_model=default_model) self._inject_litellm_kwargs(kwargs) diff --git a/src/rath/llm/openai/client.py b/src/rath/llm/openai/client.py index 5612caa..124f94c 100644 --- a/src/rath/llm/openai/client.py +++ b/src/rath/llm/openai/client.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from typing import Any, Iterator, cast from openai import ( @@ -14,6 +13,7 @@ RateLimitError, ) +from rath.config.env import env_value from rath.llm.chat_request import RathLLMChatRequest from rath.llm.chat_response import ( RathLLMChatResponse, @@ -74,8 +74,8 @@ def _resolve_base_url(provider: Provider) -> str: entry = _config_provider_entry() if not provider.base_url else None return resolve_credential( provider.base_url, - os.environ.get("OPENAI_BASE_URL"), - os.environ.get("AZURE_OPENAI_ENDPOINT"), + env_value("OPENAI_BASE_URL"), + env_value("AZURE_OPENAI_ENDPOINT"), getattr(entry, "base_url", None), ) @@ -87,15 +87,15 @@ def _resolve_api_key(provider: Provider, base_url: str) -> str: if _is_azure_endpoint(base_url): return resolve_credential( provider.api_key, - os.environ.get("AZURE_OPENAI_API_KEY"), - os.environ.get("AZURE_API_KEY"), - os.environ.get("OPENAI_API_KEY"), + env_value("AZURE_OPENAI_API_KEY"), + env_value("AZURE_API_KEY"), + env_value("OPENAI_API_KEY"), config_key, ) return resolve_credential( provider.api_key, - os.environ.get("OPENAI_API_KEY"), - os.environ.get("AZURE_OPENAI_API_KEY"), + env_value("OPENAI_API_KEY"), + env_value("AZURE_OPENAI_API_KEY"), config_key, ) @@ -147,8 +147,8 @@ def __init__(self, provider: Provider) -> None: use_azure_legacy = _is_azure_endpoint(base_url) and "/openai/v1" not in base_url if use_azure_legacy: api_version = ( - os.environ.get("OPENAI_API_VERSION") - or os.environ.get("AZURE_OPENAI_API_VERSION") + env_value("OPENAI_API_VERSION") + or env_value("AZURE_OPENAI_API_VERSION") or "2024-10-21" ) self._client = AzureOpenAI( @@ -175,7 +175,7 @@ def complete(self, req: RathLLMChatRequest) -> RathLLMChatResponse: """ default_model = ( self._provider.model - or os.environ.get("OPENAI_DEFAULT_MODEL") + or env_value("OPENAI_DEFAULT_MODEL") or _config_default_model() ) kwargs = to_create_kwargs(req, default_model=default_model) @@ -200,7 +200,7 @@ def complete_stream(self, req: RathLLMChatRequest) -> Iterator[RathLLMStreamDelt """ default_model = ( self._provider.model - or os.environ.get("OPENAI_DEFAULT_MODEL") + or env_value("OPENAI_DEFAULT_MODEL") or _config_default_model() ) kwargs = to_create_kwargs_stream(req, default_model=default_model) diff --git a/src/rath/llm/registry.py b/src/rath/llm/registry.py index 4c85bb5..5fc70c6 100644 --- a/src/rath/llm/registry.py +++ b/src/rath/llm/registry.py @@ -23,11 +23,40 @@ "register_chat_client", "chat_client_for", "registered_kinds", + "clear_client_cache", ] ChatClientFactory = Callable[[Provider], ChatClient] _FACTORIES: dict[str, ChatClientFactory] = {} + +# Constructed-client cache. Keyed on the provider's HTTP-identity fields so a +# reused provider does not rebuild the SDK client on every loop run. Only +# providers with an EXPLICIT api_key are cached: a provider that leaves api_key +# empty resolves credentials from env/config at construction time, and env can +# change within a process, so caching such a client could serve a stale one. +_CLIENT_CACHE: dict[tuple[str, str, str, str], ChatClient] = {} +_CACHE_LOCK = threading.Lock() + + +def _cache_key(provider: Provider) -> tuple[str, str, str, str] | None: + """Identity key for caching, or ``None`` when the provider must not be cached.""" + if not provider.api_key: + return None # env/config fallback — never cache (could go stale) + return ( + provider.provider_kind or "openai", + provider.base_url or "", + provider.api_key, + provider.model or "", + ) + + +def clear_client_cache() -> None: + """Drop all cached clients (call after rebinding credentials / in tests).""" + with _CACHE_LOCK: + _CLIENT_CACHE.clear() + + # Guards reads from / writes to ``_FACTORIES`` only. Deliberately does # **not** wrap ``factory(provider)`` in :func:`chat_client_for` — built-in # factories (``RathOpenAIChatClient``, ``RathAnthropicChatClient``) are @@ -57,6 +86,12 @@ def chat_client_for(provider: Provider) -> ChatClient: raise ``ValueError`` listing what is currently registered. """ kind = provider.provider_kind or "openai" + key = _cache_key(provider) + if key is not None: + with _CACHE_LOCK: + cached = _CLIENT_CACHE.get(key) + if cached is not None: + return cached with _FACTORIES_LOCK: try: factory = _FACTORIES[kind] @@ -65,7 +100,12 @@ def chat_client_for(provider: Provider) -> ChatClient: f"unknown provider_kind={kind!r}; " f"registered kinds: {sorted(_FACTORIES)}", ) from e - return factory(provider) + client = factory(provider) + if key is not None: + with _CACHE_LOCK: + # Another thread may have built one concurrently; keep the first. + client = _CLIENT_CACHE.setdefault(key, client) + return client def registered_kinds() -> tuple[str, ...]: diff --git a/src/rath/llm/vlm.py b/src/rath/llm/vlm.py index 4e10b2d..a774fbb 100644 --- a/src/rath/llm/vlm.py +++ b/src/rath/llm/vlm.py @@ -16,7 +16,6 @@ import base64 import mimetypes -import os from dataclasses import dataclass, replace from pathlib import Path from typing import TYPE_CHECKING, Any @@ -29,6 +28,7 @@ RateLimitError, ) +from rath.config.env import env_value from rath.llm.credentials import resolve_credential from rath.llm.retry import retry_with_backoff @@ -113,17 +113,40 @@ def from_config( return replace(base, **overrides) +def _config_vlm_entry() -> Any: + """First config entry for VLM: ``vlm_provider`` else default. + + Returns ``None`` when no config file / no suitable entry. Mirrors the + chat clients' config fallback so a ``VLMProvider()`` with no api_key/env + still resolves from ``~/.openrath/config.json`` (P2.3). + """ + try: + from rath.config.store import ConfigStore + + cfg = ConfigStore.load().config.llm + except (FileNotFoundError, RuntimeError): + return None + name = getattr(cfg, "vlm_provider", None) or cfg.default_provider + if name is None: + return None + return cfg.providers.get(name) + + def _resolve_api_key(provider: VLMProvider) -> str: + entry = _config_vlm_entry() if not provider.api_key else None return resolve_credential( provider.api_key, - os.environ.get("OPENAI_API_KEY"), + env_value("OPENAI_API_KEY"), + getattr(entry, "api_key", None), ) def _resolve_base_url(provider: VLMProvider) -> str: + entry = _config_vlm_entry() if not provider.base_url else None return resolve_credential( provider.base_url, - os.environ.get("OPENAI_BASE_URL"), + env_value("OPENAI_BASE_URL"), + getattr(entry, "base_url", None), ) diff --git a/src/rath/memory/adapters/local.py b/src/rath/memory/adapters/local.py index c0c5bd6..c76602c 100644 --- a/src/rath/memory/adapters/local.py +++ b/src/rath/memory/adapters/local.py @@ -63,6 +63,7 @@ memory_uri_prefix, to_public_uri, ) +from rath.persistence.atomic import atomic_write_json, atomic_write_text logger = logging.getLogger(__name__) @@ -228,7 +229,7 @@ def _dispatch_write(self, bound: "_LocalHandle", op: MemoryOpWrite) -> MemoryRes target = resolved.with_suffix(_MD_SUFFIX) target.parent.mkdir(parents=True, exist_ok=True) data = op.content - target.write_text(data, encoding="utf-8") + atomic_write_text(target, data) # Stale embedding/meta sidecars must not persist past a content rewrite. for suffix in _HIDDEN_SUFFIXES: sidecar = resolved.with_suffix(suffix) @@ -345,7 +346,7 @@ def _dispatch_resource( meta_lines.extend(["", "## Reason", op.reason]) if op.instruction: meta_lines.extend(["", "## Instruction", op.instruction]) - meta_path.write_text("\n".join(meta_lines) + "\n", encoding="utf-8") + atomic_write_text(meta_path, "\n".join(meta_lines), newline=True) return MemoryWriteResult( uri=f"{target_uri.rstrip('/')}/{sha}", @@ -368,10 +369,7 @@ def _dispatch_commit( commit_root.mkdir(parents=True, exist_ok=True) archive_path = commit_root / "messages.json" normalized = [_normalize_message(m) for m in op.messages] - archive_path.write_text( - json.dumps(normalized, ensure_ascii=False, indent=2), - encoding="utf-8", - ) + atomic_write_json(archive_path, normalized) archived_uri = ( f"{MEMORY_URI_PREFIX}session/{op.session_id}/commits/{stamp}/messages.json" ) @@ -398,7 +396,7 @@ def _dispatch_commit( continue target = sub.with_suffix(_MD_SUFFIX) target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content, encoding="utf-8") + atomic_write_text(target, content) return MemoryCommitResult( task_id=None, archived_uri=archived_uri, @@ -429,10 +427,7 @@ def _touch_meta( if not update_only: meta["embedding_provider"] = options.get("embedding_provider") meta["vlm_provider"] = options.get("vlm_provider") - meta_path.write_text( - json.dumps(meta, indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + atomic_write_json(meta_path, meta) def _resolve_uri( @@ -765,10 +760,7 @@ def _load_vec(sidecar: Path, *, expected_model: str) -> list[float] | None: def _store_vec(sidecar: Path, model: str, vec: Any) -> None: payload = {"model": model, "vector": [float(x) for x in vec]} sidecar.parent.mkdir(parents=True, exist_ok=True) - sidecar.write_text( - json.dumps(payload, ensure_ascii=False), - encoding="utf-8", - ) + atomic_write_json(sidecar, payload, indent=None) def _cosine(u: Any, v: list[float]) -> float: diff --git a/src/rath/persistence/__init__.py b/src/rath/persistence/__init__.py new file mode 100644 index 0000000..9100b4e --- /dev/null +++ b/src/rath/persistence/__init__.py @@ -0,0 +1,31 @@ +"""Cross-cutting persistence helpers shared by the session, backend, and +memory planes. + +The one public primitive today is the atomic-write helper +(:func:`~rath.persistence.atomic.atomic_write_text` / +:func:`~rath.persistence.atomic.atomic_write_json`): a temp-file + +``os.replace`` writer that is durable on POSIX and Windows and safe under +concurrent writers to the same path. +""" + +from __future__ import annotations + +from rath.persistence.atomic import atomic_write_json, atomic_write_text +from rath.persistence.gc import GCReport, gc +from rath.persistence.manifest import ( + LAYOUT_VERSION, + ManifestVersionError, + check_manifest, + ensure_manifest, +) + +__all__ = [ + "atomic_write_text", + "atomic_write_json", + "gc", + "GCReport", + "ensure_manifest", + "check_manifest", + "ManifestVersionError", + "LAYOUT_VERSION", +] diff --git a/src/rath/persistence/atomic.py b/src/rath/persistence/atomic.py new file mode 100644 index 0000000..82d8d4f --- /dev/null +++ b/src/rath/persistence/atomic.py @@ -0,0 +1,151 @@ +"""Atomic file writes shared across the persistence planes. + +Writing config, registry, and memory sidecar files with a bare +``path.write_text(...)`` has two problems: + +1. **Not atomic** — a crash or exception mid-write leaves a truncated file + that later fails to parse. +2. **Not concurrency-safe on Windows** — even the temp-file + ``os.replace`` + idiom raises :class:`PermissionError` when a second thread/process holds + the destination open during the replace, because Windows rejects a rename + onto an open file for a brief sharing window. + +:func:`atomic_write_text` / :func:`atomic_write_json` solve both: they write +to a uniquely-named temp file in the target directory, ``os.replace`` it into +place (atomic on POSIX and Windows), serialize concurrent writers to the same +path through a process-global path-keyed lock, and retry the short Windows +sharing-violation window before giving up. + +These are process-local guarantees. Cross-*process* exclusion for +append-style writers is a separate concern handled by +:class:`rath.session.persistence._lock.FileLock`. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import threading +import time +from pathlib import Path +from typing import Any + +__all__ = ["atomic_write_text", "atomic_write_json"] + +# Process-global, path-keyed locks so two ConfigStore/registry instances that +# point at the same file cannot race their os.replace calls. Keyed by the +# resolved target path. The registry itself is guarded by ``_LOCKS_GUARD``. +_LOCKS: dict[Path, threading.Lock] = {} +_LOCKS_GUARD = threading.Lock() + +# Windows only: os.replace onto a concurrently-held target raises +# PermissionError for a brief window. Retry a handful of times with a short +# backoff before surfacing the error. +_WIN_REPLACE_ATTEMPTS = 10 +_WIN_REPLACE_BACKOFF_S = 0.02 + + +def _lock_for(path: Path) -> threading.Lock: + with _LOCKS_GUARD: + lock = _LOCKS.get(path) + if lock is None: + lock = threading.Lock() + _LOCKS[path] = lock + return lock + + +def _replace_with_retry(src: Path, dst: Path) -> None: + """``os.replace(src, dst)`` with a Windows sharing-violation retry loop.""" + if not sys.platform.startswith("win"): + os.replace(src, dst) + return + last: OSError | None = None + for attempt in range(_WIN_REPLACE_ATTEMPTS): + try: + os.replace(src, dst) + return + except PermissionError as e: # target briefly held by another writer + last = e + time.sleep(_WIN_REPLACE_BACKOFF_S * (attempt + 1)) + assert last is not None + raise last + + +def atomic_write_text( + path: Path | str, + text: str, + *, + newline: bool = False, + encoding: str = "utf-8", + mode: int | None = None, +) -> None: + """Atomically write ``text`` to ``path``. + + Creates the parent directory if missing. Writes a uniquely-named temp + file in the same directory, then ``os.replace`` it into place. Concurrent + writers to the same resolved path are serialized; the Windows + sharing-violation window is retried. On any failure the temp file is + removed and the original target is left untouched. + + ``newline`` appends a trailing ``"\\n"`` when the caller has not already. + ``mode`` (e.g. ``0o600``) restricts the final file on POSIX; ignored on + Windows (matching :func:`rath.config.secrets.chmod_user_only`). + """ + target = Path(path).resolve() + target.parent.mkdir(parents=True, exist_ok=True) + if newline and not text.endswith("\n"): + text = text + "\n" + + lock = _lock_for(target) + with lock: + fd = tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + dir=target.parent, + prefix=".atomic_", + suffix=".tmp", + delete=False, + ) + tmp_path = Path(fd.name) + try: + fd.write(text) + fd.flush() + os.fsync(fd.fileno()) + fd.close() + _replace_with_retry(tmp_path, target) + except BaseException: + try: + fd.close() + except Exception: # noqa: BLE001 -- already closing down + pass + tmp_path.unlink(missing_ok=True) + raise + if mode is not None and not sys.platform.startswith("win"): + try: + os.chmod(target, mode) + except OSError: # pragma: no cover -- racing fs / unsupported + pass + + +def atomic_write_json( + path: Path | str, + payload: Any, + *, + indent: int | None = 2, + sort_keys: bool = False, + ensure_ascii: bool = False, + mode: int | None = None, +) -> None: + """Atomically write ``payload`` as JSON to ``path``. + + Serialization happens **before** the temp file is created, so an + unserializable payload raises without touching the filesystem (no temp + debris, original target intact). Otherwise defers to + :func:`atomic_write_text`. + """ + text = json.dumps( + payload, indent=indent, sort_keys=sort_keys, ensure_ascii=ensure_ascii + ) + atomic_write_text(path, text, newline=True, mode=mode) diff --git a/src/rath/persistence/gc.py b/src/rath/persistence/gc.py new file mode 100644 index 0000000..65b4333 --- /dev/null +++ b/src/rath/persistence/gc.py @@ -0,0 +1,181 @@ +"""Unified retention / garbage collection across the persistence planes. + +Each plane already had its own ``prune_*`` (sessions, local + remote sandboxes, +local memory stores), but there was no single entry point and — importantly — +nothing pruned the **memory commits archive** (``memory/local//session/ +/commits//``), which grew without bound on every ``commit_memory``. + +``gc(older_than=..., dry_run=...)`` gives one opt-in sweep over all of them and +returns a :class:`GCReport` of what was (or would be) removed. ``dry_run=True`` +(the default) reports without deleting. Every removed path is verified to live +under the resolved data root before deletion, so a bug can never delete outside +``.openrath/``. +""" + +from __future__ import annotations + +import logging +import shutil +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from uuid import UUID + +__all__ = ["GCReport", "gc"] + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class GCReport: + """What a :func:`gc` sweep removed (or would remove, in dry-run).""" + + sessions: list[UUID] = field(default_factory=list) + local_sandboxes: list[UUID] = field(default_factory=list) + remote_sandboxes: list[UUID] = field(default_factory=list) + memory_stores: list[UUID] = field(default_factory=list) + memory_commits: list[Path] = field(default_factory=list) + dry_run: bool = True + + +def _data_root() -> Path: + from rath.config.paths import resolve_config_dir + + return resolve_config_dir().resolve() + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root) + return True + except ValueError: + return False + + +def _collect_old_commits(root: Path, cutoff: datetime) -> list[Path]: + """Find ``.../commits//`` dirs older than ``cutoff`` under the memory plane.""" + from rath.memory.persistence.paths import local_memory_root + + mem_root = local_memory_root() + if not mem_root.is_dir(): + return [] + found: list[Path] = [] + # memory/local//session//commits// + for commits_dir in mem_root.glob("*/session/*/commits/*"): + if not commits_dir.is_dir(): + continue + if not _is_within(commits_dir, root): # defense-in-depth + continue + try: + mtime = datetime.fromtimestamp(commits_dir.stat().st_mtime, tz=timezone.utc) + except OSError: + continue + if mtime < cutoff: + found.append(commits_dir) + return sorted(found) + + +def gc(*, older_than: timedelta, dry_run: bool = True) -> GCReport: + """Sweep prunable artifacts older than ``older_than`` across all planes. + + With ``dry_run=True`` (default) nothing is deleted — the report lists what + *would* be removed. With ``dry_run=False`` the existing per-plane prune + helpers run and the memory commits archive is trimmed. Deletion is confined + to the resolved data root. + """ + root = _data_root() + cutoff = datetime.now(timezone.utc) - older_than + report = GCReport(dry_run=dry_run) + + # --- memory commits archive (new; previously unbounded) ------------------ + commit_dirs = _collect_old_commits(root, cutoff) + report.memory_commits = commit_dirs + if not dry_run: + for d in commit_dirs: + if _is_within(d, root): + shutil.rmtree(d, ignore_errors=True) + + # --- other planes -------------------------------------------------------- + if dry_run: + report.sessions = _dry_sessions(cutoff) + report.local_sandboxes = _dry_local_sandboxes(cutoff) + report.remote_sandboxes = _dry_remote_sandboxes(cutoff) + report.memory_stores = _dry_memory_stores(cutoff) + else: + from rath.backend.persistence.registry import PersistentSandboxRegistry + from rath.memory.persistence.registry import PersistentMemoryRegistry + from rath.session.persistence.loader import prune_sessions + + report.sessions = prune_sessions(older_than=older_than) + sandbox_reg = PersistentSandboxRegistry() + report.local_sandboxes = sandbox_reg.prune_local(older_than=older_than) + report.remote_sandboxes = sandbox_reg.prune_remote(older_than=older_than) + report.memory_stores = PersistentMemoryRegistry().prune_local( + older_than=older_than + ) + + return report + + +# --- dry-run enumerators (mirror each prune's cutoff without deleting) ------- + + +def _dry_sessions(cutoff: datetime) -> list[UUID]: + from rath.session.persistence.loader import list_persisted_sessions + + out: list[UUID] = [] + for meta in list_persisted_sessions(): + created = meta.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + if created < cutoff: + out.append(meta.id) + return out + + +def _dry_local_sandboxes(cutoff: datetime) -> list[UUID]: + from rath.backend.persistence.paths import local_sandbox_dir + from rath.backend.persistence.registry import PersistentSandboxRegistry + + out: list[UUID] = [] + for sid in PersistentSandboxRegistry().list_local(): + try: + mtime = datetime.fromtimestamp( + local_sandbox_dir(sid).stat().st_mtime, tz=timezone.utc + ) + except OSError: + continue + if mtime < cutoff: + out.append(sid) + return out + + +def _dry_remote_sandboxes(cutoff: datetime) -> list[UUID]: + from rath.backend.persistence.registry import PersistentSandboxRegistry + + reg = PersistentSandboxRegistry() + out: list[UUID] = [] + for rec in reg.list_remote(): + last = rec.last_used_at + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + if last < cutoff: + out.append(rec.id) + return out + + +def _dry_memory_stores(cutoff: datetime) -> list[UUID]: + from rath.memory.persistence.paths import local_store_dir + from rath.memory.persistence.registry import PersistentMemoryRegistry + + out: list[UUID] = [] + for sid in PersistentMemoryRegistry().list_local(): + try: + mtime = datetime.fromtimestamp( + local_store_dir(sid).stat().st_mtime, tz=timezone.utc + ) + except OSError: + continue + if mtime < cutoff: + out.append(sid) + return out diff --git a/src/rath/persistence/manifest.py b/src/rath/persistence/manifest.py new file mode 100644 index 0000000..fdd9f10 --- /dev/null +++ b/src/rath/persistence/manifest.py @@ -0,0 +1,107 @@ +"""Root layout manifest for the ``.openrath/`` data root. + +Historically each plane carried its own ``SCHEMA_VERSION`` (config, backend +spec-json, memory meta) with no coordination and no record of the overall +on-disk *layout*. ``manifest.json`` at the data root records the layout version +plus a snapshot of every plane's schema version, so: + +- an upgrade can detect an older/newer layout deterministically; +- a newer OpenRath's data root is refused with a clear error rather than being + silently misread by an older install. + +The manifest is intentionally tiny and additive. Writing it is best-effort at +the persistence boundary; :func:`check_manifest` is a no-op when it is absent +(fresh or legacy root), so it never breaks existing installs. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from rath.persistence.atomic import atomic_write_json + +__all__ = [ + "LAYOUT_VERSION", + "MANIFEST_FILENAME", + "ManifestVersionError", + "plane_schema_versions", + "read_manifest", + "ensure_manifest", + "check_manifest", +] + +#: Bump when the overall on-disk directory layout changes (not when a single +#: plane's schema changes — those are tracked per-plane below). +LAYOUT_VERSION = 1 + +MANIFEST_FILENAME = "manifest.json" + + +class ManifestVersionError(RuntimeError): + """Raised when the on-disk layout version is newer than this install.""" + + +def plane_schema_versions() -> dict[str, int]: + """Snapshot each plane's current schema version. + + Imported lazily so this module has no import-time dependency on the + config/backend/memory packages (avoids import cycles). + """ + from rath.backend.persistence.spec_json import SCHEMA_VERSION as BACKEND_V + from rath.config.schema import SCHEMA_VERSION as CONFIG_V + from rath.memory.adapters.local import META_SCHEMA_VERSION as MEMORY_V + + return {"config": CONFIG_V, "backend": BACKEND_V, "memory": MEMORY_V} + + +def _manifest_path(root: Path) -> Path: + return root / MANIFEST_FILENAME + + +def read_manifest(root: Path) -> dict[str, Any] | None: + """Return the parsed manifest, or ``None`` when absent/unreadable.""" + path = _manifest_path(root) + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + return data if isinstance(data, dict) else None + + +def ensure_manifest(root: Path) -> dict[str, Any]: + """Create the manifest if missing; return the effective manifest. + + Idempotent: an existing current-layout manifest is refreshed with the + latest per-plane schema versions but keeps its layout version. + """ + existing = read_manifest(root) + manifest: dict[str, Any] = { + "layout_version": LAYOUT_VERSION, + "planes": plane_schema_versions(), + } + if existing == manifest: + return existing + atomic_write_json(_manifest_path(root), manifest) + return manifest + + +def check_manifest(root: Path) -> None: + """Raise :class:`ManifestVersionError` if the layout is newer than ours. + + No-op when the manifest is absent (fresh or legacy root) — this must never + break an install that predates the manifest. + """ + data = read_manifest(root) + if data is None: + return + on_disk = data.get("layout_version") + if isinstance(on_disk, int) and on_disk > LAYOUT_VERSION: + raise ManifestVersionError( + f"{_manifest_path(root)} has layout_version={on_disk}, newer than this " + f"OpenRath (supports {LAYOUT_VERSION}); upgrade OpenRath to read this " + f"data root safely." + ) diff --git a/src/rath/session/compress.py b/src/rath/session/compress.py index 0f3e113..b848148 100644 --- a/src/rath/session/compress.py +++ b/src/rath/session/compress.py @@ -33,7 +33,7 @@ def run_session_compress( user_session: Session, agent_session: Session, *, - agent_provider: Provider, + agent_provider: Provider | None = None, executor: SessionLoopExecutor | None = None, compress_instruction: str | None = None, register_sessions: bool = True, @@ -67,6 +67,16 @@ def run_session_compress( ``persist_path``) with a trailer. """ + # Explicit provider wins; otherwise fall back to the user session's bound + # provider (session.to(Provider(...))). Mirrors run_session_loop (P4.4). + if agent_provider is None: + agent_provider = user_session.provider + if agent_provider is None: + raise ValueError( + "no provider for run_session_compress: pass agent_provider=Provider(...) " + "or bind one on the session via session.to(Provider(...))" + ) + # Join lazy input sessions before reading their chunk_table. if user_session._pending is not None: user_session.synchronize() diff --git a/src/rath/session/loop.py b/src/rath/session/loop.py index cf025c5..500b81f 100644 --- a/src/rath/session/loop.py +++ b/src/rath/session/loop.py @@ -405,7 +405,7 @@ def run_session_loop( user_session: Session, agent_session: Session, *, - agent_provider: Provider, + agent_provider: Provider | None = None, tools: list[FlowToolCall] | None = None, executor: SessionLoopExecutor | None = None, max_tool_rounds: int = 64, @@ -449,6 +449,21 @@ def run_session_loop( executes the loop on a background asyncio loop so multiple ``run_session_loop`` calls can overlap. """ + # Resolve the effective provider. An explicit ``agent_provider`` (as passed + # by Agent/AgentParam) always wins; otherwise fall back to a provider bound + # on the user session via ``session.to(Provider(...))`` (P4.3). This keeps + # every existing Agent call unchanged while enabling raw/CLI use that placed + # the provider on the session. + effective_provider = ( + agent_provider if agent_provider is not None else user_session.provider + ) + if effective_provider is None: + raise ValueError( + "no provider for run_session_loop: pass agent_provider=Provider(...) " + "or bind one on the session via session.to(Provider(...))" + ) + agent_provider = effective_provider + # Join lazy input sessions before submitting the loop coroutine. if user_session._pending is not None: user_session.synchronize() diff --git a/src/rath/session/select.py b/src/rath/session/select.py index 821a442..923b9b4 100644 --- a/src/rath/session/select.py +++ b/src/rath/session/select.py @@ -51,7 +51,7 @@ def select_session( user_session: Session, agent_session: Session, *workflow_descriptions: str, - agent_provider: Provider, + agent_provider: Provider | None = None, executor: SessionLoopExecutor | None = None, ) -> tuple[int, str]: """LLM picks the best-matching description for the current user session. @@ -69,6 +69,16 @@ def select_session( if not workflow_descriptions: return (-1, "") + # Explicit provider wins; otherwise fall back to the user session's bound + # provider (session.to(Provider(...))). Mirrors run_session_loop (P4.4). + if agent_provider is None: + agent_provider = user_session.provider + if agent_provider is None: + raise ValueError( + "no provider for select_session: pass agent_provider=Provider(...) " + "or bind one on the session via session.to(Provider(...))" + ) + # Join lazy input sessions before reading their chunk_table. if user_session._pending is not None: user_session.synchronize() diff --git a/src/rath/session/session.py b/src/rath/session/session.py index 336c6b2..f8f20e4 100644 --- a/src/rath/session/session.py +++ b/src/rath/session/session.py @@ -22,6 +22,7 @@ from rath.backend import BackendSandbox, BackendSandboxSpec, get from rath.llm import add_usage from rath.llm.chat_response import RathLLMTokenUsage +from rath.llm.provider import Provider from rath.session.chunk import ChunkKind, ChunkRow, ChunkTable from rath.session.graph.kind import LineageKind from rath.session.graph.legacy import SessionLineage @@ -172,6 +173,7 @@ class Session: "sandbox", "sandbox_backend", "_sandbox_open_spec", + "provider", "_cm_depth", "lineage", "parent_session_ids", @@ -193,6 +195,7 @@ def __init__( sandbox: BackendSandbox | None = None, sandbox_backend: str | None = None, _sandbox_open_spec: BackendSandboxSpec | None = None, + provider: Provider | None = None, _cm_depth: int = 0, lineage: SessionLineage | None = None, parent_session_ids: tuple[UUID, ...] = (), @@ -206,6 +209,10 @@ def __init__( self.sandbox = sandbox self.sandbox_backend = sandbox_backend self._sandbox_open_spec = _sandbox_open_spec + # Session-level LLM provider (a plain value, no lifecycle/refcount). + # Used by run_session_loop only as a FALLBACK when no agent_provider is + # passed (Agent/AgentParam provider always wins). See P4.3/P4.4. + self.provider = provider self._cm_depth = _cm_depth self.lineage = lineage self.parent_session_ids = parent_session_ids @@ -381,11 +388,41 @@ def create(cls, kind: str = "user", text: str = "") -> Session: def to( self, - backend: str = "local", + backend: str | Provider = "local", *, spec: BackendSandboxSpec | str | None = None, + provider: str | None = None, ) -> Session: - """Close any current handle, set target backend, and return ``self`` (chainable).""" + """Place this session on a sandbox backend **or** bind an LLM provider. + + Type-dispatched so one verb covers both resource kinds, without + breaking the historical sandbox meaning: + + - ``session.to("local", spec=...)`` — sandbox placement (unchanged). A + bare positional **string** is always a sandbox backend name. + - ``session.to(Provider(...))`` — bind a session-level provider (a plain + value; no lifecycle). Does not touch the sandbox. + - ``session.to(provider="name")`` — bind a provider from a config preset, + resolved lazily via :meth:`Provider.from_config`. + + The session provider is only a **fallback** for ``run_session_loop`` + when no ``agent_provider`` is supplied; an Agent's provider always wins. + + Returns ``self`` (chainable). + """ + # Provider binding path (positional Provider or provider= name). + if isinstance(backend, Provider) or provider is not None: + if isinstance(backend, Provider) and provider is not None: + raise ValueError( + "pass either a Provider positionally or provider=, not both" + ) + if isinstance(backend, Provider): + self.provider = backend + else: + assert provider is not None + self.provider = Provider.from_config(provider) + return self + # Sandbox placement path (bare string backend name; the default). self.close_sandbox() self.sandbox_backend = backend self._sandbox_open_spec = _coerce_sandbox_open_spec(spec) @@ -469,6 +506,7 @@ def fork(self) -> "Session": chunk_table=ChunkTable(rows=rows), sandbox_backend=self.sandbox_backend, _sandbox_open_spec=self._sandbox_open_spec, + provider=self.provider, ) if self.sandbox is not None and not self.sandbox.closed: forked.bind_sandbox(self.sandbox) @@ -487,6 +525,7 @@ def detach(self) -> "Session": chunk_table=ChunkTable(rows=rows), sandbox_backend=self.sandbox_backend, _sandbox_open_spec=self._sandbox_open_spec, + provider=self.provider, ) if self.sandbox is not None and not self.sandbox.closed: detached.bind_sandbox(self.sandbox) @@ -528,6 +567,7 @@ def merge(self, other: "Session") -> "Session": chunk_table=ChunkTable(rows=merged_rows), sandbox_backend=self.sandbox_backend, _sandbox_open_spec=self._sandbox_open_spec, + provider=self.provider, cumulative_usage=merged_usage, ) if self.sandbox is not None and not self.sandbox.closed: diff --git a/tests/backends/persistence/test_atomic_registry.py b/tests/backends/persistence/test_atomic_registry.py new file mode 100644 index 0000000..0119031 --- /dev/null +++ b/tests/backends/persistence/test_atomic_registry.py @@ -0,0 +1,77 @@ +"""P3.2 — backend remote-sandbox registry writes are atomic (no bare write_text). + +Real filesystem. Verifies record_remote / touch_remote leave a complete, +parseable file and no ``.atomic_*.tmp`` debris, and that concurrent record +calls to the same id do not raise on Windows (the atomic primitive serializes ++ retries the replace). +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path +from uuid import uuid4 + +from rath.backend.persistence.registry import PersistentSandboxRegistry + + +def test_record_remote_is_atomic_and_clean(_isolate_openrath_home: Path) -> None: + reg = PersistentSandboxRegistry() + sid = reg.record_remote("opensandbox", "native-123") + rec = reg.load_remote(sid) + assert rec is not None and rec.remote_id == "native-123" + + # File is complete JSON, no temp debris in the opensandbox dir. + from rath.backend.persistence.paths import opensandbox_index_path + + path = opensandbox_index_path(sid) + json.loads(path.read_text(encoding="utf-8")) # parses + debris = [p.name for p in path.parent.glob(".atomic_*")] + assert debris == [] + + +def test_touch_remote_is_atomic(_isolate_openrath_home: Path) -> None: + reg = PersistentSandboxRegistry() + sid = reg.record_remote("opensandbox", "native-xyz") + before = reg.load_remote(sid) + assert before is not None + reg.touch_remote(sid) + after = reg.load_remote(sid) + assert after is not None + assert after.remote_id == "native-xyz" + # last_used advanced (or at least stayed a valid ISO timestamp). + assert after.last_used_at is not None + + +def test_registry_uses_atomic_primitive_not_bare_write_text() -> None: + """Guard: the registry persists JSON via the atomic primitive, not the + non-atomic path.write_text (which leaves a truncated file on a crash).""" + import rath.backend.persistence.registry as reg_mod + + src = Path(reg_mod.__file__).read_text(encoding="utf-8") + assert "atomic_write_json" in src, "registry should use atomic_write_json" + assert ".write_text(" not in src, "registry should not use bare write_text" + + +def test_concurrent_record_same_id_no_error(_isolate_openrath_home: Path) -> None: + reg = PersistentSandboxRegistry() + fixed = uuid4() + barrier = threading.Barrier(5) + errors: list[BaseException] = [] + + def _w(tag: int) -> None: + try: + barrier.wait(timeout=5.0) + reg.record_remote("opensandbox", f"native-{tag}", sandbox_id=fixed) + except BaseException as exc: # noqa: BLE001 + errors.append(exc) + + threads = [threading.Thread(target=_w, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10.0) + assert not errors, f"concurrent record_remote raised: {errors!r}" + rec = reg.load_remote(fixed) + assert rec is not None and rec.remote_id.startswith("native-") diff --git a/tests/backends/test_opensandbox_ci_stability.py b/tests/backends/test_opensandbox_ci_stability.py new file mode 100644 index 0000000..efca73b --- /dev/null +++ b/tests/backends/test_opensandbox_ci_stability.py @@ -0,0 +1,76 @@ +"""Offline guards for OpenSandbox CI stability (no live server required).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from rath.backend.opensandbox import ( + _command_stdout_rerun_allowed, + _is_transient_sandbox_create_error, + _should_retry_command_for_empty_stdout, +) + +pytest.importorskip("opensandbox") +from opensandbox.exceptions import SandboxInternalException # noqa: E402 +from opensandbox.models.execd import ( # noqa: E402 + Execution, + ExecutionComplete, + ExecutionLogs, + OutputMessage, +) + + +def test_ci_prepull_image_matches_backend_default() -> None: + workflow = Path(".github/workflows/ci-test-opensandbox.yml").read_text( + encoding="utf-8" + ) + assert "OpenSandboxBackend._DEFAULT_IMAGE" in workflow + assert "opensandbox/code-interpreter:v1.0.2" not in workflow + assert "--reruns" not in workflow + + +def test_should_retry_empty_stdout_race() -> None: + execution = Execution( + complete=ExecutionComplete(timestamp=1, execution_time_in_millis=5), + exit_code=0, + ) + assert _should_retry_command_for_empty_stdout(execution) + + +def test_should_not_retry_when_stdout_present() -> None: + execution = Execution( + complete=ExecutionComplete(timestamp=1, execution_time_in_millis=5), + exit_code=0, + logs=ExecutionLogs(stdout=[OutputMessage(text="hello\n", timestamp=1)]), + ) + assert not _should_retry_command_for_empty_stdout(execution) + + +def test_should_not_retry_nonzero_exit() -> None: + execution = Execution( + complete=ExecutionComplete(timestamp=1, execution_time_in_millis=5), + exit_code=7, + ) + assert not _should_retry_command_for_empty_stdout(execution) + + +def test_transient_create_error_detects_network_timeout() -> None: + exc = SandboxInternalException( + "Network connectivity error:", + cause=TimeoutError("read timed out"), + ) + assert _is_transient_sandbox_create_error(exc) + + +def test_command_stdout_rerun_limited_to_print_probes() -> None: + assert _command_stdout_rerun_allowed("python3 -c \"print('hello')\"") + assert not _command_stdout_rerun_allowed( + "python3 -c \"pathlib.Path('x').write_text('y')\"" + ) + + +def test_transient_create_error_rejects_bind_rejection() -> None: + exc = ValueError("host path not under any allowed prefix") + assert not _is_transient_sandbox_create_error(exc) diff --git a/tests/backends/test_opensandbox_config_env.py b/tests/backends/test_opensandbox_config_env.py new file mode 100644 index 0000000..79c6a82 --- /dev/null +++ b/tests/backends/test_opensandbox_config_env.py @@ -0,0 +1,82 @@ +"""P2.5 — opensandbox resolves domain/flags through the registry + backend config. + +Closes the gap left by P1.2/P2: the `backend` config section had no consumer +and opensandbox read bare os.environ. These are offline, real-filesystem tests +of the pure resolver + is_available (no container, no live service). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.backend.opensandbox import ( + resolve_opensandbox_domain, + strict_workspace_bind, +) +from rath.config.paths import resolve_config_path +from rath.config.schema import BackendProviderConfig +from rath.config.store import ConfigStore + + +@pytest.fixture(autouse=True) +def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + for var in ( + "OPEN_SANDBOX_DOMAIN", + "OPENSANDBOX_DOMAIN", + "RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND", + ): + monkeypatch.delenv(var, raising=False) + ConfigStore._cache.clear() + yield + ConfigStore._cache.clear() + + +def test_domain_env_wins(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPEN_SANDBOX_DOMAIN", "https://env.example") + assert resolve_opensandbox_domain() == "https://env.example" + + +def test_domain_legacy_env_alias(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENSANDBOX_DOMAIN", "https://legacy.example") + assert resolve_opensandbox_domain() == "https://legacy.example" + + +def test_domain_from_backend_config() -> None: + """The backend config section is actually consumed (P1.2 wiring).""" + store = ConfigStore(path=resolve_config_path()) + store.config.backend.providers["sb"] = BackendProviderConfig( + backend_kind="opensandbox", domain="https://cfg.example" + ) + store.config.backend.default_provider = "sb" + store.save() + ConfigStore._cache.clear() + assert resolve_opensandbox_domain() == "https://cfg.example" + + +def test_domain_env_beats_config(monkeypatch: pytest.MonkeyPatch) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.backend.providers["sb"] = BackendProviderConfig( + backend_kind="opensandbox", domain="https://cfg.example" + ) + store.config.backend.default_provider = "sb" + store.save() + ConfigStore._cache.clear() + monkeypatch.setenv("OPEN_SANDBOX_DOMAIN", "https://env.example") + assert resolve_opensandbox_domain() == "https://env.example" + + +def test_domain_none_when_unset() -> None: + assert resolve_opensandbox_domain() is None + + +def test_strict_flag_read_at_runtime(monkeypatch: pytest.MonkeyPatch) -> None: + """Flag is resolved via the registry at call time (not frozen at import).""" + assert strict_workspace_bind() is False + monkeypatch.setenv("RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND", "1") + assert strict_workspace_bind() is True + monkeypatch.setenv("RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND", "no") + assert strict_workspace_bind() is False diff --git a/tests/backends/test_opensandbox_image_defaults.py b/tests/backends/test_opensandbox_image_defaults.py new file mode 100644 index 0000000..a4f57dd --- /dev/null +++ b/tests/backends/test_opensandbox_image_defaults.py @@ -0,0 +1,25 @@ +"""P2.6 — opensandbox default image/entrypoint track the current image. + +The code-interpreter image moved its entrypoint from +``/opt/opensandbox/code-interpreter.sh`` (v1.0.2) to +``/opt/code-interpreter/code-interpreter.sh`` (v1.1.0+). A fresh install +pulling today's image with the old hardcoded entrypoint fails at container +start with exit 127. These offline guards pin the defaults to the current +image so that regression is caught without needing a live backend. +""" + +from __future__ import annotations + +from rath.backend.opensandbox import OpenSandboxBackend + + +def test_default_image_is_current() -> None: + # Must target a pullable, current tag (not the retired v1.0.2). + assert OpenSandboxBackend._DEFAULT_IMAGE == "opensandbox/code-interpreter:v1.1.0" + + +def test_default_entrypoint_matches_current_image_layout() -> None: + # v1.1.0 relocated the launcher under /opt/code-interpreter/. + assert OpenSandboxBackend._DEFAULT_ENTRYPOINT == ( + "/opt/code-interpreter/code-interpreter.sh", + ) diff --git a/tests/config/test_backend_section.py b/tests/config/test_backend_section.py new file mode 100644 index 0000000..f57940c --- /dev/null +++ b/tests/config/test_backend_section.py @@ -0,0 +1,92 @@ +"""P1.2 — a ``backend`` config section parallel to llm / memory / mcp. + +Gives backends (e.g. opensandbox) a config home instead of env-only. Also +verifies the backend section's ``api_key`` participates in the P1.1 secret +split (credentials.json), since ``backend`` is in ``SECRET_SECTIONS``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.config.paths import resolve_config_dir, resolve_config_path +from rath.config.schema import BackendProviderConfig, RathConfig +from rath.config.store import ConfigStore + + +@pytest.fixture(autouse=True) +def _clear_cache() -> Iterator[None]: + ConfigStore._cache.clear() + yield + ConfigStore._cache.clear() + + +def test_backend_section_roundtrips(_isolate_openrath_home: Path) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.backend.providers["sandbox-main"] = BackendProviderConfig( + backend_kind="opensandbox", + domain="https://sandbox.example.com", + api_key="sk-backend-secret", + ) + store.config.backend.default_provider = "sandbox-main" + store.save() + ConfigStore._cache.clear() + + reloaded = ConfigStore.load() + entry = reloaded.get_backend_provider("sandbox-main") + assert entry.backend_kind == "opensandbox" + assert entry.domain == "https://sandbox.example.com" + assert entry.api_key == "sk-backend-secret" + + +def test_backend_getter_unknown_name_lists_available( + _isolate_openrath_home: Path, +) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.backend.providers["a"] = BackendProviderConfig( + backend_kind="opensandbox" + ) + store.save() + ConfigStore._cache.clear() + reloaded = ConfigStore.load() + with pytest.raises(KeyError, match="available"): + reloaded.get_backend_provider("nope") + + +def test_backend_default_provider(_isolate_openrath_home: Path) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.backend.providers["d"] = BackendProviderConfig( + backend_kind="opensandbox", domain="https://d.example" + ) + store.config.backend.default_provider = "d" + store.save() + ConfigStore._cache.clear() + reloaded = ConfigStore.load() + assert reloaded.get_backend_provider(None).domain == "https://d.example" + + +def test_backend_api_key_split_into_credentials( + _isolate_openrath_home: Path, +) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.backend.providers["s"] = BackendProviderConfig( + backend_kind="opensandbox", api_key="sk-be-split" + ) + store.save() + + config_raw = json.loads(resolve_config_path().read_text(encoding="utf-8")) + assert config_raw["backend"]["providers"]["s"].get("api_key") in (None, "") + creds_raw = json.loads( + (resolve_config_dir() / "credentials.json").read_text(encoding="utf-8") + ) + assert creds_raw["backend"]["providers"]["s"] == "sk-be-split" + + +def test_backend_section_defaults_empty() -> None: + cfg = RathConfig() + assert cfg.backend.providers == {} + assert cfg.backend.default_provider is None diff --git a/tests/config/test_credentials_split.py b/tests/config/test_credentials_split.py new file mode 100644 index 0000000..f007a62 --- /dev/null +++ b/tests/config/test_credentials_split.py @@ -0,0 +1,148 @@ +"""P1.1 — split secrets (api_key) out of config.json into credentials.json. + +Real filesystem tests. The in-memory ``RathConfig`` model is unchanged +(``entry.api_key`` still works for callers); the split happens only at the +ConfigStore load/save boundary: + +- ``save()`` writes routing/presets to ``config.json`` (no api_key) and + secrets to a 0600 ``credentials.json``; +- ``load()`` merges them back so ``entry.api_key`` is populated; +- an existing single ``config.json`` with inline ``api_key`` still loads + (back-compat) and is migrated out to ``credentials.json`` on the next save. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.config.paths import resolve_config_dir, resolve_config_path +from rath.config.schema import LLMProviderConfig +from rath.config.store import ConfigStore + + +@pytest.fixture(autouse=True) +def _clear_cache() -> Iterator[None]: + ConfigStore._cache.clear() + yield + ConfigStore._cache.clear() + + +def _credentials_path() -> Path: + return resolve_config_dir() / "credentials.json" + + +def test_save_writes_secret_to_credentials_not_config( + _isolate_openrath_home: Path, +) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="openai", model="gpt-5", api_key="sk-secret-xyz" + ) + store.config.llm.default_provider = "main" + store.save() + + config_raw = json.loads(resolve_config_path().read_text(encoding="utf-8")) + main_entry = config_raw["llm"]["providers"]["main"] + # Routing fields stay; the secret must NOT be in config.json. + assert main_entry["model"] == "gpt-5" + assert main_entry.get("api_key") in (None, "") + + creds_raw = json.loads(_credentials_path().read_text(encoding="utf-8")) + # Secret lives in credentials.json, addressable by the provider name. + assert "sk-secret-xyz" in json.dumps(creds_raw) + + +def test_load_remerges_secret(_isolate_openrath_home: Path) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="openai", model="gpt-5", api_key="sk-remerge" + ) + store.config.llm.default_provider = "main" + store.save() + ConfigStore._cache.clear() + + reloaded = ConfigStore.load() + assert reloaded.get_llm_provider("main").api_key == "sk-remerge" + + +@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX perms only") +def test_credentials_file_is_0600(_isolate_openrath_home: Path) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="openai", api_key="sk-perm" + ) + store.save() + assert (_credentials_path().stat().st_mode & 0o777) == 0o600 + + +def test_legacy_inline_api_key_still_loads_and_migrates( + _isolate_openrath_home: Path, +) -> None: + # Hand-write a legacy single-file config with an inline secret. + path = resolve_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "version": 1, + "llm": { + "default_provider": "main", + "providers": { + "main": { + "provider_kind": "openai", + "model": "gpt-5", + "api_key": "sk-legacy-inline", + } + }, + }, + }, + indent=2, + ), + encoding="utf-8", + ) + + # Loads with the inline secret intact (back-compat). + store = ConfigStore.load() + assert store.get_llm_provider("main").api_key == "sk-legacy-inline" + + # On save, the secret migrates out to credentials.json and leaves + # config.json clean. + store.save() + config_raw = json.loads(path.read_text(encoding="utf-8")) + assert config_raw["llm"]["providers"]["main"].get("api_key") in (None, "") + creds_raw = json.loads(_credentials_path().read_text(encoding="utf-8")) + assert "sk-legacy-inline" in json.dumps(creds_raw) + + +def test_inline_key_takes_precedence_over_credentials( + _isolate_openrath_home: Path, +) -> None: + """If both an inline key and a credentials entry exist, inline wins + (highest precedence, matches the documented back-compat rule).""" + path = resolve_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "version": 1, + "llm": { + "default_provider": "main", + "providers": { + "main": {"provider_kind": "openai", "api_key": "sk-inline"} + }, + }, + } + ), + encoding="utf-8", + ) + _credentials_path().write_text( + json.dumps({"version": 1, "llm": {"providers": {"main": "sk-from-creds"}}}), + encoding="utf-8", + ) + store = ConfigStore.load() + assert store.get_llm_provider("main").api_key == "sk-inline" diff --git a/tests/config/test_env_reference.py b/tests/config/test_env_reference.py new file mode 100644 index 0000000..8c526e2 --- /dev/null +++ b/tests/config/test_env_reference.py @@ -0,0 +1,28 @@ +"""P2.4 — the registry emits a stable, sorted env reference (markdown).""" + +from __future__ import annotations + +from rath.config.env import env_reference_markdown, env_reference_rows + + +def test_markdown_has_header_and_all_rows() -> None: + md = env_reference_markdown() + # Header row + separator + one line per declared var. + lines = [ln for ln in md.splitlines() if ln.strip()] + assert lines[0].startswith("| Name | Kind | Consumers | Default |") + assert set(lines[1].replace(" ", "")) <= {"|", "-"} + body = lines[2:] + assert len(body) == len(env_reference_rows()) + # Sorted by name and includes a known var. + assert "OPENAI_API_KEY" in md + names_in_order = [ln.split("|")[1].strip().strip("`") for ln in body] + assert names_in_order == sorted(names_in_order) + + +def test_secret_defaults_never_leak_a_value() -> None: + # Secrets have no default, so the Default column is blank for them. + md = env_reference_markdown() + for line in md.splitlines(): + if "OPENAI_API_KEY" in line and line.startswith("|"): + cols = [c.strip() for c in line.strip("|").split("|")] + assert cols[-1] == "" # no default value printed for a secret diff --git a/tests/config/test_env_registry.py b/tests/config/test_env_registry.py new file mode 100644 index 0000000..00f0311 --- /dev/null +++ b/tests/config/test_env_registry.py @@ -0,0 +1,95 @@ +"""P2.1 — central EnvSpec registry. + +Every environment variable OpenRath reads is declared once with a kind +(secret/routing/flag), its consumers, and a default. The registry is a +single-read + documentation layer; it does NOT change the documented +precedence (explicit field > env > config), which callers express via +``resolve(name, *explicit)``. +""" + +from __future__ import annotations + +import pytest + +from rath.config.env import ( + EnvKind, + env_flag, + env_reference_rows, + env_value, + get_env_spec, + resolve_env, +) + + +def test_declared_vars_present() -> None: + # A representative sample of the inventory must be declared. + for name in ( + "OPENRATH_HOME", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_DEFAULT_MODEL", + "ANTHROPIC_API_KEY", + "LITELLM_API_KEY", + "OPEN_SANDBOX_DOMAIN", + "RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND", + ): + spec = get_env_spec(name) + assert spec.name == name + assert spec.kind in EnvKind + assert spec.consumers # non-empty description of who reads it + + +def test_secret_vars_marked_secret() -> None: + assert get_env_spec("OPENAI_API_KEY").kind is EnvKind.SECRET + assert get_env_spec("ANTHROPIC_API_KEY").kind is EnvKind.SECRET + assert get_env_spec("OPENAI_BASE_URL").kind is EnvKind.ROUTING + assert get_env_spec("RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND").kind is EnvKind.FLAG + + +def test_env_value_reads_and_strips(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", " sk-abc ") + assert env_value("OPENAI_API_KEY") == "sk-abc" + monkeypatch.setenv("OPENAI_API_KEY", " ") # whitespace-only → None + assert env_value("OPENAI_API_KEY") is None + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + assert env_value("OPENAI_API_KEY") is None + + +def test_unknown_var_is_typed_error() -> None: + with pytest.raises(KeyError, match="not a declared"): + env_value("NOT_A_REAL_VAR") + with pytest.raises(KeyError): + get_env_spec("NOT_A_REAL_VAR") + + +def test_resolve_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + # explicit wins over env + assert resolve_env("OPENAI_API_KEY", "sk-explicit") == "sk-explicit" + # empty explicit falls through to env + assert resolve_env("OPENAI_API_KEY", None, "") == "sk-from-env" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + # nothing set → empty string (resolve_credential contract) + assert resolve_env("OPENAI_API_KEY", None) == "" + + +def test_flag_coercion(monkeypatch: pytest.MonkeyPatch) -> None: + name = "RATH_OPENSANDBOX_STRICT_WORKSPACE_BIND" + for truthy in ("1", "true", "TRUE", "yes", "on"): + monkeypatch.setenv(name, truthy) + assert env_flag(name) is True + for falsy in ("0", "false", "no", "off", ""): + monkeypatch.setenv(name, falsy) + assert env_flag(name) is False + monkeypatch.delenv(name, raising=False) + assert env_flag(name) is False # default + + +def test_reference_rows_sorted_and_complete() -> None: + rows = env_reference_rows() + names = [r["name"] for r in rows] + assert names == sorted(names) + # Every row documents name/kind/consumers/default keys. + for r in rows: + assert set(r) >= {"name", "kind", "consumers", "default"} + assert "OPENAI_API_KEY" in names diff --git a/tests/config/test_manifest_wiring.py b/tests/config/test_manifest_wiring.py new file mode 100644 index 0000000..b4a0772 --- /dev/null +++ b/tests/config/test_manifest_wiring.py @@ -0,0 +1,47 @@ +"""P3.3 — ConfigStore writes/validates the root manifest at the data root.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.config.paths import resolve_config_dir, resolve_config_path +from rath.config.schema import LLMProviderConfig +from rath.config.store import ConfigStore +from rath.persistence.manifest import LAYOUT_VERSION, MANIFEST_FILENAME + + +@pytest.fixture(autouse=True) +def _clear_cache() -> Iterator[None]: + ConfigStore._cache.clear() + yield + ConfigStore._cache.clear() + + +def test_save_creates_manifest(_isolate_openrath_home: Path) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["m"] = LLMProviderConfig(provider_kind="openai") + store.save() + manifest_path = resolve_config_dir() / MANIFEST_FILENAME + assert manifest_path.is_file() + data = json.loads(manifest_path.read_text(encoding="utf-8")) + assert data["layout_version"] == LAYOUT_VERSION + + +def test_load_raises_on_newer_layout(_isolate_openrath_home: Path) -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["m"] = LLMProviderConfig(provider_kind="openai") + store.save() + manifest_path = resolve_config_dir() / MANIFEST_FILENAME + data = json.loads(manifest_path.read_text(encoding="utf-8")) + data["layout_version"] = LAYOUT_VERSION + 1 + manifest_path.write_text(json.dumps(data), encoding="utf-8") + ConfigStore._cache.clear() + + from rath.persistence.manifest import ManifestVersionError + + with pytest.raises(ManifestVersionError): + ConfigStore.load() diff --git a/tests/config/test_schema.py b/tests/config/test_schema.py index ba5e83b..f46d3cc 100644 --- a/tests/config/test_schema.py +++ b/tests/config/test_schema.py @@ -30,6 +30,7 @@ def test_defaults_round_trip_to_known_shape() -> None: }, "mcp": {"default_enabled": [], "servers": {}}, "memory": {"default_provider": None, "providers": {}}, + "backend": {"default_provider": None, "providers": {}}, } c = LLMConfig( default_provider="chat", @@ -125,13 +126,13 @@ def test_extra_allow_round_trips_unknown_keys() -> None: "experiment.flag": "on", }, "mcp": {"default_enabled": [], "servers": {}}, - "backend": {"some": "future-section"}, + "future_section": {"some": "future-section"}, } cfg = RathConfig.model_validate(src) dumped = cfg.model_dump(mode="json") - # Unknown top-level "backend", unknown llm-level "experiment.flag", and - # unknown provider-level "future_field" all survive the round-trip. - assert dumped["backend"] == {"some": "future-section"} + # Unknown top-level "future_section", unknown llm-level "experiment.flag", + # and unknown provider-level "future_field" all survive the round-trip. + assert dumped["future_section"] == {"some": "future-section"} assert dumped["llm"]["experiment.flag"] == "on" assert dumped["llm"]["providers"]["x"]["future_field"] == {"nested": True} diff --git a/tests/config/test_store_caching.py b/tests/config/test_store_caching.py index 4ff9bde..b774ecf 100644 --- a/tests/config/test_store_caching.py +++ b/tests/config/test_store_caching.py @@ -183,12 +183,20 @@ def _writer(tag: str) -> None: assert not t.is_alive() assert not errors, f"concurrent save raised: {errors!r}" - # Final file is parseable and reflects ONE of the writers (last to - # ``replace()`` wins; we don't assert which). + # Final config is parseable and reflects ONE of the writers (last to + # ``replace()`` wins; we don't assert which). Since P1.1, the api_key is + # externalized to credentials.json, so we read the secret from there. final = json.loads(path.read_text(encoding="utf-8")) - final_key = final["llm"]["providers"]["main"]["api_key"] + assert "main" in final["llm"]["providers"] + creds = json.loads((path.parent / "credentials.json").read_text(encoding="utf-8")) + final_key = creds["llm"]["providers"]["main"] assert final_key in {f"sk-{i}" for i in range(5)} - # No temp files left behind. - leftover_tmps = sorted(p.name for p in path.parent.glob(".config_*.tmp")) + # No temp files left behind (from either the legacy prefix or the atomic + # primitive's ``.atomic_*.tmp``). + leftover_tmps = sorted( + p.name + for p in path.parent.glob("*.tmp") + if p.name.startswith((".config_", ".atomic_")) + ) assert leftover_tmps == [], f"atomic save left temp files: {leftover_tmps}" diff --git a/tests/flow/test_agentparam_to.py b/tests/flow/test_agentparam_to.py new file mode 100644 index 0000000..a4708ff --- /dev/null +++ b/tests/flow/test_agentparam_to.py @@ -0,0 +1,85 @@ +"""P4.2 — AgentParam.to() rebinds the provider (type-dispatched, chainable). + +- ``ap.to(Provider(...))`` rebinds .provider to that value; +- ``ap.to(provider="name")`` resolves lazily via Provider.from_config; +- ``ap.to(model="m")`` overrides just the model on the current provider; +- returns self (chainable); +- a bare non-Provider positional is rejected (str stays reserved for sandbox + semantics elsewhere; here the LLM path requires Provider/provider=/model=). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.config.paths import resolve_config_path +from rath.config.schema import LLMProviderConfig +from rath.config.store import ConfigStore +from rath.flow.agent_param import AgentParam +from rath.llm.provider import Provider +from rath.session.session import Session + + +@pytest.fixture(autouse=True) +def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + ConfigStore._cache.clear() + yield + ConfigStore._cache.clear() + + +def _ap() -> AgentParam: + return AgentParam( + agent_session=Session.from_agent_prompt("sys"), + provider=Provider(model="base"), + ) + + +def test_to_provider_instance() -> None: + ap = _ap() + ret = ap.to(Provider(model="gpt-5.5", api_key="sk-x")) + assert ret is ap # chainable + assert ap.provider.model == "gpt-5.5" + assert ap.provider.api_key == "sk-x" + + +def test_to_provider_name_from_config() -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="anthropic", model="claude", api_key="sk-cfg" + ) + store.config.llm.default_provider = "main" + store.save() + ConfigStore._cache.clear() + + ap = _ap() + ap.to(provider="main") + assert ap.provider.model == "claude" + assert ap.provider.provider_kind == "anthropic" + assert ap.provider.api_key == "sk-cfg" + + +def test_to_model_override_keeps_other_fields() -> None: + ap = AgentParam( + agent_session=Session.from_agent_prompt("sys"), + provider=Provider(model="old", api_key="sk-keep", temperature=0.3), + ) + ap.to(model="new") + assert ap.provider.model == "new" + assert ap.provider.api_key == "sk-keep" + assert ap.provider.temperature == 0.3 + + +def test_to_rejects_bare_string() -> None: + ap = _ap() + with pytest.raises(TypeError): + ap.to("openai") # ambiguous; must use provider= or Provider(...) + + +def test_to_requires_something() -> None: + ap = _ap() + with pytest.raises((TypeError, ValueError)): + ap.to() diff --git a/tests/flow/test_compile.py b/tests/flow/test_compile.py new file mode 100644 index 0000000..d48dd29 --- /dev/null +++ b/tests/flow/test_compile.py @@ -0,0 +1,60 @@ +"""P5.3 — Workflow.compile() returns a CompiledWorkflow. + +compile() is opt-in and non-breaking: the CompiledWorkflow is callable exactly +like the workflow (delegates to forward), and exposes the static manifest, +children, and a repr of the graph. It never runs the model or materializes a +session. +""" + +from __future__ import annotations + +from rath.flow.agent_param import AgentParam +from rath.flow.compile import CompiledWorkflow, ResourceManifest +from rath.flow.workflow import Workflow +from rath.llm.provider import Provider +from rath.session.session import Session + + +class _Echo(Workflow): + def __init__(self) -> None: + super().__init__() + self.a = AgentParam(Session.from_agent_prompt("sys"), Provider(model="m")) + + def forward(self, session: Session) -> Session: + return session + + +def test_compile_returns_compiled_workflow() -> None: + wf = _Echo() + cw = wf.compile() + assert isinstance(cw, CompiledWorkflow) + assert isinstance(cw.manifest, ResourceManifest) + + +def test_compiled_is_callable_like_workflow() -> None: + wf = _Echo() + cw = wf.compile() + s = Session.from_user_message("hi") + out = cw(s) + # _Echo.forward is identity; the compiled call must behave the same. + assert out is s + + +def test_compiled_exposes_graph() -> None: + wf = _Echo() + cw = wf.compile() + assert cw.manifest.provider_models() == ["m"] + assert "_Echo" in repr(cw) + + +def test_compile_does_not_mutate_workflow() -> None: + wf = _Echo() + before = [n for n, _ in wf.named_agents()] + wf.compile() + assert [n for n, _ in wf.named_agents()] == before + + +def test_compiled_wraps_original() -> None: + wf = _Echo() + cw = wf.compile() + assert cw.workflow is wf diff --git a/tests/flow/test_compile_example.py b/tests/flow/test_compile_example.py new file mode 100644 index 0000000..b821136 --- /dev/null +++ b/tests/flow/test_compile_example.py @@ -0,0 +1,26 @@ +"""P5.6 — example/12_compile.py runs offline (no API key) and exits cleanly.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_EXAMPLE = _REPO_ROOT / "example" / "12_compile.py" + + +def test_compile_example_runs_offline() -> None: + assert _EXAMPLE.is_file(), f"missing example: {_EXAMPLE}" + proc = subprocess.run( + [sys.executable, str(_EXAMPLE)], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, f"example failed:\n{proc.stdout}\n{proc.stderr}" + # Exercised the manifest + validation + lifecycle sections. + assert "Reachable provider models:" in proc.stdout + assert "validate() problems:" in proc.stdout + assert "Resources released on exit." in proc.stdout diff --git a/tests/flow/test_compile_lifecycle.py b/tests/flow/test_compile_lifecycle.py new file mode 100644 index 0000000..048e337 --- /dev/null +++ b/tests/flow/test_compile_lifecycle.py @@ -0,0 +1,80 @@ +"""P5.5 — CompiledWorkflow lifecycle context manager. + +`with wf.compile() as cw:` pre-acquires the planned resources (memory stores +bound on reachable AgentParams) and releases them in reverse order on exit, +even on exception. Refcounts return to baseline. Real local memory backend +(no mocks). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.flow.agent_param import AgentParam +from rath.flow.workflow import Workflow +from rath.llm.provider import Provider +from rath.memory import get as get_memory_backend +from rath.session.session import Session + + +@pytest.fixture(autouse=True) +def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + yield + + +def _local_store(): # type: ignore[no-untyped-def] + return get_memory_backend("local").open() + + +class _MemWF(Workflow): + def __init__(self, store) -> None: # type: ignore[no-untyped-def] + super().__init__() + self.a = AgentParam( + Session.from_agent_prompt("s"), + Provider(model="m", api_key="sk"), + memory=store, + ) + + def forward(self, session: Session) -> Session: + return session + + +def test_context_manager_acquires_and_releases() -> None: + store = _local_store() + baseline = store.refcount + wf = _MemWF(store) + with wf.compile() as cw: + # inside the block the planned memory store has an extra reference + assert store.refcount == baseline + 1 + assert cw.workflow is wf + # released on exit, back to baseline + assert store.refcount == baseline + + +def test_context_manager_releases_on_exception() -> None: + store = _local_store() + baseline = store.refcount + wf = _MemWF(store) + with pytest.raises(RuntimeError): + with wf.compile(): + assert store.refcount == baseline + 1 + raise RuntimeError("boom") + assert store.refcount == baseline + + +def test_context_manager_no_memory_is_noop() -> None: + class _Plain(Workflow): + def __init__(self) -> None: + super().__init__() + self.a = AgentParam(Session.from_agent_prompt("s"), Provider(model="m")) + + def forward(self, session: Session) -> Session: + return session + + wf = _Plain() + with wf.compile() as cw: + assert cw.workflow is wf # no resources to acquire, no error diff --git a/tests/flow/test_compile_validate.py b/tests/flow/test_compile_validate.py new file mode 100644 index 0000000..b6bc93e --- /dev/null +++ b/tests/flow/test_compile_validate.py @@ -0,0 +1,65 @@ +"""P5.4 — CompiledWorkflow.validate() pre-flight (offline, no model call). + +validate() inspects the manifest and fails fast on unknown provider kinds or +missing credentials BEFORE any run. It returns a list of problems (empty when +clean); validate(raise_on_error=True) raises instead. No network / no live LLM. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.flow.agent_param import AgentParam +from rath.flow.workflow import Workflow +from rath.llm.provider import Provider +from rath.session.session import Session + + +@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"): + monkeypatch.delenv(v, raising=False) + yield + + +class _One(Workflow): + def __init__(self, provider: Provider) -> None: + super().__init__() + self.a = AgentParam(Session.from_agent_prompt("sys"), provider) + + +def test_validate_clean_when_key_present() -> None: + wf = _One(Provider(provider_kind="openai", model="m", api_key="sk-explicit")) + problems = wf.compile().validate() + assert problems == [] + + +def test_validate_flags_missing_credentials() -> None: + # No explicit key and no env/config → credential problem. + wf = _One(Provider(provider_kind="openai", model="m")) + problems = wf.compile().validate() + assert any("credential" in p.lower() or "api" in p.lower() for p in problems) + + +def test_validate_flags_unknown_provider_kind() -> None: + wf = _One(Provider(provider_kind="openai", model="m", api_key="sk")) + cw = wf.compile() + # Corrupt the manifest to an unregistered kind to exercise the check. + from dataclasses import replace + + cw.manifest.agents[0] = replace( + cw.manifest.agents[0], + provider=Provider(provider_kind="nope-kind", model="m", api_key="sk"), # type: ignore[arg-type] + ) + problems = cw.validate() + assert any("kind" in p.lower() for p in problems) + + +def test_validate_raise_on_error() -> None: + wf = _One(Provider(provider_kind="openai", model="m")) + with pytest.raises(ValueError): + wf.compile().validate(raise_on_error=True) diff --git a/tests/flow/test_examples_offline.py b/tests/flow/test_examples_offline.py new file mode 100644 index 0000000..8b68977 --- /dev/null +++ b/tests/flow/test_examples_offline.py @@ -0,0 +1,43 @@ +"""Smoke tests: the no-key examples run offline and exit cleanly. + +Only the key-free rungs are covered here (they run as real subprocesses, no +mocks). The LLM-backed examples (01, 03-05, 07, 08, 10, 11) need a live key and +would incur cost / rate limits, so they stay lint+import-checked via +`ruff check example` rather than executed. Example 09 is key-free only when no +provider is configured; with a configured key it attempts an optional live +turn, so it is intentionally excluded from the offline set. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[2] + +# (script, artifact-it-may-write-to-repo-root) +_OFFLINE_EXAMPLES = [ + ("02_session_lineage.py", "lineage_demo.jsonl"), + ("06_mcp_tool.py", None), +] + + +@pytest.mark.parametrize("script,artifact", _OFFLINE_EXAMPLES) +def test_no_key_example_runs_offline(script: str, artifact: str | None) -> None: + path = _REPO_ROOT / "example" / script + assert path.is_file(), f"missing example: {path}" + try: + proc = subprocess.run( + [sys.executable, str(path)], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, f"{script} failed:\n{proc.stdout}\n{proc.stderr}" + finally: + if artifact: + (_REPO_ROOT / artifact).unlink(missing_ok=True) diff --git a/tests/flow/test_module_tree.py b/tests/flow/test_module_tree.py new file mode 100644 index 0000000..19e6596 --- /dev/null +++ b/tests/flow/test_module_tree.py @@ -0,0 +1,81 @@ +"""P5.1 — nested Workflow/Agent children register into a module tree. + +Mirrors torch.nn.Module child registration: assigning a Workflow (or Agent, +which is a Workflow) as an attribute registers it under named_children(); +AgentParam leaves still register under named_agents(). modules() walks the +tree recursively. repr shows the nested structure. +""" + +from __future__ import annotations + +from rath.flow.agent import Agent +from rath.flow.agent_param import AgentParam +from rath.flow.workflow import Workflow +from rath.llm.provider import Provider +from rath.session.session import Session + + +class _Leaf(Workflow): + def __init__(self) -> None: + super().__init__(description="leaf") + self.p = AgentParam(Session.from_agent_prompt("leaf"), Provider(model="m")) + + +class _Parent(Workflow): + def __init__(self) -> None: + super().__init__(description="parent") + self.child_b = _Leaf() + self.child_a = _Leaf() + self.own = AgentParam(Session.from_agent_prompt("own"), Provider(model="m")) + + +def test_children_registered_sorted() -> None: + p = _Parent() + names = [n for n, _ in p.named_children()] + assert names == ["child_a", "child_b"] # sorted + for _n, c in p.named_children(): + assert isinstance(c, _Leaf) + + +def test_agentparam_leaves_still_register() -> None: + p = _Parent() + assert [n for n, _ in p.named_agents()] == ["own"] + + +def test_modules_walks_recursively() -> None: + p = _Parent() + mods = list(p.modules()) + # self + 2 children + assert p in mods + assert sum(isinstance(m, _Leaf) for m in mods) == 2 + + +def test_agent_as_child_registers_once() -> None: + """An Agent assigned to a parent is a child; its own AgentParam is not + double-counted on the parent.""" + + class _Uses(Workflow): + def __init__(self) -> None: + super().__init__() + self.worker = Agent("sys", model="gpt-5.5") + + u = _Uses() + assert [n for n, _ in u.named_children()] == ["worker"] + # The parent has no AgentParam of its own — the Agent's param stays inside it. + assert u.named_agents() == () + assert isinstance(u.worker, Agent) + # The child Agent still has its own registered AgentParam. + assert [n for n, _ in u.worker.named_agents()] == ["agent"] + + +def test_delattr_unregisters_child() -> None: + p = _Parent() + del p.child_a + assert [n for n, _ in p.named_children()] == ["child_b"] + + +def test_repr_shows_nested_tree() -> None: + p = _Parent() + text = repr(p) + assert "_Parent" in text + assert "child_a" in text and "child_b" in text diff --git a/tests/flow/test_resource_manifest.py b/tests/flow/test_resource_manifest.py new file mode 100644 index 0000000..69f29c6 --- /dev/null +++ b/tests/flow/test_resource_manifest.py @@ -0,0 +1,83 @@ +"""P5.2 — static ResourceManifest collected from a workflow module tree. + +The collector walks the module tree (P5.1) and every reachable AgentParam, +recording provider identity, whether memory is bound, and the agent-prompt +session id. Selector children are recorded as DYNAMIC nodes (known-unknowns), +never silently dropped, and their runtime routing is not predicted. +""" + +from __future__ import annotations + +from rath.flow.agent_param import AgentParam +from rath.flow.compile import ResourceManifest, collect_manifest +from rath.flow.selector import Selector +from rath.flow.workflow import Workflow +from rath.llm.provider import Provider +from rath.session.session import Session + + +class _Leaf(Workflow): + def __init__(self, model: str) -> None: + super().__init__() + self.p = AgentParam(Session.from_agent_prompt("sys"), Provider(model=model)) + + +class _Tree(Workflow): + def __init__(self) -> None: + super().__init__() + self.a = _Leaf("m-a") + self.b = _Leaf("m-b") + self.own = AgentParam(Session.from_agent_prompt("own"), Provider(model="m-own")) + + +def test_collect_reaches_all_agentparams() -> None: + m = collect_manifest(_Tree()) + assert isinstance(m, ResourceManifest) + models = m.provider_models() + assert set(models) == {"m-a", "m-b", "m-own"} + + +def test_manifest_records_agent_paths() -> None: + m = collect_manifest(_Tree()) + paths = {a.path for a in m.agents} + # dotted paths from the root through named_children/named_agents + assert "own" in paths + assert any(p.endswith(".p") for p in paths) + + +def test_selector_is_dynamic_node() -> None: + class _WithSelector(Workflow): + def __init__(self) -> None: + super().__init__() + self.router = Selector(Provider(model="r")) + self.leaf = _Leaf("m-x") + + m = collect_manifest(_WithSelector()) + dyn_paths = {d.path for d in m.dynamic_nodes} + assert "router" in dyn_paths + # dynamic node carries a reason, and is not treated as a static leaf + assert all(d.reason for d in m.dynamic_nodes) + + +def test_memory_binding_recorded() -> None: + class _Mem(Workflow): + def __init__(self, store) -> None: # type: ignore[no-untyped-def] + super().__init__() + self.p = AgentParam( + Session.from_agent_prompt("s"), Provider(model="m"), memory=store + ) + + # A sentinel object standing in for a MemoryStore (collector only records + # presence, it does not open anything). + sentinel = object() + m = collect_manifest(_Mem(sentinel)) + assert any(a.has_memory for a in m.agents) + + +def test_empty_workflow_manifest_is_empty() -> None: + class _E(Workflow): + pass + + m = collect_manifest(_E()) + assert m.agents == [] + assert m.dynamic_nodes == [] diff --git a/tests/flow/test_workflow_to.py b/tests/flow/test_workflow_to.py new file mode 100644 index 0000000..083e54e --- /dev/null +++ b/tests/flow/test_workflow_to.py @@ -0,0 +1,67 @@ +"""P4.5 — Workflow.to() fans a provider out to all registered AgentParams.""" + +from __future__ import annotations + +import pytest + +from rath.flow.agent_param import AgentParam +from rath.flow.workflow import Workflow +from rath.llm.provider import Provider +from rath.session.session import Session + + +class _TwoAgents(Workflow): + def __init__(self) -> None: + super().__init__() + self.a = AgentParam(Session.from_agent_prompt("a"), Provider(model="a0")) + self.b = AgentParam(Session.from_agent_prompt("b"), Provider(model="b0")) + + +def test_to_provider_rebinds_all_agents() -> None: + wf = _TwoAgents() + ret = wf.to(Provider(model="shared", api_key="sk")) + assert ret is wf # chainable + for _name, ap in wf.named_agents(): + assert ap.provider.model == "shared" + + +def test_to_provider_name_rebinds_all(monkeypatch, tmp_path) -> None: # type: ignore[no-untyped-def] + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + from rath.config.paths import resolve_config_path + from rath.config.schema import LLMProviderConfig + from rath.config.store import ConfigStore + + ConfigStore._cache.clear() + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="openai", model="cfg-model", api_key="sk" + ) + store.config.llm.default_provider = "main" + store.save() + ConfigStore._cache.clear() + + wf = _TwoAgents() + wf.to(provider="main") + for _name, ap in wf.named_agents(): + assert ap.provider.model == "cfg-model" + + +def test_to_model_override_all() -> None: + wf = _TwoAgents() + wf.to(model="m9") + for _name, ap in wf.named_agents(): + assert ap.provider.model == "m9" + + +def test_to_on_empty_workflow_is_noop() -> None: + class _Empty(Workflow): + pass + + wf = _Empty() + assert wf.to(Provider(model="x", api_key="sk")) is wf # no agents, no error + + +def test_to_rejects_bare_string() -> None: + wf = _TwoAgents() + with pytest.raises(TypeError): + wf.to("openai") diff --git a/tests/llm/test_client_cache.py b/tests/llm/test_client_cache.py new file mode 100644 index 0000000..64cd4cf --- /dev/null +++ b/tests/llm/test_client_cache.py @@ -0,0 +1,60 @@ +"""P4.1 — optional client cache in chat_client_for. + +Constructing a fresh SDK client on every loop run is wasteful when the same +provider identity is reused. We cache the constructed ChatClient keyed on the +provider's HTTP-identity fields — but ONLY when the Provider carries an +explicit api_key, so a provider relying on env/config fallback is never served +a stale client (env can change mid-process; an explicit-key provider cannot go +stale). A helper clears the cache for tests / rebinds. +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +from rath.llm.provider import Provider +from rath.llm.registry import chat_client_for, clear_client_cache + + +@pytest.fixture(autouse=True) +def _clear() -> Iterator[None]: + clear_client_cache() + yield + clear_client_cache() + + +def test_identical_explicit_providers_share_client() -> None: + p1 = Provider(provider_kind="openai", base_url="https://x/v1", api_key="sk-a") + p2 = Provider(provider_kind="openai", base_url="https://x/v1", api_key="sk-a") + c1 = chat_client_for(p1) + c2 = chat_client_for(p2) + assert c1 is c2 # cache hit on identical identity + + +def test_differing_identity_distinct_clients() -> None: + c1 = chat_client_for(Provider(base_url="https://x/v1", api_key="sk-a")) + c2 = chat_client_for(Provider(base_url="https://x/v1", api_key="sk-b")) + c3 = chat_client_for(Provider(base_url="https://y/v1", api_key="sk-a")) + assert c1 is not c2 + assert c1 is not c3 + + +def test_clear_invalidates() -> None: + p = Provider(base_url="https://x/v1", api_key="sk-a") + c1 = chat_client_for(p) + clear_client_cache() + c2 = chat_client_for(p) + assert c1 is not c2 + + +def test_fallback_provider_not_cached(monkeypatch: pytest.MonkeyPatch) -> None: + """A Provider with no explicit api_key relies on env/config and must NOT be + cached (otherwise it could be served a client built against stale env).""" + monkeypatch.setenv("OPENAI_API_KEY", "sk-env") + monkeypatch.setenv("OPENAI_BASE_URL", "https://env/v1") + p = Provider(provider_kind="openai") # api_key=None → fallback + c1 = chat_client_for(p) + c2 = chat_client_for(p) + assert c1 is not c2 # not cached diff --git a/tests/llm/test_credentials_via_env_registry.py b/tests/llm/test_credentials_via_env_registry.py new file mode 100644 index 0000000..97a1ff5 --- /dev/null +++ b/tests/llm/test_credentials_via_env_registry.py @@ -0,0 +1,73 @@ +"""P2.2/P2.3 — credential resolution routes through the EnvSpec registry. + +Characterization tests: they pin the documented precedence (Provider field > +env > config) for each sync client's resolver, so the refactor from bare +``os.environ.get`` to ``env_value`` is provably behavior-preserving. No mocks +— we call the real module-level resolver functions with real env state. +""" + +from __future__ import annotations + +import pytest + +from rath.llm.provider import Provider + + +@pytest.fixture(autouse=True) +def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path): # type: ignore[no-untyped-def] + # Pin OPENRATH_HOME to an empty tmp dir so config-file fallback is inert + # and we're testing only the Provider>env tiers. + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + yield + + +def test_openai_base_url_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + from rath.llm.openai.client import _resolve_base_url + + monkeypatch.setenv("OPENAI_BASE_URL", "https://env.example/v1") + # Provider field wins. + assert ( + _resolve_base_url(Provider(base_url="https://explicit/v1")) + == "https://explicit/v1" + ) + # Falls through to env. + assert _resolve_base_url(Provider()) == "https://env.example/v1" + # Whitespace env is treated as unset. + monkeypatch.setenv("OPENAI_BASE_URL", " ") + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://azure.example") + assert _resolve_base_url(Provider()) == "https://azure.example" + + +def test_openai_api_key_precedence_non_azure(monkeypatch: pytest.MonkeyPatch) -> None: + from rath.llm.openai.client import _resolve_api_key + + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-env") + assert _resolve_api_key(Provider(api_key="sk-explicit"), "") == "sk-explicit" + assert _resolve_api_key(Provider(), "https://api.openai.com/v1") == "sk-env" + + +def test_openai_api_key_precedence_azure(monkeypatch: pytest.MonkeyPatch) -> None: + from rath.llm.openai.client import _resolve_api_key + + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azkey") + monkeypatch.setenv("OPENAI_API_KEY", "sk-env") + # Azure endpoint prefers the Azure key. + assert _resolve_api_key(Provider(), "https://x.openai.azure.com/openai") == "azkey" + + +def test_anthropic_key_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + from rath.llm.anthropic.client import _resolve_anthropic_key + + monkeypatch.setenv("ANTHROPIC_API_KEY", "ak-env") + assert _resolve_anthropic_key(Provider(api_key="ak-explicit")) == "ak-explicit" + assert _resolve_anthropic_key(Provider()) == "ak-env" + + +def test_litellm_key_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + pytest.importorskip("litellm") + from rath.llm.litellm.client import _resolve_litellm_key + + monkeypatch.setenv("LITELLM_API_KEY", "lk-env") + assert _resolve_litellm_key(Provider(api_key="lk-explicit")) == "lk-explicit" + assert _resolve_litellm_key(Provider()) == "lk-env" diff --git a/tests/llm/test_env_registry_async_embed_vlm.py b/tests/llm/test_env_registry_async_embed_vlm.py new file mode 100644 index 0000000..4dd3355 --- /dev/null +++ b/tests/llm/test_env_registry_async_embed_vlm.py @@ -0,0 +1,104 @@ +"""P2.3 — async clients + embedding/vlm resolve through the EnvSpec registry. + +- async openai/anthropic resolvers preserve precedence (refactor guard); +- embedding.py / vlm.py gain the config-file fallback that chat clients have + (previously they only consulted Provider + env), routed via env_value. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.config.paths import resolve_config_path +from rath.config.store import ConfigStore +from rath.llm.embedding import EmbeddingProvider +from rath.llm.provider import Provider +from rath.llm.vlm import VLMProvider + + +@pytest.fixture(autouse=True) +def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + ConfigStore._cache.clear() + yield + ConfigStore._cache.clear() + + +def test_async_openai_resolvers_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + from rath._async.aopenai import _resolve_api_key, _resolve_base_url + + monkeypatch.setenv("OPENAI_BASE_URL", "https://aenv/v1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-aenv") + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + assert _resolve_base_url(Provider(base_url="https://ax/v1")) == "https://ax/v1" + assert _resolve_base_url(Provider()) == "https://aenv/v1" + assert _resolve_api_key(Provider(api_key="sk-ax"), "") == "sk-ax" + assert _resolve_api_key(Provider(), "https://api.openai.com/v1") == "sk-aenv" + + +def test_async_anthropic_resolver_precedence(monkeypatch: pytest.MonkeyPatch) -> None: + from rath._async.aanthropic import _resolve_async_anthropic_key + + monkeypatch.setenv("ANTHROPIC_API_KEY", "ak-aenv") + assert _resolve_async_anthropic_key(Provider(api_key="ak-ax")) == "ak-ax" + assert _resolve_async_anthropic_key(Provider()) == "ak-aenv" + + +def test_embedding_config_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """EmbeddingProvider with no key/env resolves from llm config (new in P2.3).""" + from rath.config.schema import LLMProviderConfig + from rath.llm.embedding import _resolve_api_key, _resolve_base_url + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="openai", api_key="sk-cfg-embed", base_url="https://cfg/v1" + ) + store.config.llm.default_provider = "main" + store.save() + ConfigStore._cache.clear() + + assert ( + _resolve_api_key(EmbeddingProvider(model="text-embedding-3-small")) + == "sk-cfg-embed" + ) + assert ( + _resolve_base_url(EmbeddingProvider(model="text-embedding-3-small")) + == "https://cfg/v1" + ) + # Explicit still wins. + assert _resolve_api_key(EmbeddingProvider(model="m", api_key="sk-x")) == "sk-x" + + +def test_vlm_config_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + from rath.config.schema import LLMProviderConfig + from rath.llm.vlm import _resolve_api_key, _resolve_base_url + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="openai", api_key="sk-cfg-vlm", base_url="https://cfgv/v1" + ) + store.config.llm.default_provider = "main" + store.save() + ConfigStore._cache.clear() + + assert _resolve_api_key(VLMProvider(model="gpt-4o")) == "sk-cfg-vlm" + assert _resolve_base_url(VLMProvider(model="gpt-4o")) == "https://cfgv/v1" + + +def test_no_bare_os_environ_in_refactored_modules() -> None: + """Guard: refactored modules read env only through the registry.""" + import rath._async.aanthropic as aanthropic + import rath._async.aopenai as aopenai + import rath.llm.embedding as embedding + import rath.llm.vlm as vlm + + for mod in (aopenai, aanthropic, embedding, vlm): + src = Path(mod.__file__).read_text(encoding="utf-8") + assert "os.environ.get(" not in src, f"{mod.__name__} still uses os.environ.get" diff --git a/tests/persistence/__init__.py b/tests/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/persistence/test_atomic_json.py b/tests/persistence/test_atomic_json.py new file mode 100644 index 0000000..e0e7efb --- /dev/null +++ b/tests/persistence/test_atomic_json.py @@ -0,0 +1,99 @@ +"""P3.1 — shared atomic-JSON write primitive. + +Real filesystem tests only (no mocks). Verifies: +- atomicity: a mid-write failure never leaves a half-written target; +- 0600 perms on POSIX; +- no temp debris on success or failure; +- concurrent writers to the same path from *different* threads do not raise + (this is the Windows ``os.replace`` PermissionError bug the primitive fixes) + and the final file is one complete, parseable payload. +""" + +from __future__ import annotations + +import json +import sys +import threading +from pathlib import Path + +import pytest + +from rath.persistence.atomic import atomic_write_json, atomic_write_text + + +def test_atomic_write_text_creates_file(tmp_path: Path) -> None: + target = tmp_path / "sub" / "a.txt" # parent does not exist yet + atomic_write_text(target, "hello\n") + assert target.read_text(encoding="utf-8") == "hello\n" + + +def test_atomic_write_json_roundtrip(tmp_path: Path) -> None: + target = tmp_path / "cfg.json" + payload = {"b": 2, "a": 1, "nested": {"x": [1, 2, 3]}} + atomic_write_json(target, payload) + assert json.loads(target.read_text(encoding="utf-8")) == payload + + +def test_no_partial_file_on_serialization_failure(tmp_path: Path) -> None: + target = tmp_path / "cfg.json" + atomic_write_json(target, {"ok": 1}) + original = target.read_text(encoding="utf-8") + + class Unserializable: + pass + + with pytest.raises(TypeError): + atomic_write_json(target, {"bad": Unserializable()}) + + # Original file untouched; no temp debris. + assert target.read_text(encoding="utf-8") == original + assert sorted(p.name for p in tmp_path.glob("*.tmp")) == [] + assert sorted(p.name for p in tmp_path.glob(".*tmp*")) == [] + + +@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX perms only") +def test_atomic_write_sets_0600(tmp_path: Path) -> None: + target = tmp_path / "secret.json" + atomic_write_json(target, {"k": "v"}, mode=0o600) + assert (target.stat().st_mode & 0o777) == 0o600 + + +def test_concurrent_writes_same_path_no_error(tmp_path: Path) -> None: + """5 threads write the same path simultaneously via independent calls. + + Must not raise (the primitive serializes replace via a path-keyed lock + and retries the Windows sharing-violation window), and the final file + must be exactly one writer's complete payload. + """ + target = tmp_path / "shared.json" + barrier = threading.Barrier(5) + errors: list[BaseException] = [] + + def _writer(tag: int) -> None: + try: + barrier.wait(timeout=5.0) + atomic_write_json(target, {"writer": tag, "pad": "x" * (tag + 1)}) + except BaseException as exc: # noqa: BLE001 -- collected for assertion + errors.append(exc) + + threads = [threading.Thread(target=_writer, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10.0) + assert not t.is_alive() + + assert not errors, f"concurrent atomic writes raised: {errors!r}" + final = json.loads(target.read_text(encoding="utf-8")) + assert final["writer"] in set(range(5)) + # No temp debris from any writer. + leftovers = sorted(p.name for p in tmp_path.iterdir() if p.name != "shared.json") + assert leftovers == [], f"atomic write left debris: {leftovers}" + + +def test_write_text_trailing_newline_option(tmp_path: Path) -> None: + target = tmp_path / "n.txt" + atomic_write_text(target, "line", newline=False) + assert target.read_text(encoding="utf-8") == "line" + atomic_write_text(target, "line", newline=True) + assert target.read_text(encoding="utf-8") == "line\n" diff --git a/tests/persistence/test_gc.py b/tests/persistence/test_gc.py new file mode 100644 index 0000000..3842362 --- /dev/null +++ b/tests/persistence/test_gc.py @@ -0,0 +1,76 @@ +"""P3.4 — unified retention/GC across all persistence planes. + +Real filesystem. ``rath.persistence.gc`` enumerates prunable artifacts by age +across sessions, sandboxes (local dirs + remote index), memory stores, and — +critically — the previously-unbounded memory commits archive. dry_run reports +without deleting; a real run removes only what it reported. Never touches paths +outside the resolved data root. +""" + +from __future__ import annotations + +import os +from datetime import timedelta +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.persistence.gc import GCReport, gc + + +@pytest.fixture(autouse=True) +def _isolate_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[Path]: + root = tmp_path / "openrath_home" + root.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("OPENRATH_HOME", str(root)) + yield root + + +def _age(path: Path, days: int) -> None: + """Backdate a path's mtime by ``days`` days.""" + past = (Path(path).stat().st_mtime) - days * 86400 + os.utime(path, (past, past)) + + +def test_gc_prunes_old_memory_commits(_isolate_home: Path) -> None: + # Build a memory store with two commit archives, backdate one. + store = _isolate_home / "memory" / "local" / "store1" + old_commit = store / "session" / "s1" / "commits" / "20240101T000000000000" + new_commit = store / "session" / "s1" / "commits" / "20260705T000000000000" + for c in (old_commit, new_commit): + c.mkdir(parents=True, exist_ok=True) + (c / "messages.json").write_text("[]", encoding="utf-8") + _age(old_commit, days=400) + + report = gc(older_than=timedelta(days=90), dry_run=True) + assert isinstance(report, GCReport) + assert str(old_commit) in [str(p) for p in report.memory_commits] + assert str(new_commit) not in [str(p) for p in report.memory_commits] + # dry-run must not delete. + assert old_commit.exists() + + report2 = gc(older_than=timedelta(days=90), dry_run=False) + assert not old_commit.exists() + assert new_commit.exists() # recent one kept + assert str(old_commit) in [str(p) for p in report2.memory_commits] + + +def test_gc_dry_run_reports_all_categories(_isolate_home: Path) -> None: + report = gc(older_than=timedelta(days=30), dry_run=True) + # Report has a slot for each plane even when empty. + assert report.sessions == [] + assert report.local_sandboxes == [] + assert report.remote_sandboxes == [] + assert report.memory_stores == [] + assert report.memory_commits == [] + + +def test_gc_never_escapes_root(_isolate_home: Path) -> None: + # A commit-like dir OUTSIDE the root must never be collected. + outside = _isolate_home.parent / "outside" / "commits" / "20240101T000000000000" + outside.mkdir(parents=True, exist_ok=True) + _age(outside, days=999) + report = gc(older_than=timedelta(days=1), dry_run=False) + assert outside.exists() + assert all(str(_isolate_home) in str(p) for p in report.memory_commits) diff --git a/tests/persistence/test_manifest.py b/tests/persistence/test_manifest.py new file mode 100644 index 0000000..8373c1a --- /dev/null +++ b/tests/persistence/test_manifest.py @@ -0,0 +1,66 @@ +"""P3.3 — root layout manifest (.openrath/manifest.json). + +Records the layout version + per-plane schema versions so upgrades are +coordinated and a future/newer layout is detected with a clear error instead +of silent misreads. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from rath.persistence.manifest import ( + LAYOUT_VERSION, + ManifestVersionError, + check_manifest, + ensure_manifest, + read_manifest, +) + + +def test_ensure_creates_manifest(tmp_path: Path) -> None: + m = ensure_manifest(tmp_path) + assert m["layout_version"] == LAYOUT_VERSION + assert "planes" in m and set(m["planes"]) >= {"config", "backend", "memory"} + path = tmp_path / "manifest.json" + assert path.is_file() + # Parseable, and per-plane schema versions are recorded ints. + on_disk = json.loads(path.read_text(encoding="utf-8")) + assert on_disk == m + assert all(isinstance(v, int) for v in m["planes"].values()) + + +def test_ensure_is_idempotent(tmp_path: Path) -> None: + first = ensure_manifest(tmp_path) + second = ensure_manifest(tmp_path) + assert first == second + + +def test_read_missing_returns_none(tmp_path: Path) -> None: + assert read_manifest(tmp_path) is None + + +def test_check_detects_newer_layout(tmp_path: Path) -> None: + ensure_manifest(tmp_path) + # Simulate a manifest written by a newer OpenRath. + path = tmp_path / "manifest.json" + data = json.loads(path.read_text(encoding="utf-8")) + data["layout_version"] = LAYOUT_VERSION + 1 + path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises(ManifestVersionError, match="newer"): + check_manifest(tmp_path) + + +def test_check_passes_for_current(tmp_path: Path) -> None: + ensure_manifest(tmp_path) + # Should not raise. + check_manifest(tmp_path) + + +def test_check_noop_when_absent(tmp_path: Path) -> None: + # No manifest yet (fresh/legacy root) → check is a no-op, not an error. + check_manifest(tmp_path) diff --git a/tests/session/persistence/test_provider_not_pickled.py b/tests/session/persistence/test_provider_not_pickled.py new file mode 100644 index 0000000..d4f105e --- /dev/null +++ b/tests/session/persistence/test_provider_not_pickled.py @@ -0,0 +1,58 @@ +"""P4.6 — a session's bound Provider is never serialized to the JSONL header. + +Provider.on_budget_exceeded is a live callable, so a bound provider is not +serializable. Session persistence must not attempt to write it. This locks the +invariant: persisting a session whose provider carries a callback succeeds and +the on-disk header contains no provider / no callback. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.llm.provider import Provider +from rath.session.persistence._serialize import build_header +from rath.session.session import Session + + +@pytest.fixture(autouse=True) +def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + yield + + +def _budget_cb(*_a: object, **_k: object) -> None: # pragma: no cover - never called + raise AssertionError("should not be invoked") + + +def test_header_excludes_provider() -> None: + s = Session.from_user_message("hi").to( + Provider(model="m", api_key="sk", on_budget_exceeded=_budget_cb) + ) + header = build_header(s, sandbox_handle_id=None) + # Header is JSON-serializable (no live callable leaked in). + dumped = json.dumps(header) + assert "provider" not in header + assert "on_budget_exceeded" not in dumped + assert "_budget_cb" not in dumped + + +def test_persisted_session_roundtrip_ignores_provider(tmp_path: Path) -> None: + from rath.session.persistence.writer import SessionWriter + + s = Session.from_user_message("hello").to( + Provider(model="m", api_key="sk", on_budget_exceeded=_budget_cb) + ) + out = tmp_path / f"{s.id}.jsonl" + writer = SessionWriter(s, path=out) + for i, row in enumerate(s.chunk_table.rows): + writer.write_chunk(i, row) + writer.close() + + text = out.read_text(encoding="utf-8") + assert "on_budget_exceeded" not in text + assert "_budget_cb" not in text diff --git a/tests/session/test_loop_provider_precedence.py b/tests/session/test_loop_provider_precedence.py new file mode 100644 index 0000000..a33b7f1 --- /dev/null +++ b/tests/session/test_loop_provider_precedence.py @@ -0,0 +1,87 @@ +"""P4.4 — run_session_loop provider precedence. + +- explicit agent_provider= wins (Agent path unchanged); +- when omitted, the loop falls back to the session-bound provider + (session.to(Provider(...))); +- omitting both raises a clear ValueError before any model call. + +Uses a fake executor so no network/key is needed (this tests provider +*resolution*, not a live completion). +""" + +from __future__ import annotations + +import pytest + +from rath.llm.chat_response import ( + RathLLMAssistantMessage, + RathLLMChatChoice, + RathLLMChatResponse, + RathLLMTokenUsage, +) +from rath.llm.provider import Provider +from rath.session.loop import run_session_loop +from rath.session.session import Session + + +class _RecordingExecutor: + """Minimal SessionLoopExecutor that records the request's resolved model. + + The effective provider is folded into the chat request (model etc.) before + ``complete`` runs, so ``req.model`` reflects which provider won. + """ + + def __init__(self) -> None: + self.seen_model: str | None = None + + def complete(self, req): # type: ignore[no-untyped-def] + self.seen_model = req.model + return RathLLMChatResponse( + id="resp-1", + choices=( + RathLLMChatChoice( + index=0, + finish_reason="stop", + message=RathLLMAssistantMessage(content="done"), + ), + ), + created=0, + model=req.model or "", + usage=RathLLMTokenUsage( + prompt_tokens=1, completion_tokens=1, total_tokens=2 + ), + ) + + def tool_schemas(self): # type: ignore[no-untyped-def] + return () + + def dispatch_tool(self, session, tool, arguments): # type: ignore[no-untyped-def] + raise AssertionError("no tools in this test") + + +def _run(user: Session, agent: Session, **kw): # type: ignore[no-untyped-def] + ex = _RecordingExecutor() + out = run_session_loop(user, agent, executor=ex, lazy=False, **kw) + out.synchronize() + return out, ex + + +def test_explicit_agent_provider_wins() -> None: + user = Session.from_user_message("hi").to(Provider(model="SESSION", api_key="s")) + agent = Session.from_agent_prompt("sys") + _out, ex = _run(user, agent, agent_provider=Provider(model="AGENT", api_key="a")) + assert ex.seen_model == "AGENT" + + +def test_session_provider_fallback() -> None: + user = Session.from_user_message("hi").to(Provider(model="SESSION", api_key="s")) + agent = Session.from_agent_prompt("sys") + _out, ex = _run(user, agent) # no agent_provider + assert ex.seen_model == "SESSION" + + +def test_missing_both_raises() -> None: + user = Session.from_user_message("hi") # no provider bound + agent = Session.from_agent_prompt("sys") + with pytest.raises(ValueError, match="no provider"): + run_session_loop(user, agent, executor=_RecordingExecutor(), lazy=False) diff --git a/tests/session/test_session_provider_binding.py b/tests/session/test_session_provider_binding.py new file mode 100644 index 0000000..c61b23c --- /dev/null +++ b/tests/session/test_session_provider_binding.py @@ -0,0 +1,86 @@ +"""P4.3 — Session carries an optional provider, switchable via .to(). + +- ``session.to("local", spec=...)`` still binds the SANDBOX (unchanged); +- ``session.to(Provider(...))`` sets a session-level provider (value, no + lifecycle); +- ``session.to(provider="name")`` resolves a config preset lazily; +- the bound provider survives fork/detach (copied like sandbox_backend) and + merge keeps self's provider; +- binding a provider must NOT open a sandbox. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterator + +import pytest + +from rath.config.paths import resolve_config_path +from rath.config.schema import LLMProviderConfig +from rath.config.store import ConfigStore +from rath.llm.provider import Provider +from rath.session.session import Session + + +@pytest.fixture(autouse=True) +def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + monkeypatch.setenv("OPENRATH_HOME", str(tmp_path / "home")) + ConfigStore._cache.clear() + yield + ConfigStore._cache.clear() + + +def test_to_local_still_binds_sandbox_not_provider() -> None: + s = Session.from_user_message("hi").to("local", spec="./") + assert s.sandbox_backend == "local" + assert s.provider is None # provider untouched by a sandbox .to() + + +def test_to_provider_instance_sets_provider_no_sandbox_open() -> None: + s = Session.from_user_message("hi").to(Provider(model="gpt-5.5", api_key="sk")) + assert s.provider is not None and s.provider.model == "gpt-5.5" + # No sandbox opened by binding a provider. + assert s.sandbox is None + + +def test_to_provider_name_lazy_resolves() -> None: + store = ConfigStore(path=resolve_config_path()) + store.config.llm.providers["main"] = LLMProviderConfig( + provider_kind="openai", model="gpt-x", api_key="sk-cfg" + ) + store.config.llm.default_provider = "main" + store.save() + ConfigStore._cache.clear() + + s = Session.from_user_message("hi").to(provider="main") + assert s.provider is not None and s.provider.model == "gpt-x" + + +def test_chainable_sandbox_then_provider() -> None: + s = ( + Session.from_user_message("hi") + .to("local", spec="./") + .to(Provider(model="m", api_key="sk")) + ) + assert s.sandbox_backend == "local" + assert s.provider is not None and s.provider.model == "m" + + +def test_provider_survives_fork_and_detach() -> None: + s = Session.from_user_message("hi").to(Provider(model="m", api_key="sk")) + f = s.fork() + d = s.detach() + assert f.provider is not None and f.provider.model == "m" + assert d.provider is not None and d.provider.model == "m" + + +def test_merge_keeps_self_provider() -> None: + a = Session.from_user_message("a").to(Provider(model="A", api_key="sk")) + b = Session.from_user_message("b").to(Provider(model="B", api_key="sk")) + merged = a.merge(b) + assert merged.provider is not None and merged.provider.model == "A" + + +def test_default_provider_is_none() -> None: + assert Session.from_user_message("x").provider is None diff --git a/uv.lock b/uv.lock index 2ca9027..9b5accb 100644 --- a/uv.lock +++ b/uv.lock @@ -467,48 +467,46 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.0" +version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, - { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, - { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, - { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, - { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, - { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, - { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, - { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, - { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, - { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, - { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, - { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, - { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, - { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, - { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, - { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, - { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, - { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, - { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] @@ -520,6 +518,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -632,7 +639,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.3" +version = "0.139.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -641,9 +648,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, ] [[package]] @@ -698,6 +705,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/1d/5573ef3624ceb7abf4a46073d3554e37191c868abc3aecd5289a72f9810a/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed", size = 156539, upload-time = "2025-10-19T22:33:35.898Z" }, ] +[[package]] +name = "feedparser" +version = "6.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sgmllib3k" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" }, +] + [[package]] name = "filelock" version = "3.29.0" @@ -1805,7 +1824,7 @@ wheels = [ [[package]] name = "openrath" -version = "1.2.2" +version = "1.3.0" source = { editable = "." } dependencies = [ { name = "anthropic" }, @@ -1852,10 +1871,10 @@ requires-dist = [ { name = "litellm", marker = "extra == 'litellm'", specifier = ">=1.80,<1.88" }, { name = "mcp", specifier = ">=1.0.0" }, { name = "openai", specifier = ">=1.0.0" }, - { name = "opensandbox", marker = "extra == 'opensandbox'", specifier = ">=0.1.7" }, + { name = "opensandbox", marker = "extra == 'opensandbox'", specifier = ">=0.1.13" }, { name = "opensandbox-code-interpreter", marker = "extra == 'opensandbox'", specifier = ">=0.1.2" }, - { name = "opensandbox-server", marker = "extra == 'opensandbox'", specifier = ">=0.1.12" }, - { name = "openviking", marker = "extra == 'openviking'", specifier = ">=0.2.6" }, + { name = "opensandbox-server", marker = "extra == 'opensandbox'", specifier = ">=0.2.1" }, + { name = "openviking", marker = "extra == 'openviking'", specifier = ">=0.4.7" }, { name = "pydantic", specifier = ">=2.0.0,<3" }, ] provides-extras = ["litellm", "opensandbox", "openviking"] @@ -1878,7 +1897,7 @@ docs = [ [[package]] name = "opensandbox" -version = "0.1.9" +version = "0.1.13" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -1886,9 +1905,9 @@ dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/2a/ab3cc141e041f71a373c97fcda8749dba9328f1b9bf80401378c0611556f/opensandbox-0.1.9.tar.gz", hash = "sha256:670fbf292c498f8467963d21e91ade9ea8b8f63f4ef18d18fff9581e0952ec03", size = 160034, upload-time = "2026-05-12T12:27:20.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/e2/d2716adfca0cce6af3913035372d2f8cbe6a836e595d4747e19481113439/opensandbox-0.1.13.tar.gz", hash = "sha256:eab4b6597b2941f0418fa186a377c15b01acb8549f24f84963e7c15434232977", size = 201287, upload-time = "2026-06-25T06:52:57.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/9b/553f8d7a30eddb12785711b2a1c682386878e2bb95450acd806f9fa62930/opensandbox-0.1.9-py3-none-any.whl", hash = "sha256:17faed35b60a982fee5a643fed8e4e12f041e5432d5ea0665d2828d1f2082759", size = 360945, upload-time = "2026-05-12T12:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/f1/57/74459959e69e4271929ab3658c5abcbbf977977d126ddd7d861bfde4d0e5/opensandbox-0.1.13-py3-none-any.whl", hash = "sha256:8decd1eb952a3b539fecaf443b025c24d6d4a535f62242abd11644bc20505570", size = 491625, upload-time = "2026-06-25T06:52:55.726Z" }, ] [[package]] @@ -1906,7 +1925,7 @@ wheels = [ [[package]] name = "opensandbox-server" -version = "0.1.14" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, @@ -1915,15 +1934,17 @@ dependencies = [ { name = "kubernetes" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "redis" }, + { name = "starlette" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "uvicorn", extra = ["standard"] }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/f2/02e215820b6228137c0335779644ca8fd3c643fc702c3a37997e17e0e616/opensandbox_server-0.1.14.tar.gz", hash = "sha256:d44e2de5c28171d91b1536faddb3b6a28db5f13fb26041d6a90a3975f15ca3bd", size = 158224, upload-time = "2026-05-18T13:39:37.071Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/0e/3f8cfff895fb77ef0db0622f81c6e0c27fb645d5e73a10beb088dc99294b/opensandbox_server-0.2.1.tar.gz", hash = "sha256:7cea6dcb816f28b4e2686c70ec68d3ee477ae2c4b6a7ad15a2ffc2485249ad99", size = 166552, upload-time = "2026-06-29T11:28:46.264Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/6b/e5c9fd57c9453ebd9fb5ef6524e6e918bc97b96283f39877059b90909214/opensandbox_server-0.1.14-py3-none-any.whl", hash = "sha256:6eeac518be7a9553ecfd07ce4b576fe2c8a76f17c750ef3d5c151e3a13a4ea6f", size = 237777, upload-time = "2026-05-18T13:39:38.21Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/c8df684d2e3485692cb19fe987c8a9ca0d62bf26c1c142f5844657879d8d/opensandbox_server-0.2.1-py3-none-any.whl", hash = "sha256:884a630ebd32f9e9dec8c0195fafd34dafa2b7b96539554efbe98639c95bb892", size = 246161, upload-time = "2026-06-29T11:28:44.93Z" }, ] [[package]] @@ -2057,14 +2078,17 @@ wheels = [ [[package]] name = "openviking" -version = "0.3.19" +version = "0.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "apscheduler" }, { name = "argon2-cffi" }, + { name = "charset-normalizer" }, { name = "cryptography" }, + { name = "defusedxml" }, { name = "ebooklib" }, { name = "fastapi" }, + { name = "feedparser" }, { name = "httpx" }, { name = "jinja2" }, { name = "json-repair" }, @@ -2081,6 +2105,7 @@ dependencies = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-instrumentation-asyncio" }, { name = "opentelemetry-sdk" }, + { name = "openviking-sdk" }, { name = "pathspec" }, { name = "pdfminer-six" }, { name = "pdfplumber" }, @@ -2113,13 +2138,25 @@ dependencies = [ { name = "xlrd" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/bd/f1a4b57c08ccf5905e69ef0dd0a7b1996d170955ee592d209bd1a05ce9e4/openviking-0.3.19.tar.gz", hash = "sha256:909182485f7a36ce3c6dff9974822eeffbbf9fcebe3b308c2d82f381c2acaa33", size = 18992104, upload-time = "2026-05-22T11:54:57.558Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/ad/a9c44e4c7af316a95dc4c808f2b481df02f661227b0d7eb4c30a944c4e1e/openviking-0.4.7.tar.gz", hash = "sha256:5ff7b5a72988ca24b8c497578c4b238100622e6c351b01ebb6d2a0b6467626fd", size = 55450411, upload-time = "2026-07-02T13:46:19.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/64/4ae58fc1ad06d9051e9b26eb6f1ec1069f12009d334016b8661a03321e07/openviking-0.3.19-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:77ff5853336d43b260d9808bb8014116b5b409d44b6a6261a45111a8b3341e8d", size = 12344929, upload-time = "2026-05-22T11:54:43.267Z" }, - { url = "https://files.pythonhosted.org/packages/96/eb/abc0c03618819ce0542b50bf14ab39e30db0addf5001ea10bee4d75dc863/openviking-0.3.19-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:6509797a03bbee89c6b56f8203a1f79d5157e759969f70b1f5cf7038a46dccd4", size = 14481930, upload-time = "2026-05-22T11:54:46.948Z" }, - { url = "https://files.pythonhosted.org/packages/a2/07/369b7bebbac2392fed34debe079d7ef9eb930d2f4ff00246a51acff0ea68/openviking-0.3.19-cp310-abi3-manylinux_2_31_aarch64.whl", hash = "sha256:fa064a3861cfc5da74417196e642cb3fefe1eb91096784bbda8a7bdcbccc71e7", size = 13340762, upload-time = "2026-05-22T11:54:49.814Z" }, - { url = "https://files.pythonhosted.org/packages/22/66/6ce4f624664bf197fafa4b179d86ea5d85f16383247a04d3659cce813571/openviking-0.3.19-cp310-abi3-manylinux_2_31_x86_64.whl", hash = "sha256:563917b28749a52153348cd10c186c02c13ff2aa1ce30ddcb200bea1cfd3b419", size = 15621853, upload-time = "2026-05-22T11:54:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/18/ec/dd6924ed7c489196f1d103cbad4d70081863b4da164f2006e5a5c4ebaa19/openviking-0.3.19-cp310-abi3-win_amd64.whl", hash = "sha256:01c04ba4126c60c9a2977d11e0a1c22d83941dfa5ab72bede577a15634793fa8", size = 17772830, upload-time = "2026-05-22T11:54:54.809Z" }, + { url = "https://files.pythonhosted.org/packages/32/77/9d47f6c326085b3eaac69caf219611afdb74cad56d404b0a741ab26afb54/openviking-0.4.7-cp310-abi3-macosx_14_0_arm64.whl", hash = "sha256:51c8d3a8eb68772fb1d1b1bb57aaaf26ed654d2ff0213d8994a270d9c4ab544e", size = 18680099, upload-time = "2026-07-02T13:46:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/0a/18/b61d8d669319d6395d86d51eaf10b321e437dd0a5dc32367c4d3e2610aaa/openviking-0.4.7-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:9134f2921218740be697d68cc3c9db5f7e38bfcb53464e55800a74e8bbf33ed8", size = 21055402, upload-time = "2026-07-02T13:46:04.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ba/e98c4e0fbd2cc2a3c94cbe9816df1b94de582d19800087030d9029a10b0f/openviking-0.4.7-cp310-abi3-manylinux_2_31_aarch64.whl", hash = "sha256:0cfdb0308a9cd37f23cec5424425962bf784a0f89f816f7d39f1abc2ebeb03da", size = 19729687, upload-time = "2026-07-02T13:46:07.77Z" }, + { url = "https://files.pythonhosted.org/packages/21/5a/6c0e6e3bc732c4e1335597102478608f9e6cdb582a64f68e5b91a34b8970/openviking-0.4.7-cp310-abi3-manylinux_2_31_x86_64.whl", hash = "sha256:e98c13e1f8cf9c9c805323e86516ccf4ace94053e0b309e212e8b79b242350e3", size = 22227659, upload-time = "2026-07-02T13:46:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9f/7dd3cbd41d3a8fe23a504f474a5cef7e67a6b13084049a32536bf7191ee8/openviking-0.4.7-cp310-abi3-win_amd64.whl", hash = "sha256:da2a032dcae0f025ad2fe1df0d3d4e6d5d37f9263deef78b973e9d125ea0d08b", size = 24679040, upload-time = "2026-07-02T13:46:14.999Z" }, +] + +[[package]] +name = "openviking-sdk" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/46/8fe7438310208e1aaa99bad4db2393d56320c3a718db017d8b8ba4920515/openviking_sdk-0.1.3.tar.gz", hash = "sha256:032632f3d5b93a3781f070757295ba4891be11e1799d3cfd3ad5c84a908ff965", size = 30505, upload-time = "2026-07-03T02:49:05.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/98/1f60859fc6a54b32ebbd4221ed5a777fe1c81828476959fe99a07ea2e1ca/openviking_sdk-0.1.3-py3-none-any.whl", hash = "sha256:20e94db83b7db58018d75a552b32a1bb5705a7b4ec48e2ea34829896921c562e", size = 20468, upload-time = "2026-07-03T02:49:04.625Z" }, ] [[package]] @@ -2716,11 +2753,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.29" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -3136,6 +3173,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] +[[package]] +name = "sgmllib3k" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } + [[package]] name = "shellingham" version = "1.5.4" @@ -3353,15 +3396,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.1.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]]