aquaman164 commited on
Commit
0ff0913
·
verified ·
1 Parent(s): ec002e1

Upload code/vq_proxy.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. code/vq_proxy.py +38 -3
code/vq_proxy.py CHANGED
@@ -30,6 +30,7 @@ Notes:
30
  are folded into user turns — Qwen chat templates only allow system first.
31
  """
32
  import argparse
 
33
  import json
34
  import os
35
  import sys
@@ -66,15 +67,37 @@ if _timeout_env in ("", "0", "none", "None"):
66
  else:
67
  _READ_TIMEOUT = float(_timeout_env)
68
 
 
 
 
 
 
 
 
 
 
69
  @asynccontextmanager
70
  async def lifespan(app):
71
  app.state.client = httpx.AsyncClient(timeout=httpx.Timeout(_READ_TIMEOUT, connect=10.0))
 
72
  try:
73
  yield
74
  finally:
75
  await app.state.client.aclose()
76
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  app = FastAPI(title="vq_proxy", lifespan=lifespan)
79
 
80
 
@@ -317,7 +340,7 @@ def openai_to_anthropic_response(oai, model_name):
317
  FORWARD_THINKING = os.environ.get("VQ_PROXY_THINKING", "1") != "0"
318
 
319
 
320
- async def anthropic_stream(oai_body, model_name, est_input):
321
  """Consume the upstream OpenAI SSE stream, yield Anthropic SSE events."""
322
  msg_id = _new_id("msg")
323
  open_kind = None # "think" | "text" | "tool" | None
@@ -364,6 +387,17 @@ async def anthropic_stream(oai_body, model_name, est_input):
364
  yield _sse("ping", {"type": "ping"})
365
 
366
  async for line in resp.aiter_lines():
 
 
 
 
 
 
 
 
 
 
 
367
  if not line or not line.startswith("data:"):
368
  continue
369
  payload = line[len("data:"):].strip()
@@ -485,12 +519,13 @@ async def messages(request: Request):
485
  if stream:
486
  est_input = max(1, len(json.dumps(oai_body.get("messages", []), ensure_ascii=False)) // 4)
487
  return StreamingResponse(
488
- anthropic_stream(oai_body, model_name, est_input),
489
  media_type="text/event-stream",
490
  )
491
 
492
  try:
493
- resp = await app.state.client.post(f"{UPSTREAM_URL}/v1/chat/completions", json=oai_body)
 
494
  except httpx.HTTPError as exc:
495
  return JSONResponse(status_code=502, content={
496
  "type": "error",
 
30
  are folded into user turns — Qwen chat templates only allow system first.
31
  """
32
  import argparse
33
+ import asyncio
34
  import json
35
  import os
36
  import sys
 
67
  else:
68
  _READ_TIMEOUT = float(_timeout_env)
69
 
70
+ # The single GPU can only do one big prefill at a time without OOMing the Metal
71
+ # working set. mlx_lm.server batches concurrent HTTP requests, so if Claude Code
72
+ # retries/resends (or fires a background request) while a large prefill is in
73
+ # flight, two prefills stack on the GPU. Serialize upstream generations here so
74
+ # vq_serve never sees more than VQ_MAX_CONCURRENCY at once. Raise it only on a
75
+ # machine with unified memory to spare.
76
+ _MAX_CONCURRENCY = max(1, int(os.environ.get("VQ_MAX_CONCURRENCY", "1")))
77
+
78
+
79
  @asynccontextmanager
80
  async def lifespan(app):
81
  app.state.client = httpx.AsyncClient(timeout=httpx.Timeout(_READ_TIMEOUT, connect=10.0))
82
+ app.state.sem = asyncio.Semaphore(_MAX_CONCURRENCY)
83
  try:
84
  yield
85
  finally:
86
  await app.state.client.aclose()
87
 
88
 
89
+ async def _with_sem(gen, sem):
90
+ """Hold `sem` for the lifetime of async generator `gen`, releasing on normal
91
+ completion OR on cancellation (client disconnect). This is what frees the slot
92
+ so a queued retry can proceed instead of stacking a second upstream prefill."""
93
+ await sem.acquire()
94
+ try:
95
+ async for item in gen:
96
+ yield item
97
+ finally:
98
+ sem.release()
99
+
100
+
101
  app = FastAPI(title="vq_proxy", lifespan=lifespan)
102
 
103
 
 
340
  FORWARD_THINKING = os.environ.get("VQ_PROXY_THINKING", "1") != "0"
341
 
342
 
343
+ async def anthropic_stream(oai_body, model_name, est_input, request=None):
344
  """Consume the upstream OpenAI SSE stream, yield Anthropic SSE events."""
345
  msg_id = _new_id("msg")
346
  open_kind = None # "think" | "text" | "tool" | None
 
387
  yield _sse("ping", {"type": "ping"})
388
 
389
  async for line in resp.aiter_lines():
390
+ # During a long prefill this generator yields nothing (keepalive lines
391
+ # below are skipped), so Starlette can't detect a client disconnect on
392
+ # a send. Poll it explicitly here — mlx_lm emits keepalive lines every
393
+ # chunk, so this loop keeps ticking — and bail out on disconnect so the
394
+ # async-with closes the upstream connection and vq_serve aborts the run.
395
+ if request is not None:
396
+ try:
397
+ if await request.is_disconnected():
398
+ break
399
+ except Exception:
400
+ pass
401
  if not line or not line.startswith("data:"):
402
  continue
403
  payload = line[len("data:"):].strip()
 
519
  if stream:
520
  est_input = max(1, len(json.dumps(oai_body.get("messages", []), ensure_ascii=False)) // 4)
521
  return StreamingResponse(
522
+ _with_sem(anthropic_stream(oai_body, model_name, est_input, request), app.state.sem),
523
  media_type="text/event-stream",
524
  )
525
 
526
  try:
527
+ async with app.state.sem:
528
+ resp = await app.state.client.post(f"{UPSTREAM_URL}/v1/chat/completions", json=oai_body)
529
  except httpx.HTTPError as exc:
530
  return JSONResponse(status_code=502, content={
531
  "type": "error",