Skip to content

Commit 2020c08

Browse files
Allow opt-in concurrent Codex runs
1 parent 9fda064 commit 2020c08

6 files changed

Lines changed: 133 additions & 33 deletions

File tree

.env.example

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ MAX_REQUEST_BODY_BYTES=262144
2020
MAX_MESSAGES=32
2121
MAX_TOTAL_TEXT_CHARS=80000
2222

23-
# Local admission queue for short bursts while preserving one active Codex run.
23+
# Local admission queue for short bursts while all Codex run slots are busy.
2424
# 0 fails fast with wrapper_busy 429. Values above 0 wait up to that many
25-
# seconds for the active run to finish. The app clamps this to 0-5 seconds.
25+
# seconds for a run slot to open. The app clamps this to 0-5 seconds.
2626
QUEUE_WAIT_SECONDS=0
27+
28+
# Optional provider-side parallelism. Keep 1 unless local wrapper_busy 429s are
29+
# the bottleneck and the signed-in Codex account tolerates two concurrent CLI
30+
# executions. The app clamps this to 1-2.
31+
MAX_CONCURRENT_CODEX_RUNS=1
2732
CORS_ALLOWED_ORIGINS=
2833
LOG_LEVEL=INFO

README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ project.
3535
- `GET /v1/models` with the local model alias `codex-cli-default`.
3636
- `POST /v1/chat/completions` for text-only chat messages.
3737
- Non-streaming responses and final-only SSE streaming.
38-
- One active Codex execution at a time, with an optional short local wait queue.
38+
- One active Codex execution by default, with an optional second local execution
39+
slot and short local wait queue.
3940
- A local operator dashboard at `http://127.0.0.1:8320/dashboard/`.
4041

4142
## What It Does Not Provide
@@ -228,8 +229,11 @@ For Obsidian LLM Wiki, use:
228229
required for the inspected OpenAI-compatible provider path.
229230

230231
If near-simultaneous local requests collide, set `QUEUE_WAIT_SECONDS=2` or `3`
231-
in `.env` and restart the wrapper. The wrapper still permits only one active
232-
Codex run.
232+
in `.env` and restart the wrapper. If the collisions are confirmed
233+
`wrapper_busy` responses rather than upstream rate limits, you can also set
234+
`MAX_CONCURRENT_CODEX_RUNS=2` to permit a second local Codex CLI execution.
235+
Keep it at `1` if the signed-in Codex account starts returning
236+
`upstream_rate_limit`.
233237

234238
## Configuration
235239

@@ -245,6 +249,7 @@ Most configuration lives in `.env`, copied from `.env.example`.
245249
| `MAX_MESSAGES` | Maximum number of chat messages. |
246250
| `MAX_TOTAL_TEXT_CHARS` | Maximum total text across messages. |
247251
| `QUEUE_WAIT_SECONDS` | Short local wait queue for bursty clients. Values are clamped to `0-5`. |
252+
| `MAX_CONCURRENT_CODEX_RUNS` | Number of provider-side Codex executions allowed at once. Values are clamped to `1-2`; default `1`. |
248253
| `CORS_ALLOWED_ORIGINS` | Optional comma-separated explicit origins. Requests without `Origin` are allowed. |
249254
| `LOG_LEVEL` | Python wrapper log level. |
250255

@@ -360,11 +365,11 @@ docker compose restart
360365
`500000`, and restart.
361366
- `413` with `Request body too large`: increase `MAX_REQUEST_BODY_BYTES`, up to
362367
`2000000`, and restart.
363-
- `429` with `code: "wrapper_busy"`: wait for the active request to finish,
364-
reduce client concurrency to `1`, increase batch delay, or set a small
365-
`QUEUE_WAIT_SECONDS`.
368+
- `429` with `code: "wrapper_busy"`: wait for active requests to finish,
369+
reduce client concurrency to `1`, increase batch delay, set a small
370+
`QUEUE_WAIT_SECONDS`, or opt into `MAX_CONCURRENT_CODEX_RUNS=2`.
366371
- `429` with `code: "upstream_rate_limit"`: the signed-in upstream account is
367-
rate limited; wait and retry later.
372+
rate limited; wait and retry later, and keep `MAX_CONCURRENT_CODEX_RUNS=1`.
368373
- `502` or `/healthz` returning `503`: check
369374
`docker exec -it codex-cli-provider codex login status`, then re-run device
370375
login if needed.

src/dashboard_static/app.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,16 @@ function renderStatus(data) {
7878

7979
setText("#runner-value", runner.busy ? "Busy" : runner.ready ? "Ready" : "Unready");
8080
setClass("#runner-value", runner.ready && !runner.busy ? "ok-text" : runner.busy ? "warn-text" : "bad-text");
81-
setText("#runner-detail", data.provider.modelAlias);
81+
setText("#runner-detail", `${runner.activeRuns}/${runner.maxConcurrentRuns} active - ${data.provider.modelAlias}`);
8282

8383
setText("#event-value", String(events.total));
8484
setText("#event-detail", `${events.errors} errors, p95 ${events.p95DurationMs ?? "-"} ms`);
8585

8686
setText("#limit-body", formatBytes(limits.maxBodyBytes));
8787
setText("#limit-text", `${limits.maxTotalTextChars.toLocaleString()} chars`);
8888
setText("#limit-timeout", `${limits.requestTimeoutSeconds}s`);
89+
setText("#limit-concurrency", `${limits.maxConcurrentRuns} run${limits.maxConcurrentRuns === 1 ? "" : "s"}`);
90+
setText("#limit-queue", `${limits.queueWaitSeconds}s`);
8991
setText("#last-refresh", `Updated ${new Date().toLocaleTimeString()}`);
9092
}
9193

src/dashboard_static/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ <h2>Runtime Limits</h2>
8686
<dt>Timeout</dt>
8787
<dd id="limit-timeout">-</dd>
8888
</div>
89+
<div>
90+
<dt>Runs</dt>
91+
<dd id="limit-concurrency">-</dd>
92+
</div>
93+
<div>
94+
<dt>Queue</dt>
95+
<dd id="limit-queue">-</dd>
96+
</div>
8997
</dl>
9098
</article>
9199

src/server.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

382385
def 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)

tests/test_server.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ async def request(app, method, url, token=TEST_SECRET, **kwargs):
9090
return await client.request(method, url, headers=headers, **kwargs)
9191

9292

93+
async def wait_for_prompt_count(runner, count):
94+
for _ in range(50):
95+
if len(runner.prompts) >= count:
96+
return
97+
await asyncio.sleep(0.01)
98+
raise AssertionError(f"expected {count} prompts, got {len(runner.prompts)}")
99+
100+
93101
def test_default_app_uses_local_runner():
94102
app = create_app(settings())
95103
assert isinstance(app.state.runner, LocalCodexRunner)
@@ -101,6 +109,15 @@ def test_app_settings_do_not_require_runner_socket():
101109
assert not hasattr(configured, "runner_api_key")
102110

103111

112+
def test_admission_settings_are_clamped():
113+
high = AppSettings(proxy_api_key=TEST_SECRET, queue_wait_seconds=99, max_concurrent_codex_runs=99)
114+
low = AppSettings(proxy_api_key=TEST_SECRET, queue_wait_seconds=-1, max_concurrent_codex_runs=-1)
115+
assert high.queue_wait_seconds == 5.0
116+
assert high.max_concurrent_codex_runs == 2
117+
assert low.queue_wait_seconds == 0.0
118+
assert low.max_concurrent_codex_runs == 1
119+
120+
104121
@pytest.mark.asyncio
105122
async def test_health_ready_and_unready():
106123
ready_app = create_app(settings(), FakeRunner(ready=True))
@@ -149,6 +166,19 @@ async def test_dashboard_status_is_sanitized_and_unauthenticated():
149166
assert TEST_SECRET not in response.text
150167

151168

169+
@pytest.mark.asyncio
170+
async def test_dashboard_status_reports_admission_limits():
171+
app = create_app(settings(max_concurrent_codex_runs=2, queue_wait_seconds=3), FakeRunner())
172+
response = await request(app, "GET", "/dashboard/api/status", token=None)
173+
assert response.status_code == 200
174+
body = response.json()
175+
assert body["provider"]["runner"]["activeRuns"] == 0
176+
assert body["provider"]["runner"]["maxConcurrentRuns"] == 2
177+
assert body["provider"]["runner"]["busy"] is False
178+
assert body["provider"]["limits"]["queueWaitSeconds"] == 3
179+
assert body["provider"]["limits"]["maxConcurrentRuns"] == 2
180+
181+
152182
@pytest.mark.asyncio
153183
async def test_dashboard_can_be_disabled():
154184
app = create_app(settings(dashboard_enabled=False), FakeRunner())
@@ -477,6 +507,47 @@ async def test_single_flight_queue_timeout_returns_wrapper_busy_429():
477507
assert second.json()["error"]["code"] == "wrapper_busy"
478508

479509

510+
@pytest.mark.asyncio
511+
async def test_two_execution_slots_allow_two_parallel_requests():
512+
runner = BlockingRunner()
513+
app = create_app(settings(max_concurrent_codex_runs=2, queue_wait_seconds=0), runner)
514+
payload = {"model": "codex-cli-default", "messages": [{"role": "user", "content": "hello"}]}
515+
516+
first = asyncio.create_task(request(app, "POST", "/v1/chat/completions", json=payload))
517+
second = asyncio.create_task(request(app, "POST", "/v1/chat/completions", json=payload))
518+
await wait_for_prompt_count(runner, 2)
519+
520+
status = await request(app, "GET", "/dashboard/api/status", token=None)
521+
assert status.json()["provider"]["runner"]["activeRuns"] == 2
522+
assert status.json()["provider"]["runner"]["busy"] is True
523+
524+
runner.release.set()
525+
first_response = await first
526+
second_response = await second
527+
assert first_response.status_code == 200
528+
assert second_response.status_code == 200
529+
530+
531+
@pytest.mark.asyncio
532+
async def test_concurrency_limit_returns_429_when_all_slots_busy():
533+
runner = BlockingRunner()
534+
app = create_app(settings(max_concurrent_codex_runs=2, queue_wait_seconds=0), runner)
535+
payload = {"model": "codex-cli-default", "messages": [{"role": "user", "content": "hello"}]}
536+
537+
first = asyncio.create_task(request(app, "POST", "/v1/chat/completions", json=payload))
538+
second = asyncio.create_task(request(app, "POST", "/v1/chat/completions", json=payload))
539+
await wait_for_prompt_count(runner, 2)
540+
541+
third = await request(app, "POST", "/v1/chat/completions", json=payload)
542+
runner.release.set()
543+
await first
544+
await second
545+
546+
assert third.status_code == 429
547+
assert third.headers["retry-after"] == "3"
548+
assert third.json()["error"]["code"] == "wrapper_busy"
549+
550+
480551
@pytest.mark.asyncio
481552
async def test_runner_errors_are_mapped():
482553
cases = [

0 commit comments

Comments
 (0)