feat(vllm): register single-shot so the call pays as it runs - #59
feat(vllm): register single-shot so the call pays as it runs#59rickstaa wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR switches the vllm example to single-shot runner mode now that single-shot metered payment can be maintained across the duration of a call, and updates the local OpenAI-compatible gateway to use the simpler single-shot discovery → call flow while preserving streaming (SSE).
Changes:
- Update
vllmrunner registration tomode: "single-shot"(staticrunners.json). - Simplify
vllm/gateway.pyfrom reserve/call/release torunner_selector→call_runner, including explicit handling for upstream 503 capacity errors. - Refresh repo and example documentation to reflect single-shot + metered pricing behavior and the updated call flow.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| vllm/runners.json | Switch vLLM runner mode from persistent to single-shot. |
| vllm/README.md | Update example docs to reflect single-shot mode and metered-per-call behavior. |
| vllm/gateway.py | Drop session management; use discovery + call_runner for buffered + SSE requests; add Livepeer HTTP error mapping. |
| README.md | Update the top-level matrix and runner-mode guidance to list vllm as single-shot. |
Suppressed comments (1)
vllm/gateway.py:123
- _forward_or_error only handles LivepeerHTTPError. If runner discovery fails or no candidates are available (errors that are not HTTP status codes), the gateway will still return aiohttp's default 500 instead of a readable OpenAI-style JSON error.
except LivepeerHTTPError as exc:
return web.json_response(
{"error": {"message": str(exc), "type": "livepeer_error"}},
status=exc.status_code,
)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| from livepeer_gateway.errors import LivepeerHTTPError | ||
| from livepeer_gateway.live_runner import call_runner | ||
| from livepeer_gateway.selection import runner_selector |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (3)
vllm/gateway.py:42
_forward_or_errorcurrently only catchesLivepeerHTTPError.runner_selector/call_runnercan also raise otherLivepeerGatewayErrorsubclasses (e.g. no orchestrator/runner available, as handled in other examples), which would again surface as aiohttp's default 500. Importing the base error here allows returning a JSON error consistently.
from livepeer_gateway.errors import LivepeerHTTPError
from livepeer_gateway.live_runner import call_runner
from livepeer_gateway.selection import runner_selector
vllm/gateway.py:131
- Errors like
NoOrchestratorAvailableError/NoRunnerAvailableError(which areLivepeerGatewayErrorsubclasses) will currently fall through and become aiohttp's default 500 HTML response. Returning a JSON error for these cases keeps the gateway consistently OpenAI-client-friendly (even when there isn't a usable upstream).
try:
return await _forward(request)
except LivepeerHTTPError as exc:
return web.json_response(
{"error": {"message": str(exc), "type": "livepeer_error"}},
status=exc.status_code,
)
vllm/gateway.py:83
cursor.candidates[0]will raiseIndexErrorwhen discovery returns zero candidates, which would surface as an opaque 500 to OpenAI clients. Alsorunner_urlis built fromrequest.path, which drops any query string; usingrequest.rel_urlpreserves path + query for full reverse-proxy behavior.
runner = cursor.candidates[0]
runner_url = runner.url.rstrip("/") + runner_path
vllm registered as persistent while the README apologised for it: the app is one request in, one response out, with no state to keep between calls. It stayed persistent because a single-shot call could not keep paying, so metering it needed a session the gateway held open by hand. That capability is on the pinned SDK branch now. call_runner starts a funding loop when the price is metered, and for a streamed response the stream owns that loop, so an SSE generation pays for as long as tokens flow. The orchestrator reserves a session around the call and releases it when the response returns. So the gateway drops from three SDK calls to two: discover, then call. This also fills the empty cell in the axis table, single-shot paired with metered pricing, which nothing showed before. One behaviour becomes visible: a single-shot call holds a capacity slot for its duration, so a second concurrent request gets 503 from the orchestrator. That escaped as an opaque aiohttp 500, so it is now handed back as a JSON error an OpenAI client can read. Closes #4, closes #5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gateway registered POST only while claiming to forward every OpenAI path, so GET /v1/models answered 405 without ever reaching vLLM, which serves that route. An OpenAI client calling models.list() failed against a gateway whose whole claim is that any OpenAI client just works. Forwarding a verb means passing it on: call_runner defaults to POST, so a client's GET would otherwise arrive at vLLM as a POST and be refused one hop further along. A GET also carries no body, hence the read guard. The timeout was the SDK's 5s default, which only ever passed because Qwen2.5-0.5B answers fast. A larger model or a longer generation hit it, which contradicts the point of metered single-shot: the call pays for as long as it runs, so it should be allowed to run. Listing models is a real call, so it reserves a session and on-chain pays for it. A production gateway would cache that; an example says so instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6351e00 to
de30cc9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
vllm/gateway.py:132
_forward_or_erroronly catchesLivepeerHTTPError. Discovery/selection failures (e.g., no orchestrator/runner available) and other gateway-level errors will still surface as aiohttp’s default 500, which undermines the goal of returning a readable JSON error to OpenAI clients. Consider also catchingLivepeerGatewayError(or the specific selection errors) and mapping them to an appropriate upstream-facing status (e.g., 502/503) with the same JSON error envelope.
async def _forward_or_error(request: web.Request) -> web.StreamResponse:
# A single-shot call holds a capacity slot for its duration, so a busy runner
# answers 503. Hand that back as JSON an OpenAI client can read.
try:
return await _forward(request)
except LivepeerHTTPError as exc:
return web.json_response(
{"error": {"message": str(exc), "type": "livepeer_error"}},
status=exc.status_code,
)
vllm/gateway.py:83
runner_path = request.pathdrops query parameters, so any request like/v1/...?...will be forwarded without its query string. Userequest.path_qs(orrequest.rel_url) so the upstream sees the same URL the client sent.
# GET /v1/models carries no body; everything else posts JSON.
payload = await request.json() if request.can_read_body else {}
runner_path = request.path # e.g. /v1/chat/completions
cursor = await runner_selector( # Livepeer: 1
discovery_url=args.discovery, # omit if the signer does discovery itself
app=APP_ID,
)
runner = cursor.candidates[0]
runner_url = runner.url.rstrip("/") + runner_path
vllmregistered aspersistentwhile its own README apologised for it:The app is one request in, one response out, with no state between calls. It stayed persistent only because a single-shot call could not keep paying, so metering it required a session the gateway held open by hand.
That capability is on the pinned SDK branch now.
call_runnersetsneeds_ongoing_fundingwhen the price unit is metered and starts a funding loop around the request (live_runner.py:862,888-906); for a streamed response the returned stream owns that loop, so an SSE generation keeps paying while tokens flow. Orchestrator side,ProxyLiveRunnerSingleShotreserves a session around the call and defersReleaseSessionuntil the response finishes.So
_forwarddrops from three SDK calls to two, and now reads exactly likehello-world:This fills the empty cell in the axis table. Every single-shot example paid once and every metered one was persistent, so single-shot paired with metered pricing — one call, unknown duration, paid per second, client managing no session — had no example.
Verified end to end
Ran the real stack on an RTX 3090, offchain.
client.py --prompt "In one sentence, what is Livepeer?"returned a normal completion.client.py --streamprinted a haiku token by token, so SSE survives the mode change.One behaviour this exposes
A single-shot call holds its capacity slot for the whole call, so with
capacity: 1a second concurrent request is refused. Testing that, the orchestrator's 503 escaped the gateway as aiohttp's default500 Server got itself in trouble— useless to an OpenAI client. It is now caught and returned as JSON with the upstream status:That failure predates this PR, but under single-shot a busy runner is the normal thing a concurrent client meets, so it should not surface as an opaque 500.
Supersedes #9, which only touched the README and
runners.jsonand never convertedgateway.py. Closes #4, closes #5.🤖 Generated with Claude Code