Files
OpenShell/e2e/python/conftest.py
T
Piotr Mlocek f869182d8a feat(sandbox): move inference execution to sandbox-local routing (!79) (!52)
> **🏗️ build-from-issue-agent**

Closes #79

## Summary

Replaces the gateway-proxied inference model (`ProxyInference` RPC) with sandbox-local execution. The sandbox now resolves routes from either a standalone YAML route file or a cluster bundle fetched via the new `GetSandboxInferenceBundle` RPC, then forwards requests directly to inference backends using `navigator-router`.

### Benefits of removing the gRPC hop

Moving inference execution from the gateway to the sandbox eliminates the gRPC round-trip for every inference request:

- **No payload size limits**: Direct HTTP forwarding replaces protobuf-over-gRPC serialization.
- **Streaming-ready**: Preserves connection semantics for future SSE support (e.g., `stream: true`).
- **Lower latency**: Direct sandbox → backend path instead of sandbox → gateway → backend round-trip.
- **Reduced gateway load**: Gateway only serves the control-plane bundle delivery RPC.
- **Simpler error model**: HTTP status codes flow through directly without gRPC wrapping.

## Changes Made

- **Proto**: Removed `ProxyInference` RPC. Added `GetSandboxInferenceBundle` RPC for route bundle delivery.
- **Gateway**: Replaced `proxy_inference` with `get_sandbox_inference_bundle`. Removed `Router` and `navigator-router` dependency — gateway is now control-plane only for inference.
- **Router**: Switched config file format from TOML to YAML. Added `Debug` impl that redacts API keys.
- **Sandbox**: New `InferenceContext` with local `Router` + route cache. Routes loaded from `--inference-routes` file (standalone) or cluster bundle via gRPC, with 30s background refresh. New `--inference-routes` / `NAVIGATOR_INFERENCE_ROUTES` CLI arg.
- **Dev sandbox**: Added `inference-routes.yaml` at repo root with default NVIDIA NIM route. `mise run sandbox` now mounts it automatically and supports `-e` flag to forward env vars (e.g., `mise run sandbox`).
- **Example**: Added standalone example (`examples/inference/routes.yaml`) and rewrote README to cover both standalone and cluster workflows.
- **E2E tests**: Updated existing test names/docs. Added tests for Anthropic messages protocol and multi-route policy filtering.

## Tests Added

- **Unit (21 new):** `router_error_to_http` (6 variants), `load_from_file` YAML round-trip (5), `resolve_sandbox_inference_bundle` gRPC handler (6), `build_inference_context` route loading (4)
- **E2E (2 new):** Anthropic messages protocol routing, route filtering by `allowed_routes`
- **Total:** 188 unit tests passing across 3 crates

## Documentation Updated

- `architecture/gateway.md`: Removed router, updated Inference Service
- `architecture/sandbox.md`: Updated orchestration flow, InferenceContext, route loading
- `architecture/inference-routing.md`: Complete rewrite for sandbox-local architecture

## Verification

- [x] All unit tests passing (188 across 3 crates)
- [x] Pre-commit Rust checks passing (fmt, clippy, tests)
- [x] Architecture documentation updated
- [x] E2E tests updated with new test cases
- [x] Standalone example and documentation added
- [x] Default inference-routes.yaml with env passthrough for mise run sandbox
2026-02-25 09:57:42 -08:00

162 lines
4.3 KiB
Python

from __future__ import annotations
import os
import time
from typing import TYPE_CHECKING
import grpc
import pytest
from navigator import InferenceRouteClient, Sandbox, SandboxClient
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
@pytest.fixture(scope="session")
def cluster_name() -> str | None:
return os.environ.get("NAVIGATOR_CLUSTER")
@pytest.fixture(scope="session")
def sandbox_client(cluster_name: str | None) -> Iterator[SandboxClient]:
with SandboxClient.from_active_cluster(cluster=cluster_name) as client:
yield client
@pytest.fixture(scope="session", autouse=True)
def ensure_sandbox_persistence_ready(sandbox_client: SandboxClient) -> None:
for _ in range(60):
try:
sandbox_client.list_ids(limit=1)
return
except grpc.RpcError as exc:
details = exc.details() or ""
if exc.code() == grpc.StatusCode.UNAVAILABLE:
time.sleep(2)
continue
if (
exc.code() == grpc.StatusCode.INTERNAL
and "no such table: objects" in details
):
time.sleep(1)
continue
raise
pytest.fail(
"navigator-server persistence is not initialized (missing sqlite objects table); "
"redeploy the active cluster and rerun e2e sandbox tests"
)
@pytest.fixture
def sandbox(cluster_name: str | None) -> Callable[..., Sandbox]:
def _create(*, spec: object | None = None, delete_on_exit: bool = True) -> Sandbox:
return Sandbox(
cluster=cluster_name,
spec=spec,
delete_on_exit=delete_on_exit,
# The sandbox image is large (Python, Node.js, coding agents) so the
# first pod in the cluster may need extra time for the image pull.
ready_timeout_seconds=300.0,
)
return _create
@pytest.fixture(scope="session")
def inference_client(sandbox_client: SandboxClient) -> InferenceRouteClient:
return InferenceRouteClient.from_sandbox_client(sandbox_client)
@pytest.fixture(scope="session")
def mock_inference_route(
inference_client: InferenceRouteClient,
) -> Iterator[str]:
name = "e2e-mock-local"
routing_hint = "e2e_mock_local"
# Clean up any leftover route from a previous run.
try:
inference_client.delete(name)
except grpc.RpcError:
pass
inference_client.create(
name=name,
routing_hint=routing_hint,
base_url="mock://e2e",
protocols=["openai_chat_completions"],
api_key="mock",
model_id="mock/test-model",
enabled=True,
)
yield name
try:
inference_client.delete(name)
except grpc.RpcError:
pass
@pytest.fixture(scope="session")
def mock_anthropic_route(
inference_client: InferenceRouteClient,
) -> Iterator[str]:
name = "e2e-mock-anthropic"
routing_hint = "e2e_mock_anthropic"
try:
inference_client.delete(name)
except grpc.RpcError:
pass
inference_client.create(
name=name,
routing_hint=routing_hint,
base_url="mock://e2e-anthropic",
protocols=["anthropic_messages"],
api_key="mock",
model_id="mock/claude-test",
enabled=True,
)
yield name
try:
inference_client.delete(name)
except grpc.RpcError:
pass
@pytest.fixture(scope="session")
def mock_disallowed_route(
inference_client: InferenceRouteClient,
) -> Iterator[str]:
"""Route that exists but is NOT in any sandbox's allowed_routes."""
name = "e2e-mock-disallowed"
routing_hint = "e2e_mock_disallowed"
try:
inference_client.delete(name)
except grpc.RpcError:
pass
inference_client.create(
name=name,
routing_hint=routing_hint,
base_url="mock://e2e-disallowed",
protocols=["openai_chat_completions"],
api_key="mock",
model_id="mock/disallowed-model",
enabled=True,
)
yield name
try:
inference_client.delete(name)
except grpc.RpcError:
pass
@pytest.fixture
def run_python() -> Callable[[Sandbox, str], tuple[int, str, str]]:
def _run(sandbox: Sandbox, code: str) -> tuple[int, str, str]:
result = sandbox.exec(["python", "-c", code], timeout_seconds=20)
return result.exit_code, result.stdout, result.stderr
return _run