feat(core-app,infra): a hosted-only deployment runs no local runtime - #964
Merged
Merged
Conversation
added 2 commits
September 19, 2026 10:50
`OLLAMA_URL=""` now means *there is no local runtime*, deliberately — distinct from "the runtime is unreachable" and from "misconfigured". Those three facts used to be one, so each call site guessed: some caught and degraded, some propagated to a 500, one polled for three minutes. - `CoreAppSettings.local_runtime_enabled` is the fact; every path reads it. - `GET /platform/v1/llm/local-runtime` reports `absent` | `unreachable` | `ok` plus `url_configured`. A separate endpoint: `GET /llm/models` stays a bare `list[ModelInfo]` (twelve consumers) and never 500s again — `[]` in both non-serving states, which is the regression fix for the 500 the Models page collected every ten seconds. - pull / pull-stream / delete / unload refuse with 409 when absent and 502 when a configured runtime is unreachable, never a bare 500; the SSE pull refuses before the response starts. The KV-cache apply refuses with 409 when absent and persists nothing — it never talks to Ollama, so "unreachable" is not a state it can observe. - `_ensure_can_serve` refuses a local model id with the existing `ModelCapabilityError` (ADR-0140), asked before the pause rule, since "resume to run inference" is an instruction an operator with no runtime cannot follow. A hosted embedding model keeps working; a bare one fails with the fix in the message instead of a connection error. The chat fallback chain skips local candidates for the same reason. - Readiness reports the model `n/a` and ready, instead of warming forever. - The bootstrap returns in one log line, with no 180s poll, and now waits on the runtime *state* rather than on `models()` raising. - The KV-cache apply returns its existing unavailable result and asks neither arm of the container seam to find a workload. Two test fixes ride along: the tool-rejection warning and the api-key-redaction assertions moved off `capture_logs` onto the `_RecordingLog` recorder the file already documents, because structlog freezes a module logger on first use once the app has configured logging — a capture that intercepts nothing made the redaction assertion vacuously true. core-app 0.128.0 -> 0.129.0 (MINOR). Part of #962.
…ntime
The mode the core now speaks had no way to be selected. The Helm chart refused
to render it — `epicurus.ollamaUrl` `required`d an external URL the moment
Ollama was disabled, copied from `qdrantUrl`/`openbaoUrl`, components the core
genuinely cannot run without — and Compose included the Ollama fragment
unconditionally with `core-app` hard-depending on the service.
Chart (0.1.2 -> 0.2.0):
- `epicurus.ollamaUrl` follows `minioUrl` and renders empty; `LLM_BOOTSTRAP_MODELS`
blanks itself when left at `auto` (nothing to pull into) while an explicit list
is left as written.
- In exchange, the guard actually worth having: a release with no local runtime
whose `core.llm.defaultModel` or `core.memoryEmbedModel` is still a bare (local)
name fails to render, naming the key and a hosted alias. The old guard refused a
legitimate deployment; this one refuses the half-working stack where chat works
and every embedding fails at call time.
Compose:
- `ollama` + `ollama-init` carry `profiles: [local-ai]`, core-app's dependency is
`required: false`, and `OLLAMA_URL: ${OLLAMA_URL-http://ollama:11434}` (single
dash — `:-` would substitute the default back in for the deliberately empty
value that *is* the setting).
- Local AI stays on by default: a profile is opt-in by construction, so every path
that starts the stack selects it — `.env.example` ships
`COMPOSE_PROFILES=local-ai`, every `task *-up` passes `--profile local-ai`, and
`infra/cd/reconcile.sh` passes it unless `EPICURUS_LOCAL_AI=0`. `task
hosted-only-up` is the opt-out. Nothing about an existing install changes.
Gates:
- `compose-validate` resolves the hosted-only stack and the local-ai profile.
- `chart-validate` renders the hosted-only release *and* proves the guard refuses
the half-working one.
- `k8s-smoke` upgrades the live release into the mode — a real config change in
place of the timestamp that used to force the roll — and asserts `absent`, a
200-and-empty model list, and a 409 from pull. The Ollama stand-in stays: it is
the only workload the seam's restart arm and the chart Role's `statefulsets`
verb ever have on kind (#919), and retiring it would re-open that hole.
chart 0.1.2 -> 0.2.0 (MINOR). Part of #962.
added 2 commits
September 19, 2026 11:11
The first cut expressed "no local AI runtime" as `profiles: [local-ai]` on the `ollama` services, kept on by making every start path select the profile. That regressed the one install path the repo actually publishes: README.md and docs/user/installation.md both document `git clone` -> `cd epicurus` -> `docker compose up -d`, and neither mentions an `.env` at that point. A profile is opt-in by construction, so a fresh clone of a public repo would have come up with no local runtime *and* the shipped `llama3.2` / `nomic-embed-text` defaults still pointing at it — the exact half-working stack the new chart guard exists to refuse, delivered by default to the person least able to diagnose it. An existing operator who pulled and re-upped without an `.env` lost Ollama too. `.env.example` carrying the profile does not cover that: nothing copies it into place and no doc tells anyone to. So the mode moves to `infra/ollama/compose.hosted-only.yaml`, layered over the stack — the idiom the repo already uses for `compose.docker-socket.yaml` and `compose.external-mounts.yaml`. It does both halves in one step (removes the two services, blanks `OLLAMA_URL`), because removing the container alone leaves the core probing a host that is gone, which is `unreachable`, not `absent`. `task hosted-only-up` / `hosted-only-down` drive it, and `EPICURUS_HOSTED_ONLY=1` applies it from `infra/cd/reconcile.sh` — whose default path passes no `-f` at all, exactly as before. `infra/ollama/compose.yaml` now differs from `main` by a comment block only, and `infra/ci/smoke.sh` is byte-identical again. `compose.yaml`'s header says what is true — the default `up` brings up everything, opt-ins are overlays — and names the three. Pinned, both directions: `tests/test_no_local_runtime.py` asserts no profile gates the Ollama services and no start task needs a flag, plus that the overlay removes them and blanks the URL; `compose-validate` proves the same against a real `docker compose config` on both shapes. Also folded in: `GET /platform/v1/llm/local-runtime` answering 404 (an older core) or anything unusable **reads as `ok`** — a client keeps the pre-#962 behaviour rather than collapsing every local control against a core that is serving one; the state steers what a surface draws, never what the core does. Recorded in the platform-API reference and the core-app page for the web lane. Part of #962.
…act asked for Review of the merge set against the running endpoint. Three paths do not do what the new docs claim, and the claim is the part that is wrong: * `POST /llm/unload` answers **200** when a configured runtime is unreachable, not 502. The route's absence check is 409-only by design, and the gateway's `unload` never raises because it is also on the power-pause path. The platform-API reference listed it in the bullet promising the full 409/502 pair; `docs/services/core-app.md` already described it correctly, so only the reference and the changelog were wrong. * `POST /llm/pull/stream` likewise refuses 409 before the response starts but can only report an unreachable runtime as the `event: error` frame it always used — an SSE cannot revise its status once it has begun. Both the reference and the core-app page promised a 502 there. * `config.md`'s `LLM_BOOTSTRAP_MODELS` row still said a blank value is for "hosted-only builds". That was the over-promise #962 was filed against: a hosted-only deployment needs no setting there now, because a blank `OLLAMA_URL` short-circuits the bootstrap and the chart blanks the value itself when it is left at `auto`. The twin sentence in `startup-and-recovery.md` was corrected; this one was missed. Also one log line: the KV-cache apply logged "choice recorded but nothing to apply" with no runtime, but the route refuses with 409 *before* persisting the preference, so nothing is recorded. No behaviour change beyond the log string. `core-app` stays 0.129.0 (PATCH-level wording inside an unreleased MINOR). Part of #962.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #962 — Part 1 (core) and Part 3 (infra). Part 2 (web) is a separate lane.
A deployment that runs no local runtime at all — hosted chat, hosted embeddings, no Ollama — was a documented capability (
LLM_BOOTSTRAP_MODELS=""is "hosted-only or air-gapped"; the bootstrap's docstring explicitly tolerates "a hosted-only deployment running no Ollama") that nothing supported. The chart refused to render it, Compose could not express it, and an operator who forced it met a core in which absent, unreachable and misconfigured were one fact.What changed
Core (
core-app0.128.0 → 0.129.0, MINOR) — the contract from the issue, implemented as pre-decided:CoreAppSettings.local_runtime_enabled=bool(ollama_url.strip()). Blank (or whitespace) is absent, deliberately.GET /platform/v1/llm/local-runtime→{"state": "absent"|"unreachable"|"ok", "url_configured": bool}. Separate endpoint;GET /llm/modelsstays a barelist[ModelInfo].GET /llm/modelsnever 500s:200+[]when absent (no call made) and when unreachable. This is the ten-second 500 the Models page collected.pull/pull/stream/DELETE /models/unload→ 409 when absent (detail names the mode), 502 when a configured runtime is unreachable. The SSE pull refuses before the response starts._ensure_can_serverefuses a local model id with the existingModelCapabilityError(ADR-0140) → 400, hint "No local runtime is configured — choose a hosted model". A hosted embedding model still embeds (the owner's directive, pinned as a test); the chat fallback chain no longer walks into a local candidate.<model> · n/a, ready, instead of "warming" forever.OllamaRuntime.apply_kv_cache_typereturns the existing "unavailable" result and asks neither arm to find a workload.Infra (chart 0.1.2 → 0.2.0, MINOR)
epicurus.ollamaUrlfollowsminioUrl(default, notrequired) and renders empty;LLM_BOOTSTRAP_MODELSblanks itself when left atauto, while an explicit list is left as written.core.llm.defaultModel/core.memoryEmbedModel→fail, naming the key and a hosted alias. The old guard refused a legitimate deployment; this one refuses the half-working one.profiles: [local-ai]onollama+ollama-init,required: falseon core-app's dependency, andOLLAMA_URL: ${OLLAMA_URL-http://ollama:11434}(single dash —:-would substitute the default back in for the empty value that is the setting).The default install is unchanged — and a test says so
The Compose opt-out is an overlay,
infra/ollama/compose.hosted-only.yaml, layered over the stack — the idiom the repo already uses forcompose.docker-socket.yamlandcompose.external-mounts.yaml.docker compose up -dfrom a fresh clone, with no.envand no flags, still starts the local runtime:infra/ollama/compose.yamldiffers frommainby a comment block only, andinfra/ci/smoke.shis byte-identical.The first cut used
profiles: [local-ai]on the Ollama services and kept them on by making every start path select the profile. That regressed the documented install (README.md:50,docs/user/installation.md:10-27—git clone→docker compose up -d, no.envmentioned): a fresh clone would have come up with no local runtime while the shippedllama3.2/nomic-embed-textdefaults still pointed at it, which is the half-working stack the new chart guard exists to refuse. Reverted in1615979d.The overlay does both halves in one step — removes
ollama+ollama-init, blanksOLLAMA_URL— because removing only the container leaves the core probing a host that is gone, which isunreachable, notabsent.tests/test_no_local_runtime.pypins both directions, andcompose-validateproves the same against a realdocker compose config: the default stack must contain both services and point the core athttp://ollama:11434; the overlay must contain neither and renderOLLAMA_URL: "".What an operator types for a hosted-only install
Compose — one command, plus the model defaults:
On a deploy box,
EPICURUS_HOSTED_ONLY=1makesinfra/cd/reconcile.shapply the same overlay; unset (the default) it keeps the local runtime, so a box that never asked for hosted-only cannot lose Ollama to a reconcile.Kubernetes — the chart refuses the first form and names the second:
Kubernetes parity
Both arms, and both are deliverables here. The chart renders an empty
OLLAMA_URL, blanks the bootstrap list, refuses a bare-name hosted-only release, and itsNOTES.txtsays what the mode means; Compose gets the equivalent through the hosted-only overlay. The core logic is identical on both runtimes, and the ADR-0134 seam behaves the same onCONTAINER_RUNTIME=dockerand=kubernetes: with no local runtime,OllamaRuntimenever callsrestart_serviceat all, so neither adapter is asked to find a workload that was never meant to exist (both already return the no-match result, covered bytest_docker_control.py::test_restart_service_with_no_container_is_falseand its Kubernetes twin).Gates
compose-validategains two renders: the default install (assertingollama+ollama-initare still started and the core still points at them) and the hosted-only overlay (asserting neither is present andOLLAMA_URLrenders empty).chart-validategains two: the hosted-only release renders + kubeconforms, and the guard refuses a hosted-only release with local model defaults.k8s-smokeasserts the mode for real. Rather than a second boot (the gate runs ~10 of its 20-minute budget already), the existinghelm upgradestep now upgrades into the mode — a real config change in place of the timestamp that used to force the roll — and then assertsstate: absent, a 200-and-empty model list, and a 409 from pull.runtime-smokeis untouched — it boots the same default stack it always did, so the ollama-init and KV-cache-restart assertions are unchanged.Deliberate deviation from the brief:
infra/ci/ollama-stub.yamlis not retired. It is the only Ollama workload a kind run ever has, so it is what exercisesKubernetesController.restart_serviceand the chart Role'sstatefulsetsverb (#919); retiring it would re-open that hole and force the shared KV-cache assertion to become runtime-conditional, weakening the Compose gate too. Its header now explains that, and the hosted-only assertions live in the upgrade phase where the stub is irrelevant.Version bumps (ADR-0017)
core-app0.128.0 → 0.129.0 (MINOR — new endpoint, new refusals, user-visible behaviour).infra/k8s/epicurus0.1.2 → 0.2.0 (MINOR — a new deployment mode and a new render-time guard).Tests
Every item in the issue's
## Testssection, minus the web ones (lane B): settings (blank / whitespace / set), the threelocal-runtimestates,GET /llm/models→ 200 +[]in both non-serving states, 409/502 from pull / delete / unload / KV-cache,embed()with a hosted model succeeding while absent and a bare id raisingModelCapabilityError, chat likewise, readinessn/awithout draggingreadydown, the bootstrap returning without polling, the KV-cache apply touching neither the file nor the seam, the compose static assertions, andhelm templateassertions for all four chart branches (renders / refuses / external URL / default unchanged). The container-seam "no matching workload" case was already covered on both arms and is left as-is.Local gate exclusions (docker-light, AGENTS.md)
ruff check+ruff format --check+mypy --strict services/core-app tests+pytest services/core-app/tests testsall green, with these deselected because they boot testcontainers:services/core-app/tests/test_core_app_lifespan.pyservices/core-app/tests/test_event_spine_integration.pyservices/core-app/tests/test_messaging_inbound.pytest_core_app_migrations.pyandtests/test_migrate_script.pywere run (SQLite-backed) and pass.task smokewas not run locally, per the standing directive;runtime-smoke,k8s-smoke,compose-validateandchart-validateon this PR are the authoritative check.Contract note for the web lane
GET /platform/v1/llm/local-runtimeanswering 404 (an older core with no such route), any non-2xx, or an unparseable body reads asok: keep the pre-#962 rendering.absentas a fallback would collapse every local control against a core that is serving one perfectly well. The state steers what a surface draws; the core refuses for itself, with a status and a sentence, whatever the shell believed. Documented indocs/reference/platform-api.mdand on thecore-apppage.