Skip to content

Commit c3e6157

Browse files
authored
lobes never advertises a capability it cannot serve (#92 · #91 · #95 · #97 · #96 · #74 · #69) (#102)
1 parent 20069bd commit c3e6157

44 files changed

Lines changed: 5830 additions & 301 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.devague/current

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
lobes-preserves-qwen-thinking-traces-across-multi
1+
lobes-never-advertises-a-capability-it-cannot-serv

.devague/current_plan

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
lobes-preserves-qwen-thinking-traces-across-multi
1+
lobes-never-advertises-a-capability-it-cannot-serv

.devague/frames/lobes-never-advertises-a-capability-it-cannot-serv.json

Lines changed: 467 additions & 0 deletions
Large diffs are not rendered by default.

.devague/plans/lobes-never-advertises-a-capability-it-cannot-serv.json

Lines changed: 457 additions & 0 deletions
Large diffs are not rendered by default.

.eidetic/memory/lobes__public.jsonl

Lines changed: 15 additions & 9 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,42 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
55
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [0.40.0] - 2026-07-09
8+
9+
### Security
10+
11+
- The `Host` request header is validated against a strict host-authority allowlist before it is echoed as an advertised origin in `GET /capabilities`. Previously the c29 origin-resolution change reflected an unsanitized, client-controlled `Host` header into every role's `endpoint` (e.g. `http://evil.example/../<script>` or a `user@host` authority), so a client scraping the contract could be handed an attacker's origin to dial. An invalid host is now treated as "no origin supplied" (empty endpoint), exactly as an absent header is. The trusted operator override `GATEWAY_PUBLIC_URL` is unaffected. (SonarCloud `pythonsecurity:S5131`.)
12+
13+
### Added
14+
15+
- `scripts/live-check.sh` + `tests/test_live_capabilities.py` — a LOCAL, single-trigger, unattended pre-PR gate that dials every advertised role endpoint+path, every id in `/v1/models`, checks CLI/gateway agreement, reproduces Colleague's role-discovery path, and fails on deployed-gateway version skew. It FAILS rather than skips when armed. A 429 (pressure shed) and a 503 carrying `Retry-After` count as reachable; only a 404, a connection failure, a `Retry-After`-less 503, or a bare 5xx are faults. that dials every advertised role endpoint+path, every id in `/v1/models`, checks CLI/gateway agreement, reproduces Colleague's role-discovery path, and fails on deployed-gateway version skew. It FAILS rather than skips when armed. A 429 (pressure shed) and a 503 carrying `Retry-After` count as reachable; only a 404, a connection failure, a `Retry-After`-less 503, or a bare 5xx are faults.
16+
- `lobes/gateway/_readiness.py` — a bounded background probe of each backend's `/health`, mirroring `PressureCache`. Tri-state (`True` healthy / `False` reached-but-unhealthy / `None` unreachable), daemon thread, socket-free `.current()`, and a probe that degrades to `None` on `OSError`, `http.client.HTTPException` and `ValueError`.
17+
- `GET /health` now reports `{"version": ...}`, and `lobes doctor` gains a `gateway_version_match` check that fails on skew between the deployed gateway and the CLI wheel (issue #99).
18+
- `lobes/gateway/_routing.py::is_unknown_model` — a pure predicate separating "unknown model id" from "unspecified model".
19+
- Ground-truth perception probes for the `senses` role: a stdlib-generated solid-colour PNG whose colour the model must name, and a Chatterbox-synthesized word the model must transcribe. Both carry negative controls; the old placeholder-media tests are relabelled as wire checks.
20+
21+
### Changed
22+
23+
- **No cross-backend failover.** `order_backends` now returns at most one backend. A request naming the cortex model can never be answered by the Gemma backend, which protects the `final_authority` role contract from #81.
24+
- `GET /v1/models` and `GET /capabilities.ready` are backed by the live readiness cache rather than by configuration. A wired-but-dead backend is no longer advertised.
25+
- `RoleInfo.ready` is no longer an alias of `loaded` for `cortex`/`senses`/`embedder`/`reranker`. `build_role_registry` self-enforces the invariant: a supplied `backend_ready` map is authoritative, and a present `None`, a present `False`, and a missing key all mean not-ready.
26+
- `lobes capabilities` / `lobes endpoint` now render the running gateway's `GET /capabilities` when it is reachable, falling back to an offline `.env`-derived view with `ready=false` on every role. The CLI and the gateway can no longer disagree, because there is now one derivation instead of two. The `--json` payload is keyed strictly by role name in both modes (byte-identical to the gateway's payload in live mode); the live-vs-offline signal travels on stderr (a `source:` notice) rather than as an extra top-level key, so a strict `set(keys) == ROLES` consumer never trips (Qodo).
27+
- A backend is wired only when its `*_BASE_URL` is set; the `or *_SERVED_NAME` clause is gone (issue #97).
28+
- `senses` is documented as vision-only intake. The checkpoint declares audio support but vLLM's `gemma4_unified` path does not serve it (issue #101); `stt` remains the supported speech path.
29+
- `docs/gemma4-mtp-draft.md` carries a superseded banner: DSpark does not load on vLLM 0.23 (#75). Issue #69's disabled-experiment-entry criterion is closed answered-negative.
30+
- README's quickstart no longer claims `lobes init` scaffolds the single-model deployment; the fleet duo has been the default since #69.
31+
32+
### Fixed
33+
34+
- **#91** — a dead cortex backend no longer surfaces as a terminal `404 model does not exist`. `handle_post` rewrote the model id once, before the failover loop, then retried the same body against a backend serving a different model, which correctly 404'd; the `4xx = client error, no failover` rule relayed that as terminal and killed multi-step agent loops. A dead, unreachable or warming owner now yields **503 + `Retry-After`** with `type: backend_unavailable`.
35+
- **#92 / #95** — the gateway never advertises an origin built from its internal listen port. Precedence is `GATEWAY_PUBLIC_URL` (an operator override for a tunnel or Host-rewriting proxy) > the request `Host` header > an empty endpoint. `GATEWAY_PUBLIC_URL` is deliberately NOT defaulted: a defaulted `public_url` outranks `Host` and would advertise loopback to every remote client.
36+
- **#96**`AUDIO_URL` now reaches the gateway from the base fleet compose, so `stt`/`tts` stop advertising `ready=true` on a path that 404s when the audio overlay is not composed in.
37+
- **#97**`GET /v1/models` no longer advertises phantom backends wired from a `*_SERVED_NAME` alone against a `default_url` naming a container that need not exist.
38+
- `ReadinessCache` seeds its initial per-backend snapshot with `dict.fromkeys` instead of a dict comprehension (SonarCloud `python:S7519`).
39+
- `ReadinessCache.stop()` no longer clears its thread reference while the refresh thread is still alive, so a subsequent `start()` cannot spawn a second concurrent refresh loop; the join bound now covers a full sequential refresh pass and `start()` cleanly replaces a genuinely-dead thread (Qodo reliability).
40+
- An unknown model id returns `404 model_not_found` instead of being silently served by the default backend under a different model's weights. Unknown-ness is decided against the routing table, never against the readiness-filtered `/v1/models` list, so a wired-but-dead backend still yields 503 rather than 404.
41+
- `test_live_main_text_returns_nonempty_content` no longer fails on a thinking model: `max_tokens=16` was consumed entirely by the reasoning trace, leaving `content=None` and `finish_reason=length` (issue #93).
42+
743
## [0.39.0] - 2026-07-07
844

945
### Added

CLAUDE.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ output quality is expected and opt-in, not guaranteed by the diagnostic. See
5858
Beyond `cortex`, the **fleet** exposes SIX first-class, Colleague-facing
5959
**roles** (issue #81) — the primary contract callers should address, not raw
6060
model ids: `cortex` (the 27B primary — reasoning/deciding/final authority),
61-
`senses` (the Gemma 4 12B multimodal gear — vision+audio intake/perception;
62-
never decides or takes repo actions), `embedder` (`Qwen/Qwen3-Embedding-0.6B`
61+
`senses` (the Gemma 4 12B multimodal gear — vision intake/perception; never
62+
decides or takes repo actions; the checkpoint declares audio support but it is
63+
**not currently served** on this vLLM path — issue #101 — so `senses` is
64+
vision-only in practice, and the purpose-built `stt` role, below, is the
65+
supported path for speech), `embedder` (`Qwen/Qwen3-Embedding-0.6B`
6366
`POST /v1/embeddings`), `reranker` (`Qwen/Qwen3-Reranker-0.6B` → `POST
6467
/v1/rerank` + `/v1/score`), and the opt-in audio overlay's `stt`/`tts`. Roles
6568
are routed by **task family** (`generate` / `embed` / `score` / `rerank`) and

README.md

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,24 @@ CLIs in loops, so safe-by-default is mandatory.
5050
## Running the model locally (vLLM)
5151

5252
`lobes init` scaffolds a deployment directory (default `$HOME/.lobes`) from the
53-
packaged templates: a `docker-compose.yml` that stands up the vLLM model as an
54-
OpenAI-compatible server on `:8000`, plus a `.env`. Tuned for DGX Spark (GB10
55-
Grace Blackwell, 128 GB unified memory) per
53+
packaged templates. **Since issue #69, bare `lobes init` (no flags) scaffolds
54+
the fleet duo by default** — see ["Running the model behind a gateway
55+
(fleet)"](#running-the-model-behind-a-gateway-fleet) below; that is almost
56+
certainly what you want. This section instead walks through the **legacy
57+
single-model** scaffold: one vLLM server, no gateway, opted into with
58+
`--single` (alias `--legacy`) — a `docker-compose.yml` that stands up the
59+
vLLM model directly as an OpenAI-compatible server on `:8000` (the
60+
*container's own* port; there is no gateway in front of it in this mode —
61+
see the port note in the fleet section below), plus a `.env`. Tuned for DGX
62+
Spark (GB10 Grace Blackwell, 128 GB unified memory) per
5663
[build.nvidia.com/spark/vllm](https://build.nvidia.com/spark/vllm).
5764

5865
Prerequisites: the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html),
5966
and `docker login nvcr.io` with an [NGC API key](https://org.ngc.nvidia.com/setup/api-key)
6067
to pull the `nvcr.io/nvidia/vllm` image.
6168

6269
```bash
63-
lobes init --apply # writes $HOME/.lobes/{docker-compose.yml,.env}
70+
lobes init --single --apply # writes $HOME/.lobes/{docker-compose.yml,.env} (legacy single-model)
6471
# edit $HOME/.lobes/.env to set HF_TOKEN if the model repo is gated
6572
lobes serve --apply # first run downloads ~28 GB of weights (the 27B primary)
6673
lobes status # waits/reports until /health is up
@@ -178,19 +185,37 @@ tunnel` for the full flow.
178185

179186
## Running the model behind a gateway (fleet)
180187

181-
`lobes init --fleet` scaffolds a **multi-container** deployment instead of one: the
182-
always-warm Qwen generate primary, two tiny co-resident **embedding** and
183-
**reranker** gears, and a single stdlib **gateway** that fronts them on the host
184-
port the acp `vllm-local` provider already expects. The gateway routes each
185-
request by its `model` field — to the primary, the embedder, or the reranker by
186-
task family (generate / embed / score / rerank) — and defaults an unknown/missing
187-
name to the primary, so existing single-model clients keep working unchanged. The
188-
same front fans `/v1/audio/*` out to the `--audio` overlay, and a warm *generate*
189-
fallback can be wired in later (the gateway adds it, with failover, only when one
190-
is configured).
188+
**This is the default scaffold since issue #69** — plain `lobes init --apply`
189+
(no flags) already gives you this; `--fleet` is a back-compat no-op kept only
190+
so old scripts that passed it keep working, and `--single` (alias `--legacy`)
191+
is what opts you *out*, back to the one-container deployment the previous
192+
section describes. `lobes init` scaffolds a **multi-container** deployment:
193+
the always-warm Qwen generate primary, the Gemma 4 12B multimodal gear, two
194+
tiny co-resident **embedding** and **reranker** gears, and a single stdlib
195+
**gateway** that fronts them all on the host port the acp `vllm-local`
196+
provider already expects — `VLLM_PORT` (packaged default `:8000`; `:8001` on
197+
the reference DGX Spark deployment, deliberately set apart from the
198+
single-model story's default). The gateway routes each request by its
199+
`model` field — to the primary, the multimodal gear, the embedder, or the
200+
reranker by task family (generate / embed / score / rerank) — and defaults
201+
an unknown/missing name to the primary, so existing single-model clients
202+
keep working unchanged. The same front fans `/v1/audio/*` out to the
203+
`--audio` overlay, and a warm *generate* fallback can be wired in later (the
204+
gateway adds it, with failover, only when one is configured).
205+
206+
> **`:8000` means two different things depending on scaffold — this
207+
> ambiguity is what issue #92 was about.** In the legacy single-model
208+
> section above, `:8000` is the vLLM **container's own** port, published
209+
> straight to the host with no gateway in front of it. Here, `:8000` (or
210+
> whatever `VLLM_PORT` is set to) is the **gateway's** published port,
211+
> fronting several backends by `model` field. They happen to share the same
212+
> default number, but a client must dial whichever origin its deployment
213+
> actually publishes — never assume a bare port number implies a particular
214+
> topology. `lobes doctor` and `GET /capabilities` are the source of truth
215+
> for what a given deployment actually serves and where.
191216
192217
```bash
193-
lobes init --fleet --apply # $HOME/.lobes/{docker-compose.yml,.env,Dockerfile.gateway}
218+
lobes init --apply # $HOME/.lobes/{docker-compose.yml,.env,Dockerfile.gateway} — the default
194219
docker login nvcr.io # NGC API key for the vLLM image
195220
lobes fleet up --apply # builds the gateway image + starts the backend
196221
lobes fleet status # container states + gateway /health + /v1/models
@@ -204,7 +229,7 @@ curl -s http://localhost:8000/v1/chat/completions -d '{"model":"sakamakismile/Qw
204229

205230
The fleet runs a **default-on `cortex` + `senses` duo** (the `main` + `multimodal`
206231
backends) — the 27B Qwen text generate primary served at **128K** (`cortex`, util
207-
`0.30`) and the Gemma 4 12B vision+audio gear served at **32K** (`senses`, util
232+
`0.30`) and the Gemma 4 12B vision gear served at **32K** (`senses`, util
208233
`0.14` — provisional pending live validation; `coolthor/gemma-4-12B-it-NVFP4A16`,
209234
native MTP default-on — the coder fine-tune, `sakamakismile/gemma-4-12B-coder-…`,
210235
is kept as an opt-in `multimodal-coder` gear; see

docs/colleague-stack.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,23 @@ below.
2727
| Role | Backend / service | Endpoint path | What it's for |
2828
|---|---|---|---|
2929
| `cortex` | `primary` (generate) | `POST /v1/chat/completions` | Reasoning, deciding, planning, tool use, repo actions — the final authority. |
30-
| `senses` | `multimodal` (generate) | `POST /v1/chat/completions` | Intake/perception (text+image+audio) and speaking back to the user. Does **not** decide or act. |
30+
| `senses` | `multimodal` (generate) | `POST /v1/chat/completions` | Intake/perception (text+image) and speaking back to the user. Does **not** decide or act. |
3131
| `embedder` | `embed` (pooling) | `POST /v1/embeddings` | Dense text embeddings for memory/retrieval. |
3232
| `reranker` | `rerank` (pooling) | `POST /v1/rerank` (+ `/v1/score`) | Reordering/scoring retrieved candidates. |
3333
| `stt` | Parakeet (audio overlay, opt-in) | `POST /v1/audio/transcriptions` | Speech-to-text. |
3434
| `tts` | Chatterbox (audio overlay, opt-in) | `POST /v1/audio/speech` | Text-to-speech. |
3535

36+
> **`senses` is vision-only intake — audio is not currently served (issue
37+
> #101).** The `coolthor/gemma-4-12B-it-NVFP4A16` checkpoint behind `senses`
38+
> declares an `audio_config` in its own model config, but on this vLLM serving
39+
> path (`gemma4_unified`) an `input_audio` content part is silently **dropped**
40+
> rather than rejected: a caller gets `200 OK` and a fluent answer that ignored
41+
> the audio. Live evidence and the tracking issue are in
42+
> [`docs/gemma-4-12b-nvfp4.md`](gemma-4-12b-nvfp4.md#live-validation-status-71).
43+
> For speech, use the purpose-built **`stt`** role (Parakeet, `POST
44+
> /v1/audio/transcriptions`) instead — it remains first-class and is
45+
> unaffected by this gap.
46+
3647
`cortex`, `senses`, `embedder`, and `reranker` are always enumerated (present
3748
with `loaded=false` if their gear isn't wired in this deployment); `stt`/`tts`
3849
require `lobes init --fleet --audio`. **`brain` is not a valid role name**
@@ -313,7 +324,7 @@ scaffold did solo, in exchange for co-residency:
313324
| **Fleet duo, pre-rebalance** | 64K, util 0.30 | 128K (`MULTIMODAL_MAX_MODEL_LEN=131072`, util 0.22) |
314325
| **Fleet duo, current (this doc)** | **128K** (`PRIMARY_MAX_MODEL_LEN=131072`, util 0.30 — util-bound, not context-bound) | **32K** (`MULTIMODAL_MAX_MODEL_LEN=32768`, util 0.14) |
315326

316-
The pre-rebalance duo gave the vision/audio gear its full native 128K at the
327+
The pre-rebalance duo gave the vision gear its full native 128K at the
317328
cost of trimming `cortex` to 64K; the current default flips that trade-off —
318329
`cortex` (the final-authority reasoning role) now gets its full native 128K,
319330
and `senses` (intake/perception) is trimmed to 32K, which is ample for the

docs/gateway-fleet.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,17 @@ is required (pinning keeps the image reproducible); from-source/dev boxes that r
347347
ahead of a PyPI release point `MODEL_GEAR_VERSION` at a published TestPyPI `.devN`
348348
build.
349349

350+
**That pin is written once and nothing re-pins it (issue #99).** `lobes init`
351+
sets `MODEL_GEAR_VERSION` at scaffold time; no other verb touches it
352+
afterwards, so a long-lived deployment can silently keep running an old
353+
gateway image long after the host's own `lobes` binary — and PyPI — have
354+
moved on. `GET /health` reports the deployed gateway's own `lobes-cli`
355+
`version` (additive field, issue #99) precisely so this is detectable without
356+
docker: `lobes doctor`'s `gateway_version_match` check compares it against the
357+
CLI's own version and fails the run on a real mismatch (remediation: bump
358+
`MODEL_GEAR_VERSION` in `.env` and `docker compose up -d --build gateway`) —
359+
see [Verbs](#verbs) below.
360+
350361
### Auth (known limitation)
351362

352363
The gateway is a **pass-through** and is **not auth-aware** — it does not inspect

0 commit comments

Comments
 (0)