Skip to content

feat(core-app,infra): a hosted-only deployment runs no local runtime - #964

Merged
baakhoff merged 4 commits into
mainfrom
feat/core-no-local-runtime
Sep 20, 2026
Merged

baakhoff merged 4 commits into
mainfrom
feat/core-no-local-runtime

Conversation

@baakhoff

@baakhoff baakhoff commented Sep 19, 2026

Copy link
Copy Markdown
Owner

Part of #962Part 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-app 0.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/models stays a bare list[ModelInfo].
  • GET /llm/models never 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 / unload409 when absent (detail names the mode), 502 when a configured runtime is unreachable. The SSE pull refuses before the response starts.
  • _ensure_can_serve refuses a local model id with the existing ModelCapabilityError (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.
  • Readiness reports <model> · n/a, ready, instead of "warming" forever.
  • Bootstrap: one log line and return — no 180 s poll.
  • ADR-0134 seam: OllamaRuntime.apply_kv_cache_type returns the existing "unavailable" result and asks neither arm to find a workload.

Infra (chart 0.1.2 → 0.2.0, MINOR)

  • epicurus.ollamaUrl follows minioUrl (default, not required) and renders empty; LLM_BOOTSTRAP_MODELS blanks itself when left at auto, while an explicit list is left as written.
  • New render-time guard: no local runtime and a bare (local) core.llm.defaultModel / core.memoryEmbedModelfail, naming the key and a hosted alias. The old guard refused a legitimate deployment; this one refuses the half-working one.
  • Compose: profiles: [local-ai] on ollama + ollama-init, required: false on core-app's dependency, and OLLAMA_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 for compose.docker-socket.yaml and compose.external-mounts.yaml. docker compose up -d from a fresh clone, with no .env and no flags, still starts the local runtime: infra/ollama/compose.yaml differs from main by a comment block only, and infra/ci/smoke.sh is 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-27git clonedocker compose up -d, no .env mentioned): a fresh clone would have come up with no local runtime while the shipped llama3.2 / nomic-embed-text defaults still pointed at it, which is the half-working stack the new chart guard exists to refuse. Reverted in 1615979d.

The overlay does both halves in one step — removes ollama + ollama-init, blanks OLLAMA_URL — because removing only the container leaves the core probing a host that is gone, which is unreachable, not absent. tests/test_no_local_runtime.py pins both directions, and compose-validate proves the same against a real docker compose config: the default stack must contain both services and point the core at http://ollama:11434; the overlay must contain neither and render OLLAMA_URL: "".

What an operator types for a hosted-only install

Compose — one command, plus the model defaults:

task hosted-only-up
# == docker compose -f compose.yaml -f infra/ollama/compose.hosted-only.yaml up -d

# and in .env, because a bare name routes to the local runtime:
#   LLM_DEFAULT_MODEL=claude/claude-sonnet-4-6
#   MEMORY_EMBED_MODEL=gpt/text-embedding-3-small

On a deploy box, EPICURUS_HOSTED_ONLY=1 makes infra/cd/reconcile.sh apply 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:

helm install epicurus oci://ghcr.io/baakhoff/charts/epicurus \
  --set ollama.enabled=false \
  --set core.llm.defaultModel=claude/claude-sonnet-4-6 \
  --set core.memoryEmbedModel=gpt/text-embedding-3-small

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 its NOTES.txt says 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 on CONTAINER_RUNTIME=docker and =kubernetes: with no local runtime, OllamaRuntime never calls restart_service at all, so neither adapter is asked to find a workload that was never meant to exist (both already return the no-match result, covered by test_docker_control.py::test_restart_service_with_no_container_is_false and its Kubernetes twin).

Gates

  • compose-validate gains two renders: the default install (asserting ollama + ollama-init are still started and the core still points at them) and the hosted-only overlay (asserting neither is present and OLLAMA_URL renders empty).
  • chart-validate gains two: the hosted-only release renders + kubeconforms, and the guard refuses a hosted-only release with local model defaults.
  • k8s-smoke asserts the mode for real. Rather than a second boot (the gate runs ~10 of its 20-minute budget already), the existing helm upgrade step now upgrades into the mode — a real config change in place of the timestamp that used to force the roll — and then asserts state: absent, a 200-and-empty model list, and a 409 from pull.
  • runtime-smoke is 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.yaml is not retired. It is the only Ollama workload a kind run ever has, so it is what exercises KubernetesController.restart_service and the chart Role's statefulsets verb (#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-app 0.128.0 → 0.129.0 (MINOR — new endpoint, new refusals, user-visible behaviour).
  • chart infra/k8s/epicurus 0.1.2 → 0.2.0 (MINOR — a new deployment mode and a new render-time guard).

Tests

Every item in the issue's ## Tests section, minus the web ones (lane B): settings (blank / whitespace / set), the three local-runtime states, 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 raising ModelCapabilityError, chat likewise, readiness n/a without dragging ready down, the bootstrap returning without polling, the KV-cache apply touching neither the file nor the seam, the compose static assertions, and helm template assertions 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 tests all green, with these deselected because they boot testcontainers:

  • services/core-app/tests/test_core_app_lifespan.py
  • services/core-app/tests/test_event_spine_integration.py
  • services/core-app/tests/test_messaging_inbound.py

test_core_app_migrations.py and tests/test_migrate_script.py were run (SQLite-backed) and pass. task smoke was not run locally, per the standing directive; runtime-smoke, k8s-smoke, compose-validate and chart-validate on this PR are the authoritative check.


Contract note for the web lane

GET /platform/v1/llm/local-runtime answering 404 (an older core with no such route), any non-2xx, or an unparseable body reads as ok: keep the pre-#962 rendering. absent as 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 in docs/reference/platform-api.md and on the core-app page.

NikolaI Baakh 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.
NikolaI Baakh 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant