What
A deployment that runs no local runtime at all — hosted chat and hosted embeddings, no Ollama — is something the repo already claims to support (LLM_BOOTSTRAP_MODELS="" is documented as "hosted-only or air-gapped", docs/reference/config.md:88; the bootstrap explicitly tolerates "a hosted-only deployment running no Ollama", docs/services/core-app.md:753). It is not actually supported anywhere else. Three states — in-chart Ollama, external Ollama, no Ollama — are collapsed into two, and the third is treated as a configuration error.
The Helm chart refuses to render it: infra/k8s/epicurus/templates/_helpers.tpl:113-119 requireds ollama.external.url the moment ollama.enabled is false, so helm install fails before the cluster sees anything. The proof this is a gap and not a policy is in our own CI: k8s-smoke disables the chart's Ollama and then applies a fake StatefulSet (infra/ci/ollama-stub.yaml) purely so the container-runtime seam finds a workload to match. We wrote a stub instead of a mode.
Compose cannot express it either: infra/ollama/compose.yaml is an unconditional include: (compose.yaml:13) and core-app hard-depends on the service (services/core-app/compose.yaml:83-84).
And when an operator forces it anyway (a placeholder external URL), the core degrades loudly in three places that should be quiet, and quietly in one that should be loud:
GET /platform/v1/llm/models 500s. gateway.models() lets the /api/tags failure propagate (services/core-app/src/epicurus_core_app/llm/gateway.py:1115), the route has no guard (services/core-app/src/epicurus_core_app/llm/routes.py:198-203), and no httpx exception handler exists on the app (app.py:1297-1320 covers only GatewayPausedError, PathEscapeError, OSError). The Models page polls it every 10 s (services/web/src/screens/ModelsScreen.tsx:431-438), so the operator gets a 500 every 10 s. POST /llm/pull (routes.py:283) and DELETE /llm/models (routes.py:238) 500 the same way.
GET /platform/v1/readiness reports the model component "warming" forever (gateway.py:372-396 returns warm=False on failure; readiness.py:67,103). Advisory — it never blocks a turn — but the chat warm-up indicator never completes, on every turn, for the life of the deployment.
- The startup bootstrap spends 180 s polling a runtime that does not exist before giving up (
llm/bootstrap.py:138-152), once per process start, unless the operator knows to set LLM_BOOTSTRAP_MODELS="".
- The quiet one:
show() failures are caught and return an empty ModelDetails (gateway.py:1254-1258), so model_role degrades to "unknown" and _ensure_can_serve lets the call through (gateway.py:443) to fail at the provider instead of refusing with a reason.
Meanwhile nothing validates that the defaults make sense without a runtime. LLM_DEFAULT_MODEL is llama3.2 and MEMORY_EMBED_MODEL is nomic-embed-text (services/core-app/src/epicurus_core_app/settings.py:27,146; mirrored at infra/k8s/epicurus/values.yaml:133,144), both bare names, and providers.resolve routes a bare name — and any unknown prefix — to the local runtime (llm/providers.py:50-61). So the out-of-the-box hosted-only install is precisely the half-working stack we refuse to ship elsewhere: chat works through the hosted provider the operator configured, and memory recall plus every module index fails at call time against a runtime that isn't there.
Owner directive (2026-09-19): if Ollama is turned off, embeddings must not go through it. They already need not — _embed_config classifies an embedding model through the same provider registry as chat and sends a hosted one straight to the provider with the tenant's key from OpenBao (gateway.py:932-977, #865). The gateway is ready; the deployment surface is not.
As of main @ 43625e5.
Why it happens
epicurus.ollamaUrl was written by copying epicurus.qdrantUrl / epicurus.openbaoUrl (_helpers.tpl:97-111) — components the core genuinely cannot run without, where required is right. Ollama is not one of them, and the helper three lines further down already shows the other shape: epicurus.minioUrl uses default, not required (_helpers.tpl:129-135).
Underneath that, the core has no representation of "there is no local runtime". ollama_url is a plain string with a localhost default (settings.py:20), so absent and unreachable and misconfigured are the same thing to every call site, and each one guesses: some catch and degrade (show, unload, /api/ps), some propagate to a 500 (models, pull, delete), one polls for three minutes (bootstrap). The web mirrors the same ambiguity with two inline warning strings ("The local runtime is unreachable — is the ollama service up?", ModelsScreen.tsx:493-497; ChatScreen.tsx:712-714) while every other local-runtime control — the pull card, the catalog, the KV-cache card, the context-window card, the Local (Ollama) optgroup in the embedding picker (ModelsScreen.tsx:1619-1625) — renders as though a runtime existed.
Fix
Make "no local runtime" a first-class deployment mode, spelled OLLAMA_URL="".
The contract (pre-decided, so the three parts below can be built in parallel):
CoreAppSettings grows local_runtime_enabled: bool — bool(ollama_url.strip()). Blank means absent, deliberately, not misconfigured.
- New
GET /platform/v1/llm/local-runtime → {"state": "absent" | "unreachable" | "ok", "url_configured": bool}. A separate endpoint rather than an envelope around GET /llm/models, which stays a bare list[ModelInfo] (five web consumers and seven internal callers read it; wrapping it is a breaking change bought for nothing).
GET /llm/models never 500s again: absent → [], unreachable → []. The state is the new endpoint's job.
POST /llm/pull, DELETE /llm/models, POST /llm/unload, the KV-cache apply: 409 with a detail naming the mode when absent; 502 with a clear detail when unreachable. Never a bare 500.
_ensure_can_serve refuses a local model id when the runtime is absent, raising the existing ModelCapabilityError shape (ADR-0140, llm/errors.py) → 400 with the hint "no local runtime is configured — choose a hosted model".
- Readiness: the model component reports a
n/a state when the runtime is absent, instead of warming forever.
- Bootstrap: absent → one log line and return, no 180 s poll.
- The container-runtime seam (ADR-0134): with no runtime, the Ollama restart arm and the KV-cache apply return their existing "unavailable" result rather than instructing the operator to restart a container that does not exist.
Part 1 — core (core-app MINOR). The contract above, plus: no validator that rejects a bare-name default (a local default with no runtime is now a legible refusal at call time, not a boot failure — a deployment may legitimately point a tenant pref at a hosted model while the env default is stale).
Part 2 — web (web MINOR). The Models page reads the new endpoint and, when the state is absent, collapses the local half into one honest line ("Local AI is not configured on this deployment") — the pull card, the catalog, the KV-cache card and the context-window card go with it; the embedding picker drops its Local (Ollama) optgroup (ModelsScreen.tsx:1619-1625) so the operator cannot choose a model that cannot run; the chat picker's Local heading likewise (ChatScreen.tsx:712-714). unreachable keeps today's warning — that state is an error and should still look like one. This is the rendering half only; the runtime-level Local AI / Hosted AI switch stays #945 (2.0.0) and is not in scope here.
Part 3 — infra (chart MINOR). epicurus.ollamaUrl renders empty when ollama.enabled: false and ollama.external.url is blank (follow minioUrl), and the chart then sets LLM_BOOTSTRAP_MODELS="" unless the operator overrode it. In exchange, add the guard that is actually worth having: fail at render time when there is no local runtime and core.memoryEmbedModel or core.llm.defaultModel is still a bare (local) name — naming the hosted alias to set. That catches the half-working stack; today's guard catches a legitimate one. Compose gets the same capability: profiles: [local-ai] on ollama + ollama-init (the pattern infra/observability/compose.yaml:38 already uses), depends_on: ollama: {condition: service_started, required: false}, and OLLAMA_URL: ${OLLAMA_URL-http://ollama:11434} so blanking the var is how an operator says "no local runtime" on Compose too. infra/ci/ollama-stub.yaml is retired and k8s-smoke gains a hosted-only assertion in its place.
Kubernetes parity: both arms. The chart is half the deliverable (Part 3), the Compose profile is the other half, and the ADR-0134 seam must behave identically on CONTAINER_RUNTIME=docker and =kubernetes when there is no workload to restart.
Tests
- Settings: blank / whitespace / set
OLLAMA_URL → local_runtime_enabled false / false / true.
GET /llm/local-runtime returns each of the three states (absent; a URL whose host refuses; a stubbed runtime that answers /api/tags).
GET /llm/models returns [] and 200 in both the absent and unreachable states — the regression test for the 10-second 500.
pull / delete / unload / KV-cache apply → 409 when absent, 502 when unreachable, in place of 500.
embed() with a hosted embedding model succeeds while the runtime is absent (the owner's directive, pinned); embed() with a bare/local id while absent raises ModelCapabilityError, not a connection error.
- Chat with a hosted model succeeds while absent; a local id refuses with the hint.
- Readiness reports
n/a for the model component when absent, and does not report ready=False on that account.
- Bootstrap with an absent runtime returns without polling (assert on elapsed time / no HTTP call), and logs once.
- Container seam: restart-Ollama with no matching workload returns the unavailable result on both the docker and kubernetes arms.
- Web: Models page renders the collapsed local section for
absent, the warning for unreachable, and the full local UI for ok; the embedding picker has no local optgroup when absent.
- Chart:
helm template with ollama.enabled: false and a blank external.url renders (it does not today) and emits an empty OLLAMA_URL plus LLM_BOOTSTRAP_MODELS=""; the same values with core.memoryEmbedModel: nomic-embed-text fails with the new message; ollama.enabled: false with an external URL still renders that URL.
- Compose:
tests/test_compose_ports.py-style static assertion that ollama carries the local-ai profile and core-app's dependency on it is required: false.
k8s-smoke: a hosted-only boot asserts GET /platform/v1/llm/models returns 200 and /llm/local-runtime reports absent.
Docs
docs/infrastructure/kubernetes.md (a hosted-only section + the ollama.enabled row in the values table, :395), docs/infrastructure/index.md (the Compose local-ai profile), docs/services/core-app.md (the new endpoint, the 409/502 refusals, the readiness n/a state, and the hosted-only paragraph at :753 which currently over-promises), docs/reference/config.md (blank OLLAMA_URL semantics, :88 area), docs/reference/platform-api.md (GET /platform/v1/llm/local-runtime), docs/user/configuration.md (what the Models page shows on a hosted-only deployment), plus an ADR — "a hosted-only deployment runs no local runtime" — amending ADR-0010 / ADR-0011 / ADR-0118 / ADR-0134.
Note for a separate issue
llm/providers.py:42, docs/infrastructure/secrets.md:138 and docs/services/core-app.md:565 all state that one OpenRouter key reaches that provider's chat and embedding models (#865). OpenRouter exposes no embeddings endpoint as far as we can tell, which would make openrouter/… an embedding dead end and the hosted-only path above much narrower than it looks (gpt/… or the custom OpenAI-compatible slot). Verify with one real call before 1.0.0 and correct the three claims if it does not hold; not in scope here.
What
A deployment that runs no local runtime at all — hosted chat and hosted embeddings, no Ollama — is something the repo already claims to support (
LLM_BOOTSTRAP_MODELS=""is documented as "hosted-only or air-gapped",docs/reference/config.md:88; the bootstrap explicitly tolerates "a hosted-only deployment running no Ollama",docs/services/core-app.md:753). It is not actually supported anywhere else. Three states — in-chart Ollama, external Ollama, no Ollama — are collapsed into two, and the third is treated as a configuration error.The Helm chart refuses to render it:
infra/k8s/epicurus/templates/_helpers.tpl:113-119requiredsollama.external.urlthe momentollama.enabledis false, sohelm installfails before the cluster sees anything. The proof this is a gap and not a policy is in our own CI:k8s-smokedisables the chart's Ollama and then applies a fake StatefulSet (infra/ci/ollama-stub.yaml) purely so the container-runtime seam finds a workload to match. We wrote a stub instead of a mode.Compose cannot express it either:
infra/ollama/compose.yamlis an unconditionalinclude:(compose.yaml:13) andcore-apphard-depends on the service (services/core-app/compose.yaml:83-84).And when an operator forces it anyway (a placeholder external URL), the core degrades loudly in three places that should be quiet, and quietly in one that should be loud:
GET /platform/v1/llm/models500s.gateway.models()lets the/api/tagsfailure propagate (services/core-app/src/epicurus_core_app/llm/gateway.py:1115), the route has no guard (services/core-app/src/epicurus_core_app/llm/routes.py:198-203), and nohttpxexception handler exists on the app (app.py:1297-1320covers onlyGatewayPausedError,PathEscapeError,OSError). The Models page polls it every 10 s (services/web/src/screens/ModelsScreen.tsx:431-438), so the operator gets a 500 every 10 s.POST /llm/pull(routes.py:283) andDELETE /llm/models(routes.py:238) 500 the same way.GET /platform/v1/readinessreports the model component "warming" forever (gateway.py:372-396returnswarm=Falseon failure;readiness.py:67,103). Advisory — it never blocks a turn — but the chat warm-up indicator never completes, on every turn, for the life of the deployment.llm/bootstrap.py:138-152), once per process start, unless the operator knows to setLLM_BOOTSTRAP_MODELS="".show()failures are caught and return an emptyModelDetails(gateway.py:1254-1258), somodel_roledegrades to"unknown"and_ensure_can_servelets the call through (gateway.py:443) to fail at the provider instead of refusing with a reason.Meanwhile nothing validates that the defaults make sense without a runtime.
LLM_DEFAULT_MODELisllama3.2andMEMORY_EMBED_MODELisnomic-embed-text(services/core-app/src/epicurus_core_app/settings.py:27,146; mirrored atinfra/k8s/epicurus/values.yaml:133,144), both bare names, andproviders.resolveroutes a bare name — and any unknown prefix — to the local runtime (llm/providers.py:50-61). So the out-of-the-box hosted-only install is precisely the half-working stack we refuse to ship elsewhere: chat works through the hosted provider the operator configured, and memory recall plus every module index fails at call time against a runtime that isn't there.Owner directive (2026-09-19): if Ollama is turned off, embeddings must not go through it. They already need not —
_embed_configclassifies an embedding model through the same provider registry as chat and sends a hosted one straight to the provider with the tenant's key from OpenBao (gateway.py:932-977, #865). The gateway is ready; the deployment surface is not.As of
main@ 43625e5.Why it happens
epicurus.ollamaUrlwas written by copyingepicurus.qdrantUrl/epicurus.openbaoUrl(_helpers.tpl:97-111) — components the core genuinely cannot run without, whererequiredis right. Ollama is not one of them, and the helper three lines further down already shows the other shape:epicurus.minioUrlusesdefault, notrequired(_helpers.tpl:129-135).Underneath that, the core has no representation of "there is no local runtime".
ollama_urlis a plain string with a localhost default (settings.py:20), so absent and unreachable and misconfigured are the same thing to every call site, and each one guesses: some catch and degrade (show,unload,/api/ps), some propagate to a 500 (models,pull,delete), one polls for three minutes (bootstrap). The web mirrors the same ambiguity with two inline warning strings ("The local runtime is unreachable — is the ollama service up?",ModelsScreen.tsx:493-497;ChatScreen.tsx:712-714) while every other local-runtime control — the pull card, the catalog, the KV-cache card, the context-window card, theLocal (Ollama)optgroup in the embedding picker (ModelsScreen.tsx:1619-1625) — renders as though a runtime existed.Fix
Make "no local runtime" a first-class deployment mode, spelled
OLLAMA_URL="".The contract (pre-decided, so the three parts below can be built in parallel):
CoreAppSettingsgrowslocal_runtime_enabled: bool—bool(ollama_url.strip()). Blank means absent, deliberately, not misconfigured.GET /platform/v1/llm/local-runtime→{"state": "absent" | "unreachable" | "ok", "url_configured": bool}. A separate endpoint rather than an envelope aroundGET /llm/models, which stays a barelist[ModelInfo](five web consumers and seven internal callers read it; wrapping it is a breaking change bought for nothing).GET /llm/modelsnever 500s again: absent →[], unreachable →[]. The state is the new endpoint's job.POST /llm/pull,DELETE /llm/models,POST /llm/unload, the KV-cache apply: 409 with adetailnaming the mode when absent; 502 with a clear detail when unreachable. Never a bare 500._ensure_can_serverefuses a local model id when the runtime is absent, raising the existingModelCapabilityErrorshape (ADR-0140,llm/errors.py) → 400 with the hint "no local runtime is configured — choose a hosted model".n/astate when the runtime is absent, instead of warming forever.Part 1 — core (
core-appMINOR). The contract above, plus: no validator that rejects a bare-name default (a local default with no runtime is now a legible refusal at call time, not a boot failure — a deployment may legitimately point a tenant pref at a hosted model while the env default is stale).Part 2 — web (
webMINOR). The Models page reads the new endpoint and, when the state isabsent, collapses the local half into one honest line ("Local AI is not configured on this deployment") — the pull card, the catalog, the KV-cache card and the context-window card go with it; the embedding picker drops itsLocal (Ollama)optgroup (ModelsScreen.tsx:1619-1625) so the operator cannot choose a model that cannot run; the chat picker's Local heading likewise (ChatScreen.tsx:712-714).unreachablekeeps today's warning — that state is an error and should still look like one. This is the rendering half only; the runtime-level Local AI / Hosted AI switch stays #945 (2.0.0) and is not in scope here.Part 3 — infra (chart MINOR).
epicurus.ollamaUrlrenders empty whenollama.enabled: falseandollama.external.urlis blank (followminioUrl), and the chart then setsLLM_BOOTSTRAP_MODELS=""unless the operator overrode it. In exchange, add the guard that is actually worth having:failat render time when there is no local runtime andcore.memoryEmbedModelorcore.llm.defaultModelis still a bare (local) name — naming the hosted alias to set. That catches the half-working stack; today's guard catches a legitimate one. Compose gets the same capability:profiles: [local-ai]onollama+ollama-init(the patterninfra/observability/compose.yaml:38already uses),depends_on: ollama: {condition: service_started, required: false}, andOLLAMA_URL: ${OLLAMA_URL-http://ollama:11434}so blanking the var is how an operator says "no local runtime" on Compose too.infra/ci/ollama-stub.yamlis retired andk8s-smokegains a hosted-only assertion in its place.Kubernetes parity: both arms. The chart is half the deliverable (Part 3), the Compose profile is the other half, and the ADR-0134 seam must behave identically on
CONTAINER_RUNTIME=dockerand=kuberneteswhen there is no workload to restart.Tests
OLLAMA_URL→local_runtime_enabledfalse / false / true.GET /llm/local-runtimereturns each of the three states (absent; a URL whose host refuses; a stubbed runtime that answers/api/tags).GET /llm/modelsreturns[]and 200 in both the absent and unreachable states — the regression test for the 10-second 500.pull/delete/unload/ KV-cache apply → 409 when absent, 502 when unreachable, in place of 500.embed()with a hosted embedding model succeeds while the runtime is absent (the owner's directive, pinned);embed()with a bare/local id while absent raisesModelCapabilityError, not a connection error.n/afor the model component when absent, and does not reportready=Falseon that account.absent, the warning forunreachable, and the full local UI forok; the embedding picker has no local optgroup when absent.helm templatewithollama.enabled: falseand a blankexternal.urlrenders (it does not today) and emits an emptyOLLAMA_URLplusLLM_BOOTSTRAP_MODELS=""; the same values withcore.memoryEmbedModel: nomic-embed-textfails with the new message;ollama.enabled: falsewith an external URL still renders that URL.tests/test_compose_ports.py-style static assertion thatollamacarries thelocal-aiprofile andcore-app's dependency on it isrequired: false.k8s-smoke: a hosted-only boot assertsGET /platform/v1/llm/modelsreturns 200 and/llm/local-runtimereportsabsent.Docs
docs/infrastructure/kubernetes.md(a hosted-only section + theollama.enabledrow in the values table,:395),docs/infrastructure/index.md(the Composelocal-aiprofile),docs/services/core-app.md(the new endpoint, the 409/502 refusals, the readinessn/astate, and the hosted-only paragraph at:753which currently over-promises),docs/reference/config.md(blankOLLAMA_URLsemantics,:88area),docs/reference/platform-api.md(GET /platform/v1/llm/local-runtime),docs/user/configuration.md(what the Models page shows on a hosted-only deployment), plus an ADR — "a hosted-only deployment runs no local runtime" — amending ADR-0010 / ADR-0011 / ADR-0118 / ADR-0134.Note for a separate issue
llm/providers.py:42,docs/infrastructure/secrets.md:138anddocs/services/core-app.md:565all state that one OpenRouter key reaches that provider's chat and embedding models (#865). OpenRouter exposes no embeddings endpoint as far as we can tell, which would makeopenrouter/…an embedding dead end and the hosted-only path above much narrower than it looks (gpt/…or thecustomOpenAI-compatible slot). Verify with one real call before 1.0.0 and correct the three claims if it does not hold; not in scope here.