Skip to content

Commit 79918d5

Browse files
jamesbrazaclaude
andauthored
Supporting grafting envs onto pre-existing API (#352)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9e0a0aa commit 79918d5

3 files changed

Lines changed: 163 additions & 31 deletions

File tree

src/aviary/dataset_server.py

Lines changed: 57 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
import uuid
77
from contextlib import contextmanager
88
from itertools import starmap
9-
from typing import Generic, TypeVar
9+
from typing import Any, Generic
1010

11-
from pydantic import BaseModel, Field
11+
from pydantic import BaseModel, Field, model_validator
1212

13-
from aviary.env import Environment, TaskDataset
13+
from aviary.env import TaskDataset, TEnvironment
1414
from aviary.message import Message
1515
from aviary.tools import (
1616
MessagesAdapter,
@@ -21,7 +21,7 @@
2121

2222
try:
2323
import uvicorn
24-
from fastapi import Depends, FastAPI, HTTPException, Security
24+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security
2525
from fastapi.security import APIKeyHeader
2626

2727
missing_dependencies = False
@@ -36,11 +36,33 @@ class StartRequest(BaseModel):
3636
task_idx: int | None = Field(
3737
default=None,
3838
description=(
39-
"Index of the dataset to start. "
40-
"If provided, will call TaskDataset.get_new_env_by_idx(); "
41-
"otherwise, TaskDataset.get_new_env()."
39+
"Optional index of the dataset to start. If provided, will call"
40+
" TaskDataset.get_new_env_by_idx(); otherwise, TaskDataset.get_new_env()."
41+
" Mutually exclusive with task_kwargs."
4242
),
4343
)
44+
task_kwargs: dict[str, Any] | None = Field(
45+
default=None,
46+
description=(
47+
"Optional keyword arguments passed to TaskDataset.get_new_env_by_args()."
48+
" Mutually exclusive with task_idx."
49+
),
50+
)
51+
52+
@model_validator(mode="after")
53+
def _check_mutually_exclusive(self) -> "StartRequest":
54+
if self.task_idx is not None and self.task_kwargs is not None:
55+
raise ValueError(
56+
"task_idx and task_kwargs are mutually exclusive; specify at most one."
57+
)
58+
return self
59+
60+
def make_env(self, dataset: TaskDataset[TEnvironment]) -> TEnvironment:
61+
if self.task_kwargs is not None:
62+
return dataset.get_new_env_by_args(**self.task_kwargs)
63+
if self.task_idx is not None:
64+
return dataset.get_new_env_by_idx(self.task_idx)
65+
return dataset.get_new_env()
4466

4567

4668
class EnvRequest(BaseModel):
@@ -60,17 +82,14 @@ class FlushRequest(BaseModel):
6082
BIND_ALL_HOST = "0.0.0.0" # noqa: S104
6183

6284

63-
# Not sure why, but mypy complains if we use the TEnvironment in aviary.env, so redefine here
64-
TEnvironment = TypeVar("TEnvironment", bound=Environment)
65-
66-
6785
class TaskDatasetServer(Generic[TEnvironment]):
6886
def __init__(
6987
self,
7088
dataset: TaskDataset[TEnvironment],
7189
host: str = BIND_ALL_HOST,
7290
port: int = DEFAULT_SERVER_PORT,
7391
api_key: str | None = None,
92+
router: "APIRouter | None" = None,
7493
):
7594
if missing_dependencies:
7695
raise ImportError(
@@ -83,13 +102,19 @@ def __init__(
83102
self.port = port
84103
self.api_key = api_key
85104

86-
self.app = FastAPI()
87-
88105
# env ID -> (env, last used timestamp)
89106
self.envs: dict[str, tuple[TEnvironment, float]] = {}
90107
self.lock = asyncio.Lock()
108+
109+
self.router = router if router is not None else APIRouter()
91110
self._setup_routes()
92111

112+
if router is None: # Standalone mode: build a default FastAPI app
113+
self.app: FastAPI | None = FastAPI()
114+
self.app.include_router(self.router)
115+
else: # Mounted mode: caller mounts self.router onto their own app
116+
self.app = None
117+
93118
def _get_env(self, env_id: str) -> TEnvironment:
94119
try:
95120
env, _ = self.envs[env_id]
@@ -123,22 +148,17 @@ def verify_api_key(api_key: str | None = Security(api_key_header)):
123148
status_code=403, detail="Invalid or missing API key"
124149
)
125150

126-
@self.app.post("/start", dependencies=[Depends(verify_api_key)])
151+
@self.router.post("/start", dependencies=[Depends(verify_api_key)])
127152
async def start(req: StartRequest):
128153
with handle_exc_as_http_exc():
129-
if req.task_idx is None:
130-
env = await asyncio.to_thread(self.dataset.get_new_env)
131-
else:
132-
env = await asyncio.to_thread(
133-
self.dataset.get_new_env_by_idx, req.task_idx
134-
)
154+
env = await asyncio.to_thread(req.make_env, self.dataset)
135155

136156
async with self.lock:
137157
env_id = str(uuid.uuid4())
138158
self.envs[env_id] = (env, time.time())
139159
return {"env_id": env_id}
140160

141-
@self.app.post("/reset", dependencies=[Depends(verify_api_key)])
161+
@self.router.post("/reset", dependencies=[Depends(verify_api_key)])
142162
async def reset(req: EnvRequest):
143163
async with self.lock:
144164
env = self._get_env(req.env_id)
@@ -152,7 +172,7 @@ async def reset(req: EnvRequest):
152172
ToolsAdapter.dump_python(tools, exclude_none=True, by_alias=True),
153173
)
154174

155-
@self.app.post("/step", dependencies=[Depends(verify_api_key)])
175+
@self.router.post("/step", dependencies=[Depends(verify_api_key)])
156176
async def step(req: StepRequest):
157177
async with self.lock:
158178
env = self._get_env(req.env_id)
@@ -166,7 +186,7 @@ async def step(req: StepRequest):
166186
)
167187
return obs_serialized, *reward_done_trunc
168188

169-
@self.app.post("/close", dependencies=[Depends(verify_api_key)])
189+
@self.router.post("/close", dependencies=[Depends(verify_api_key)])
170190
async def close(req: EnvRequest):
171191
async with self.lock:
172192
env = self._get_env(req.env_id)
@@ -179,7 +199,7 @@ async def close(req: EnvRequest):
179199

180200
return {"env_id": req.env_id}
181201

182-
@self.app.post("/close_old_envs", dependencies=[Depends(verify_api_key)])
202+
@self.router.post("/close_old_envs", dependencies=[Depends(verify_api_key)])
183203
async def close_old_envs(req: FlushRequest):
184204
"""Endpoint to close environments that have not been used in a while.
185205
@@ -212,7 +232,7 @@ async def close(env_id: str, env: TEnvironment) -> str | None:
212232
"closed_env_ids": [env_id for env_id in closed if env_id is not None]
213233
}
214234

215-
@self.app.get("/info", dependencies=[Depends(verify_api_key)])
235+
@self.router.get("/info", dependencies=[Depends(verify_api_key)])
216236
def info():
217237
try:
218238
dataset_len: int | None = len(self.dataset)
@@ -223,11 +243,21 @@ def info():
223243
"running_env_ids": list(self.envs.keys()),
224244
}
225245

226-
def start(self):
246+
def start(self) -> None:
247+
if self.app is None:
248+
raise RuntimeError(
249+
f"{type(self).__name__} was constructed with an external router; "
250+
"mount self.router on your own FastAPI app and run uvicorn there."
251+
)
227252
uvicorn.run(self.app, host=self.host, port=self.port, log_level="debug")
228253

229-
async def astart(self):
254+
async def astart(self) -> None:
230255
"""Async equivalent of start()."""
256+
if self.app is None:
257+
raise RuntimeError(
258+
f"{type(self).__name__} was constructed with an external router; "
259+
"mount self.router on your own FastAPI app and run uvicorn there."
260+
)
231261
config = uvicorn.Config(
232262
self.app, host=self.host, port=self.port, log_level="debug"
233263
)

src/aviary/env.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,16 @@ def get_new_env(self) -> TEnvironment:
437437
f'"{self.__class__.__name__}" does not implement get_new_env'
438438
)
439439

440+
def get_new_env_by_args(self, **kwargs) -> TEnvironment:
441+
"""Get an env from arbitrary task kwargs.
442+
443+
Useful when the caller drives env creation from request payloads
444+
rather than a default configuration or fixed environment index.
445+
"""
446+
raise NotImplementedError(
447+
f'"{self.__class__.__name__}" does not implement get_new_env_by_args'
448+
)
449+
440450
def iter_batches(
441451
self, batch_size: int, shuffle: bool = False
442452
) -> Iterator[list[TEnvironment]]:

tests/test_envs.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
import time
77
from collections.abc import AsyncIterator, Sequence
88
from contextlib import asynccontextmanager
9-
from typing import Any, ClassVar
9+
from typing import Any, ClassVar, cast
1010
from unittest import mock
1111

1212
import litellm
1313
import numpy as np
1414
import pytest
1515
import pytest_asyncio
16-
from fastapi import FastAPI
16+
from fastapi import APIRouter, FastAPI
1717
from httpx import ASGITransport, AsyncClient
1818
from pydantic import BaseModel, ValidationError
1919
from pytest_subtests import SubTests
@@ -682,7 +682,32 @@ async def _make_test_client(app: FastAPI) -> AsyncIterator[AsyncClient]:
682682
@pytest_asyncio.fixture
683683
async def server_async_client() -> AsyncIterator[AsyncClient]:
684684
server = TaskDatasetServer[DummyEnv](dataset=TaskDataset.from_name("dummy"))
685-
async with _make_test_client(app=server.app) as client:
685+
async with _make_test_client(app=cast(FastAPI, server.app)) as client:
686+
yield client
687+
688+
689+
class StubArgsTaskDataset(TaskDataset[DummyEnv]):
690+
"""Stub task dataset to exercise get_new_env_by_args."""
691+
692+
def get_new_env_by_args(self, *, task: str) -> DummyEnv: # type: ignore[override]
693+
return DummyEnv(task=task)
694+
695+
696+
@pytest_asyncio.fixture
697+
async def args_async_client() -> AsyncIterator[AsyncClient]:
698+
server = TaskDatasetServer[DummyEnv](dataset=StubArgsTaskDataset())
699+
async with _make_test_client(app=cast(FastAPI, server.app)) as client:
700+
yield client
701+
702+
703+
@pytest_asyncio.fixture
704+
async def mounted_async_client() -> AsyncIterator[AsyncClient]:
705+
server = TaskDatasetServer[DummyEnv](
706+
dataset=TaskDataset.from_name("dummy"), router=APIRouter(tags=["env"])
707+
)
708+
app = FastAPI()
709+
app.include_router(server.router, prefix="/env")
710+
async with _make_test_client(app=app) as client:
686711
yield client
687712

688713

@@ -764,7 +789,7 @@ async def slow_close(*_) -> None:
764789
# last_used=0 guarantees it's stale for any req.last_used >= 0
765790
server.envs["stale"] = (stale_env, 0.0)
766791

767-
async with _make_test_client(app=server.app) as client:
792+
async with _make_test_client(app=cast(FastAPI, server.app)) as client:
768793
with mock.patch.object(DummyEnv, "close", slow_close):
769794
# Kick off /close_old_envs; env.close() will await release_event
770795
close_task = asyncio.create_task(
@@ -844,6 +869,73 @@ async def test_step_with_tool_response_message(
844869
assert not done
845870
assert not truncated
846871

872+
@pytest.mark.asyncio
873+
async def test_start_raises_when_get_new_env_by_args_not_implemented(
874+
self, server_async_client: AsyncClient
875+
) -> None:
876+
# Dummy dataset doesn't implement get_new_env_by_args, so sending
877+
# task_kwargs should surface as a 500 via handle_exc_as_http_exc
878+
response = await server_async_client.post(
879+
"/start", json={"task_kwargs": {"task": "anything"}}
880+
)
881+
assert response.status_code == 500
882+
assert "get_new_env_by_args" in response.json()["detail"]
883+
884+
@pytest.mark.asyncio
885+
async def test_start_with_task_kwargs(self, args_async_client: AsyncClient) -> None:
886+
start_resp = await args_async_client.post(
887+
"/start", json={"task_kwargs": {"task": "five-word-story topic"}}
888+
)
889+
assert start_resp.status_code == 200
890+
env_id = start_resp.json()["env_id"]
891+
892+
# Reset and confirm the task made it into the initial observation.
893+
reset_resp = await args_async_client.post("/reset", json={"env_id": env_id})
894+
assert reset_resp.status_code == 200
895+
(obs,), tools = reset_resp.json()
896+
assert "five-word-story topic" in obs["content"]
897+
assert tools
898+
899+
@pytest.mark.asyncio
900+
async def test_start_rejects_both_task_idx_and_task_kwargs(
901+
self, args_async_client: AsyncClient
902+
) -> None:
903+
# Specifying both is ambiguous; server should reject at request validation
904+
start_resp = await args_async_client.post(
905+
"/start", json={"task_idx": 42, "task_kwargs": {"task": "kwargs-won"}}
906+
)
907+
assert start_resp.status_code == 422
908+
assert "mutually exclusive" in start_resp.text
909+
910+
@pytest.mark.asyncio
911+
async def test_start_reset_step_through_prefix(
912+
self, mounted_async_client: AsyncClient
913+
) -> None:
914+
"""End-to-end smoke test for the mounted-router code path."""
915+
start_resp = await mounted_async_client.post("/env/start", json={})
916+
assert start_resp.status_code == 200, (
917+
"Mounted router did not expose /env/start — route registration"
918+
" against external APIRouter is broken"
919+
)
920+
env_id = start_resp.json()["env_id"]
921+
922+
reset_resp = await mounted_async_client.post(
923+
"/env/reset", json={"env_id": env_id}
924+
)
925+
assert reset_resp.status_code == 200, (
926+
"Mounted router cannot retrieve the env it just created"
927+
)
928+
929+
action = ToolRequestMessage(
930+
tool_calls=[
931+
ToolCall.from_name("print_story", story="one two three four five")
932+
]
933+
)
934+
step_resp = await mounted_async_client.post(
935+
"/env/step", json={"env_id": env_id, "action": action.model_dump()}
936+
)
937+
assert step_resp.status_code == 200
938+
847939

848940
class TestDefaultNoToolCallsResponse:
849941
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)