@@ -145,6 +145,7 @@ class AppSettings:
145145 max_total_text_chars : int = 80_000
146146 codex_request_timeout_seconds : int = 180
147147 queue_wait_seconds : float = 0.0
148+ max_concurrent_codex_runs : int = 1
148149 dashboard_enabled : bool = True
149150
150151 def __post_init__ (self ) -> None :
@@ -156,6 +157,7 @@ def __post_init__(self) -> None:
156157 self .max_total_text_chars = min (max (int (self .max_total_text_chars ), 1 ), 500_000 )
157158 self .codex_request_timeout_seconds = min (max (int (self .codex_request_timeout_seconds ), 5 ), 900 )
158159 self .queue_wait_seconds = min (max (float (self .queue_wait_seconds ), 0.0 ), 5.0 )
160+ self .max_concurrent_codex_runs = min (max (int (self .max_concurrent_codex_runs ), 1 ), 2 )
159161
160162 @classmethod
161163 def from_env (cls ) -> "AppSettings" :
@@ -175,6 +177,7 @@ def from_env(cls) -> "AppSettings":
175177 max_total_text_chars = int (os .environ .get ("MAX_TOTAL_TEXT_CHARS" , "80000" )),
176178 codex_request_timeout_seconds = int (os .environ .get ("CODEX_REQUEST_TIMEOUT_SECONDS" , "180" )),
177179 queue_wait_seconds = float (os .environ .get ("QUEUE_WAIT_SECONDS" , "0" )),
180+ max_concurrent_codex_runs = int (os .environ .get ("MAX_CONCURRENT_CODEX_RUNS" , "1" )),
178181 dashboard_enabled = os .environ .get ("DASHBOARD_ENABLED" , "true" ).strip ().lower () not in {"0" , "false" , "no" , "off" },
179182 )
180183
@@ -340,32 +343,31 @@ def summarize_dashboard_events(events: list[dict[str, Any]]) -> dict[str, Any]:
340343 }
341344
342345
343- async def execute_single_flight (app : FastAPI , settings : AppSettings , prompt : str ) -> str :
344- lock : asyncio .Lock = app .state .execution_lock
346+ def wrapper_busy_error () -> APIError :
347+ return APIError (
348+ 429 ,
349+ "Another Codex execution is already running" ,
350+ "rate_limit_error" ,
351+ code = "wrapper_busy" ,
352+ headers = {"Retry-After" : "3" },
353+ )
354+
355+
356+ async def execute_with_admission (app : FastAPI , settings : AppSettings , prompt : str ) -> str :
357+ semaphore : asyncio .Semaphore = app .state .execution_semaphore
345358 acquired = False
346- if lock .locked ():
359+ if semaphore .locked ():
347360 if settings .queue_wait_seconds == 0 :
348- raise APIError (
349- 429 ,
350- "Another Codex execution is already running" ,
351- "rate_limit_error" ,
352- code = "wrapper_busy" ,
353- headers = {"Retry-After" : "3" },
354- )
361+ raise wrapper_busy_error ()
355362 try :
356- await asyncio .wait_for (lock .acquire (), timeout = settings .queue_wait_seconds )
363+ await asyncio .wait_for (semaphore .acquire (), timeout = settings .queue_wait_seconds )
357364 acquired = True
358365 except asyncio .TimeoutError as exc :
359- raise APIError (
360- 429 ,
361- "Another Codex execution is already running" ,
362- "rate_limit_error" ,
363- code = "wrapper_busy" ,
364- headers = {"Retry-After" : "3" },
365- ) from exc
366+ raise wrapper_busy_error () from exc
366367 else :
367- await lock .acquire ()
368+ await semaphore .acquire ()
368369 acquired = True
370+ app .state .active_executions += 1
369371 try :
370372 return await app .state .runner .execute (
371373 prompt ,
@@ -376,7 +378,8 @@ async def execute_single_flight(app: FastAPI, settings: AppSettings, prompt: str
376378 raise map_runner_error (exc ) from exc
377379 finally :
378380 if acquired :
379- lock .release ()
381+ app .state .active_executions = max (0 , app .state .active_executions - 1 )
382+ semaphore .release ()
380383
381384
382385def create_app (settings : AppSettings | None = None , runner : Any | None = None ) -> FastAPI :
@@ -385,7 +388,8 @@ def create_app(settings: AppSettings | None = None, runner: Any | None = None) -
385388 app = FastAPI (title = "codex-cli-provider" , docs_url = None , redoc_url = None , openapi_url = None )
386389 app .state .settings = settings
387390 app .state .runner = runner
388- app .state .execution_lock = asyncio .Lock ()
391+ app .state .execution_semaphore = asyncio .Semaphore (settings .max_concurrent_codex_runs )
392+ app .state .active_executions = 0
389393 app .state .dashboard_events = deque (maxlen = DASHBOARD_EVENT_LIMIT )
390394
391395 if settings .cors_allowed_origins :
@@ -475,6 +479,7 @@ async def dashboard_status() -> JSONResponse:
475479 require_dashboard_enabled ()
476480 status = await runner .status ()
477481 events = list (app .state .dashboard_events )
482+ active_executions = int (app .state .active_executions )
478483 return dashboard_json ({
479484 "time" : now_iso (),
480485 "provider" : {
@@ -485,13 +490,17 @@ async def dashboard_status() -> JSONResponse:
485490 },
486491 "runner" : {
487492 "ready" : bool (status .get ("ready" )),
488- "busy" : bool (app .state .execution_lock .locked ()),
493+ "busy" : active_executions >= settings .max_concurrent_codex_runs ,
494+ "activeRuns" : active_executions ,
495+ "maxConcurrentRuns" : settings .max_concurrent_codex_runs ,
489496 },
490497 "limits" : {
491498 "maxBodyBytes" : settings .max_body_bytes ,
492499 "maxMessages" : settings .max_messages ,
493500 "maxTotalTextChars" : settings .max_total_text_chars ,
494501 "requestTimeoutSeconds" : settings .codex_request_timeout_seconds ,
502+ "queueWaitSeconds" : settings .queue_wait_seconds ,
503+ "maxConcurrentRuns" : settings .max_concurrent_codex_runs ,
495504 },
496505 },
497506 "events" : summarize_dashboard_events (events ),
@@ -535,7 +544,7 @@ async def chat(payload: dict[str, Any] = Body(...), _: None = Depends(require_au
535544 prompt = build_codex_prompt (messages )
536545 start = time .perf_counter ()
537546 try :
538- text = await execute_single_flight (app , settings , prompt )
547+ text = await execute_with_admission (app , settings , prompt )
539548 finally :
540549 elapsed_ms = int ((time .perf_counter () - start ) * 1000 )
541550 LOGGER .info ("request complete route=/v1/chat/completions elapsed_ms=%s model=%s" , elapsed_ms , MODEL_ALIAS )
0 commit comments