66import uuid
77from contextlib import contextmanager
88from 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
1414from aviary .message import Message
1515from aviary .tools import (
1616 MessagesAdapter ,
2121
2222try :
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
4668class EnvRequest (BaseModel ):
@@ -60,17 +82,14 @@ class FlushRequest(BaseModel):
6082BIND_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-
6785class 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 )
0 commit comments