feat(ollama): one container, several models, each its own priced app - #62
feat(ollama): one container, several models, each its own priced app#62rickstaa wants to merge 10 commits into
Conversation
runners.json advertises `vllm/qwen2.5-0.5b-instruct` by name while VLLM_MODEL let an operator serve something else. .env.example presented it as an ordinary knob, so turning it silently made the app id false, and discovery republished that to the network at whatever price was set. A static runner has no code to recompute its id: the container is the stock vllm image with zero Livepeer code, which is the point of the example. So the fix is to remove the drift rather than derive the name. Serving another model now means editing the compose command and runners.json together, which is the static registration bargain: the operator owns the contract, and the orchestrator health-polls the runner without ever checking it serves what the config claims. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new Ollama example demonstrating a realistic LLM deployment shape: one upstream container serving multiple models, with a registrar sidecar dynamically registering one Live Runner app per discovered model (per-model pricing) and a local OpenAI-compatible gateway that maps model → app id and forwards calls through the orchestrator.
Changes:
- Add
ollama/example: registrar sidecar (register_runnerper model), OpenAI-compatible gateway, and stock OpenAI client. - Add Docker/Compose setup for offchain + on-chain overlay, plus documentation for running and capacity sizing.
- Update repo-level README to include the new Ollama example in the comparison table and capability lists.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds ollama to the examples matrix and capability lists. |
| ollama/registrar.py | Sidecar that discovers models via Ollama /api/tags and registers one app per model with pricing/capacity. |
| ollama/README.md | Documents the multi-model design, capacity sizing rationale, and run instructions (offchain/on-chain). |
| ollama/pyproject.toml | Adds Python project metadata/deps for the example tooling. |
| ollama/gateway.py | Local OpenAI-compatible gateway: lists models from discovery and forwards requests to selected runners. |
| ollama/Dockerfile | Builds the registrar sidecar image. |
| ollama/compose.yml | Offchain compose: orchestrator + Ollama + puller + registrar. |
| ollama/compose.onchain.yml | On-chain overlay: adds signer and on-chain orchestrator wiring. |
| ollama/client.py | Stock OpenAI client example for buffered and streaming calls. |
| ollama/.env.example | Example environment configuration for offchain/on-chain runs. |
Suppressed comments (1)
ollama/.env.example:39
- These comments describe the runner price as coming from runners.json, but in this example pricing is dynamic (registrar PRICES/--price). The MAX_PRICE_PER_UNIT guidance should reference the metered USD/hour price configured via PRICES instead of runners.json.
# The runner's price lives in runners.json (static runner): USD per hour,
# converted to wei via the price feed and metered per second.
# Signer's max-price cap (payer side) is per billing unit, here one second, so
# it must exceed the runners.json price / 3600 (0.000111USD is ~0.40 USD/hour).
MAX_PRICE_PER_UNIT=0.000111USD
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (5)
ollama/.env.example:8
- These vLLM-specific env vars/comments appear to be copy/paste leftovers and are misleading for the Ollama example (models are discovered from Ollama tags, not configured via a single VLLM_MODEL). Remove them so the .env template only documents Ollama-related config.
# Model vLLM serves (must match the client's --model).
VLLM_MODEL=Qwen/Qwen2.5-0.5B-Instruct
# Models to pull, space separated. Which models exist is config; which models get
# advertised is discovered from Ollama by the registrar.
MODELS=qwen2.5:0.5b llama3.2:1b
ollama/.env.example:39
- This section references pricing coming from runners.json, but this example uses dynamic registration where per-model pricing comes from PRICES in the registrar. Update this to avoid confusing operators about where pricing is set.
# The runner's price lives in runners.json (static runner): USD per hour,
# converted to wei via the price feed and metered per second.
# Signer's max-price cap (payer side) is per billing unit, here one second, so
# it must exceed the runners.json price / 3600 (0.000111USD is ~0.40 USD/hour).
MAX_PRICE_PER_UNIT=0.000111USD
ollama/registrar.py:53
- _parse_prices() will raise a ValueError with a stack trace on malformed input (e.g. a non-float). Since this is operator-provided config, it should fail fast with a clear error message indicating which entry is invalid.
for item in raw.split(","):
if "=" in item:
name, _, value = item.partition("=")
prices[name.strip()] = float(value)
ollama/registrar.py:89
- When Ollama isn't ready yet, /api/tags can return non-200 responses or non-JSON bodies. resp.json() can raise exceptions that are not aiohttp.ClientError, which would abort the retry loop immediately. Consider raising for status and also retrying on JSON decode errors.
async with session.get(f"{base_url.rstrip('/')}/api/tags") as resp:
data = await resp.json()
ollama/registrar.py:109
- If --parallel/OLLAMA_NUM_PARALLEL is set to 0 (or negative), per_model will still be forced to 1 and the registrar will advertise capacity the container cannot run. Validate --parallel up front and exit with an explicit error if it's < 1.
per_model = max(1, args.parallel // len(models))
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>
register_runner and runners.json both accept it, it reaches the client through discovery, and nothing in the repo said so. Stating the boundary matters as much as the field: a caller filters and pays on the app id, so anything they choose on belongs there, and metadata is for detail the protocol has no place for. Says plainly that no example sets it, so the absence reads as a rule rather than an oversight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (5)
ollama/gateway.py:156
- _forward_or_error() only JSON-wraps LivepeerHTTPError, but other expected failures (discovery/selection errors, and the web.HTTPBadRequest/web.HTTPNotFound raised by _forward()) will return aiohttp’s default non-JSON responses. OpenAI clients typically expect JSON error bodies, so these should be normalized here too.
try:
return await _forward(request)
except LivepeerHTTPError as exc:
return web.json_response(
{"error": {"message": str(exc), "type": "livepeer_error"}},
ollama/registrar.py:54
- _parse_prices() will raise a ValueError traceback on malformed PRICES entries (e.g., "model=", non-numeric values, or stray whitespace), which makes misconfiguration hard to diagnose. It’s better to validate each entry and fail with a clear error message (or explicitly skip empty items) so the registrar doesn’t crash obscurely.
for item in raw.split(","):
if "=" in item:
name, _, value = item.partition("=")
prices[name.strip()] = float(value)
return prices
ollama/registrar.py:90
- _installed_models() doesn’t set a request timeout or check HTTP status before parsing JSON. If /api/tags hangs, returns non-JSON, or returns a non-2xx response, the registrar can block indefinitely or crash, rather than retrying cleanly within the 5-minute wait window.
async with session.get(f"{base_url.rstrip('/')}/api/tags") as resp:
data = await resp.json()
models = [m["name"] for m in data.get("models", []) if m.get("name")]
if models:
return sorted(models)
ollama/gateway.py:112
- In _forward(), invalid JSON bodies will currently bubble up as an unhandled exception (producing a 500/HTML error), and an empty runner_selector result will raise IndexError on cursor.candidates[0]. Both cases break OpenAI-client compatibility and should return a structured 4xx instead.
payload = await request.json() if request.can_read_body else {}
runner_path = request.path # e.g. /v1/chat/completions
model = str(payload.get("model", "")).strip()
if not model:
raise web.HTTPBadRequest(text="request must name a model")
ollama/gateway.py:32
- _forward_or_error() should also handle non-HTTP Livepeer selection/discovery failures (e.g., NoRunnerAvailableError / NoOrchestratorAvailableError), but gateway.py only imports LivepeerHTTPError. Importing LivepeerGatewayError allows returning JSON errors instead of aiohttp’s default HTML 500.
This issue also appears on line 152 of the same file.
from livepeer_gateway.errors import LivepeerHTTPError
WebSocket was the one transport the README declared but never showed, with the row pointing at an external repo. This is the example where the socket is required rather than convenient: HTTP cannot stream audio upstream and SSE only runs server to client, so speech-to-text needs both directions at once. The app is a small aiohttp server wrapping faster-whisper on CPU, so it runs anywhere, and the orchestrator proxies the upgrade straight through: nothing in the socket is Livepeer specific. Registered dynamic and persistent at USD per hour. A held-open socket and a metered session are the same idea, so the session is the billing unit and the meter runs for as long as the client holds it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example exists to show realtime transcription, so the model is a property of it rather than a setting. WHISPER_MODEL invited an operator to serve a bigger model under the same app id, at the same advertised price, with worse latency, and the app degrades by stretching partials rather than dropping audio, so accuracy would have been bought with lag. Device and compute type stay configurable: they change the hardware path, not the capability. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
66bd05d to
9e35568
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (8)
ollama/gateway.py:144
- Same as the streaming branch: forward the inbound HTTP method to call_runner() so the gateway doesn’t accidentally turn non-POST requests into POSTs.
result = await call_runner( # Livepeer: 3
runner=runner, # discovery metadata tells call_runner the price unit
runner_url=runner_url,
payload=payload,
signer_url=signer_url,
ollama/registrar.py:99
- --parallel/OLLAMA_NUM_PARALLEL can be set to 0 (or negative), but the capacity math later forces per-model capacity to at least 1, causing unintended over-advertising. Fail fast with a clear error when parallel < 1.
args = _parse_args()
prices = _parse_prices(args.prices)
ollama/registrar.py:108
- This comment claims the advertised total "matches the hardware". With the current floor division (parallel // len(models)), the advertised total can be less than --parallel when it isn’t evenly divisible (e.g., parallel=3, models=2 => advertised=2). Either adjust the wording here (and in the README) or adjust the capacity allocation logic.
# Split the container's real concurrency across the models it serves, so the
# advertised total matches the hardware instead of multiplying by model count.
# capacity 0 is not expressible (the orchestrator coerces it to 1), so with more
# models than parallel slots the total unavoidably overshoots -- say so loudly
# rather than quietly advertising capacity the GPU does not have.
ollama/README.md:34
- The README says the sum of advertised capacities "equals" what the hardware can do, but registrar.py uses floor division, so the sum may be lower when OLLAMA_NUM_PARALLEL isn’t divisible by the model count. Update this sentence to match the actual behavior (or update the registrar to distribute the remainder).
`OLLAMA_NUM_PARALLEL` says how many generations the container will really run at once. The registrar divides that across the models it registers, so the **sum** of the advertised capacities equals what the hardware can do.
ollama/gateway.py:112
- cursor.candidates[0] will raise IndexError when no runners match the requested model, causing a 500 instead of a readable OpenAI-style JSON error. Guard against empty candidate lists and return a 503/404 style JSON response.
cursor = await runner_selector( # Livepeer: 2
discovery_url=args.discovery, # omit if the signer does discovery itself
app=_app_id(model),
)
runner = cursor.candidates[0]
ollama/registrar.py:54
- _parse_prices() can crash with an unhelpful ValueError on invalid PRICES values (e.g., empty value or non-float), and silently ignores malformed entries without '='. Since PRICES is operator-supplied policy, validate it and exit with a clear message so misconfiguration is obvious.
for item in raw.split(","):
if "=" in item:
name, _, value = item.partition("=")
prices[name.strip()] = float(value)
return prices
ollama/registrar.py:90
- _installed_models() retries for up to 5 minutes, but each GET has no timeout and it doesn’t check HTTP status / JSON parse errors. A hung connection or a non-JSON error response can block longer than intended or crash the registrar; add a request timeout, gate on 200 responses, and treat JSON decode failures as retryable.
async with session.get(f"{base_url.rstrip('/')}/api/tags") as resp:
data = await resp.json()
models = [m["name"] for m in data.get("models", []) if m.get("name")]
if models:
return sorted(models)
ollama/gateway.py:125
- The gateway registers a catch-all route for /v1/*, but call_runner() is invoked without passing through the original HTTP method. This can mis-forward non-POST requests (e.g., OPTIONS/HEAD) by defaulting to call_runner’s default method. Pass method=request.method for parity with vllm/gateway.py.
This issue also appears on line 140 of the same file.
async with await call_runner( # Livepeer: 3 (streaming)
runner=runner, # discovery metadata tells call_runner the price unit
runner_url=runner_url,
payload=payload,
signer_url=signer_url,
The app id is a capability contract, so it should say what a caller is shopping for. streaming-asr named the technique and leaned on jargon; realtime-transcription names the thing being sold. The directory follows, so folder, app id, and docs agree. The model moves to large-v3-turbo, which swaps large-v3's 32-layer decoder for 4 and so runs far below realtime on a 3090 while staying near its quality. base.en kept pace on CPU but made errors a demo should not: "you have to go to perceive a terminal count" instead of "you have a go to proceed with terminal count", "to make the stay possible" instead of "to make this day possible". That makes the example GPU-only, joining vllm and streamdiffusion. It buys the thing the example exists to show, and the alternative was a knob that let one deployment quietly differ from another under one app id. CTranslate2 picks up cuBLAS and cuDNN from the nvidia pip wheels, so no CUDA base image is needed; the driver arrives through the compose device reservation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eb0adaf to
a43f71c
Compare
Every other example registers exactly once, so nothing showed the shape people actually deploy an LLM server in: one process, many models. A builder asking whether one deployment can serve several models billed separately had no answer here, and would likely guess wrong, since a runner carries one mode and one price_info and discovery keys on app. Ollama is the stock upstream image with no Livepeer code. A registrar sidecar asks it what it has and registers one app per model, which is what wrapping software you did not write looks like. Which models exist is discovered from /api/tags; what they cost is operator policy in PRICES. Two things fall out that nothing else in the repo could show. The app id is a stable slug, so llama3.2:1b becomes ollama/llama3.2-1b and cannot be reversed. The exact name therefore travels in metadata, which is the first real use for that field in this repo: app-specific data the network does not model but a caller needs. Capacity has to be sized by hand. Each registration carries its own counter and the orchestrator cannot know they share a GPU, so the sum is derived from OLLAMA_NUM_PARALLEL. capacity 0 is not expressible, so with more models than parallel slots the total overshoots; the registrar warns loudly rather than advertising capacity the GPU does not have. Listing is answered from discovery instead of the container, so it costs nothing: /discovery is a plain GET with no session and no payment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first version slugged the id, lowercasing it and turning `:` into `-`, then used metadata to carry back the exact name it had just destroyed. That is a denormalization repairing self-inflicted lossiness, not data the network was missing, and it quietly bent the rule the repo just wrote down: metadata is for facts the protocol does not model. go-livepeer only requires an app id be non-empty and trimmed, so `ollama/llama3.2:1b` is legal and the mapping becomes prefix-add and prefix-strip. That removes the metadata field, the JSON parse, its fallback branch, and the helper that held them. Verified against the hardest name in the test box's volume, which has capitals, a colon and several slashes: it registers, discovers, filters and forwards unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
.env.example was copied from vllm and still declared VLLM_MODEL, which
this example has no use for. And the puller read ${MODELS} inside its own
shell while nothing put MODELS in that container's environment, so setting
it in .env did nothing and the defaults were always pulled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9e35568 to
5bd3d85
Compare
99d2efa to
83d5f20
Compare
c18762d to
9827acf
Compare
Every example in the repo registers exactly once, so nothing showed the shape people actually deploy an LLM server in: one process, many models. A builder asking "can one deployment serve several models, billed separately?" had no answer here, and would probably guess wrong — a runner carries exactly one
modeand oneprice_info, and discovery keys onapp, so it necessarily means several registrations under distinct app ids.Ollama is the stock upstream image with no Livepeer code. A registrar sidecar asks it what it has (
GET /api/tags) and registers one app per model, all pointing at the same URL. That is what wrapping software you did not write looks like, and it sits between the two registration modes the repo already shows: dynamic registration of a container that has no idea Livepeer exists.Which models exist is discovered; what they cost is configured.
ollama pullsomething and it appears on the network. Prices are operator policy, so they live inPRICES.The app id is the model name, verbatim
llama3.2:1bregisters asollama/llama3.2:1b. go-livepeer only requires an app id be non-empty and trimmed, so there is no reason to slug it, and keeping it exact makes the mapping reversible in both directions.That matters because the first draft did slug it — lowercasing and turning
:into-— and then usedmetadatato carry back the string it had just destroyed. That is a denormalization repairing self-inflicted lossiness, not data the network was missing, and it bent the rule #61 writes down. Removing the slug removed the metadata field, a JSON parse, its fallback branch, and the helper holding them. Nothing in this example needs metadata, which keeps the repo's rule intact rather than breaking it in the first example that followed.Capacity has to be sized by hand
Each registration carries its own counter and the orchestrator cannot know they share a GPU (go-livepeer#4015), so the sum is derived from
OLLAMA_NUM_PARALLEL.capacity: 0is not expressible — the orchestrator coerces it to 1 — so with more models than parallel slots the total unavoidably overshoots. The registrar warns loudly rather than quietly advertising capacity the GPU does not have. The README carries an "Improvements this example is waiting on" section pointing at the same issue.Listing is free.
GET /v1/modelsis answered from/discovery, a plain GET with no session and no payment, so it reports what the network offers rather than what one container holds. Contrastvllm, where forwarding that GET costs a session.Verified end to end (RTX 3090, offchain)
The test box had four models in its Ollama volume, two of them unrelated leftovers — which made verification better than planned.
Registration and discovery, one app per discovered model, names exact:
That last one is the proof the exact-id approach holds: capitals, a colon, and several slashes, round-tripping through registration, discovery, exact-match filtering, and forwarding. A generation against it returned HTTP 200.
The overcommit warning fired, because four models against
OLLAMA_NUM_PARALLEL=2cannot each hold a slot:A real bug caught during verification — the first draft did that division silently.
Generation works buffered and streaming, each
--modelreaching the right backend. Capacity refusal: three concurrent requests against acapacity: 1model gave one200and two JSON503s.Notes
Deliberately not added to
images.yml: the only Dockerfile here builds the registrar, not a runner, and the CI convention publishesrunner-example-<name>. Publishing a sidecar under that name would be misleading.This adds a dimension the axis table does not have — capabilities per process — while its four axis values are all already covered. Worth deciding whether the table grows a column or this is filed as the exception.
🤖 Generated with Claude Code