Skip to content

lobes never advertises a capability it cannot serve (#92 · #91 · #95 · #97 · #96 · #74 · #69) - #102

Merged
OriNachum merged 39 commits into
mainfrom
spec/advertised-implies-reachable-92-91-74-69
Jul 9, 2026
Merged

lobes never advertises a capability it cannot serve (#92 · #91 · #95 · #97 · #96 · #74 · #69)#102
OriNachum merged 39 commits into
mainfrom
spec/advertised-implies-reachable-92-91-74-69

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

lobes never advertises a capability it cannot serve

Five open items — #92, #91, #95 (dup of #92), #74, #69 — turned out to be one disease: lobes advertises configuration and calls it reachability, and nothing ever dials what it advertises. This PR makes "advertised ⇒ reachable" an executable invariant.

Built through the devague pipeline: /think spec/spec-to-plan (10 tasks, 6 waves, 39/39 coverage targets) → /assign-to-workforce (12 tasks merged, each TDD-gated: tests green before and after every merge).


The issues named the symptoms correctly and the causes wrongly

#92 is not a regression of #87 — the fix was never deployed

$ docker exec model-gear-gateway python -c "import lobes, lobes.gateway.server as s; print(lobes.__version__, hasattr(s,'reachable_origin'))"
0.36.0 False
$ git log -1 --format='%ci' 0be972f     # PR #90, which added reachable_origin, shipped in 0.38.0
2026-07-04 08:27:42 +0300
$ docker image inspect lobes-gateway --format '{{.Created}}'
2026-07-03T15:00:17+03:00

Dockerfile.gateway runs pip install "lobes-cli==${MODEL_GEAR_VERSION}"; lobes init writes that pin once and no verb ever bumps it. The rig ran three lobes versions at once (gateway 0.36.0, realtime 0.34.1, CLI 0.39.0) while 0.37.0/0.38.0/0.39.0 sat published on PyPI. Filed as #99.

And :8000 was never a dead port — it is reachy-mini-dae, an unrelated uvicorn service. The advertised endpoint pointed a client at a foreign daemon.

#91 is not a backend-reload window — it is cross-model failover

handle_post rewrote the model id once, before the failover loop, while order_backends offered every same-task generate backend as a retry target — [primary, multimodal, multimodal-coder, middle] for cortex. A cortex 5xx therefore retried the same body, still naming the Qwen model, against the Gemma backend, which correctly answered 404 does not exist, and the 4xx = client error, no failover rule relayed that as terminal.

Proven from the containers' own logs, in both directions:

model-gear-vllm-primary:
  ERROR 07-09 04:08:34  vllm.v1.engine.exceptions.EngineDeadError
  INFO:  "POST /v1/chat/completions HTTP/1.1" 500 Internal Server Error
  ERROR 07-09 04:34:01  Error with model error=ErrorInfo(
      message='The model `coolthor/gemma-4-12B-it-NVFP4A16` does not exist.', code=404)

That last line is the Qwen container being handed the Gemma id. And tests/test_gateway_routing.py::test_order_backends_generate_still_failovers_between_generate_backends asserted this behaviour as intended, which is why it survived review.

Resolution (user-decided): no cross-backend failover at all. order_backends returns at most one backend. A dead / unreachable / warming owner yields 503 + Retry-After, type: backend_unavailable. A caller who asks for cortex can never silently receive Gemma — which also protects #81's final_authority contract.


Three bugs found while chasing it, now filed

#96 AUDIO_URL reaches the gateway only via docker-compose.audio.yml, so stt/tts advertised ready=true on a path returning 404 "audio endpoints are not configured".
#97 _optional_backend wired a backend from *_SERVED_NAME alone against an invented default_url. /v1/models listed 6 models against 4 running containers.
#101 senses advertises audio intake and silently drops it.

Plus #98 (the EngineDeadError that triggers #91, ~once/day), #99 (the frozen image pin), #100 (the pressure policy triggers on sticky swap occupancy; PSI showed full avg10=0.00 while the gateway shed 100% of generate traffic).

Neither contract surface was authoritative — and they were wrong in opposite directions

For the generate roles the gateway JSON was wrong (its internal :8000) and the CLI was right. For the audio roles the CLI was wrong (ready=true from a string in .env) and the gateway was right. build_role_registry really was one builder — fed two different gateway_url values. "One source of truth" held for the shape and failed for the origin.

Fixed structurally: lobes capabilities now renders the running gateway's GET /capabilities, falling back to an offline view tagged "source": "offline" with ready=false everywhere. Two derivations became one.


#74: one box proven, one box disproven

The unchecked box asked to "confirm image+text and audio+text" on coolthor/gemma-4-12B-it-NVFP4A16. Token accounting settles both:

request prompt_tokens result
text only 15
text + image (96×96 solid PNG) 273 (+258) replies "Red" / "Blue"verified against ground truth
text + audio (0.68 s WAV, 24 kHz) 34 (+19) ""
text + audio (same clip @ 16 kHz) 48 "I cannot hear any audio…"

The vision tower is wired and reads pixels (with a negative control: a blue image correctly fails a "red" assertion). Audio is dropped, not rejected — the caller gets 200 OK and a fluent answer that ignored the audio. The checkpoint declares audio_config/audio_token_id, so this is a vLLM gemma4_unified gap (#101).

Why it went unnoticed: the existing "audio+text ✓" check asserted only HTTP 200 + non-empty content against a 1×1 placeholder. It proved the wire, not the perception. senses is now documented vision-only; stt (Parakeet) is the supported speech path and works — tts("banana") → stt → "Banana."


The gate

Per the requirement "a local test before PRs that live-tests our capabilities", "runs locally and once triggered, runs without intervention":

$ scripts/live-check.sh
FAIL (exit 1) — an advertised capability is NOT reachable, or the deployed
gateway is version-skewed from this CLI.

Five checks, each naming the issue it maps to. It fails rather than skips when armed. 429 (pressure shed, #88) and 503 with Retry-After count as reachable; only a 404, a connection failure, a Retry-After-less 503, or a bare 5xx are faults.

It is red on the reference rig right now — via #91 (over-listed candidate models) and #99 (deployed gateway reports no /health version). That is the gate working: this is the check whose absence let a merged fix sit undeployed for five days while #92 was filed against it.


Notable

The spec itself was wrong, and testing the requirement caught it. c11 required the compose to default GATEWAY_PUBLIC_URL from VLLM_PORT. A subagent implemented it faithfully. But public_url outranks the Host header, so a defaulted value would have told every LAN and tunnel client to dial its own loopback — reintroducing #92 one rung out. Caught by running the real resolver against a simulated remote Host before merging; amended to c29 (explicit override > Host > empty, never the internal port) and committed with the reasoning. A negative regression guard (test_default_has_no_localhost_or_vllm_port) now stops anyone "helpfully" restoring the default.

1169 tests pass (from 1084). black / isort / flake8 / bandit clean; afi cli doctor . --strict 26/26.


Closes #92 · Closes #91 · Closes #95 · Closes #97 · Closes #96 · Closes #74 · Closes #69
Follow-ups: #98 · #99 · #100 · #101

  • lobes (Claude)

OriNachum and others added 29 commits July 9, 2026 08:34
…#74 · #69)

Unifies five open items into one invariant — advertised implies reachable —
after live investigation on the DGX Spark rig proved the issues describe the
symptoms correctly but the causes wrongly:

* #92 is not a regression of #87. `reachable_origin` shipped in 0.38.0 (PR #90);
  the rig runs a gateway image built 2026-07-03 carrying lobes 0.36.0, so the fix
  was never deployed and nothing detects the skew. Host :8000 is not dead either
  — it is an unrelated uvicorn service (reachy-mini-dae).
* #91 is not a backend-reload window. `handle_post` rewrites the model id once,
  before the failover loop, while `order_backends` offers every same-task
  generate backend as a failover target. Cortex 5xx (EngineDeadError, 04:08:34)
  -> the same body is retried against Gemma -> Gemma correctly 404s -> the
  "4xx = client error, no failover" rule relays it as terminal. The primary
  container's own logs show the symmetric case at 04:34:01 and 04:39:01.
* Two unfiled bugs of the same shape: AUDIO_URL never reaches the gateway
  container (stt/tts advertise ready=true on a path that 404s), and /v1/models
  advertises phantom backends (6 models, 4 containers).

Decisions recorded in the frame: no cross-backend failover at all; readiness
becomes a background cached probe; phantom backends stopped by both a config
gate and the readiness filter; #69's DSpark criterion closed answered-negative.

Evidence: coolthor/gemma-4-12B-it-NVFP4A16 genuinely perceives images
(red -> "Red", blue -> "Blue", ground-truth checked). Audio perception is
blocked by the AUDIO_URL bug and is gated behind its fix.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
…#92 · #91 · #74 · #69)

Forward leg from the converged frame. Tasks are decomposed by FILE so each wave
is operationally parallel, not merely formally so:

  wave 0  t1 _config.py (backend wiring gate)   t2 _routing.py (no cross-backend failover)
          t3 _readiness.py (new, background probe cache)
          t4 fleet templates (GATEWAY_PUBLIC_URL + AUDIO_URL to the gateway)
  wave 1  t5 roles.py (ready decoupled from loaded; never advertise the internal port)
  wave 2  t6 server.py (503 + Retry-After, readiness wiring, reachable_origin)
  wave 3  t7 CLI (capabilities/gateway agreement, doctor version-skew)
          t8 senses perception probe (ground-truth image + audio)
  wave 4  t9 the local, single-trigger, unattended pre-PR live gate
  wave 5  t10 documentary repairs (gemma docs, DSpark banner, README quickstart)

Risks recorded: *_BASE_URL gate may break a hand-edited .env (r1); background
probe-thread lifecycle inside ThreadingHTTPServer (r2); Chatterbox's poisoned-CUDA
history can flake the audio probe (r3); the live gate cannot run in CI, so nothing
structurally forces it to run — exactly how #87's fix shipped in 0.38.0 while the
rig kept running 0.36.0 (r4, follow-up).

New issues filed from this investigation: #96 (AUDIO_URL never reaches the
gateway), #97 (phantom backends in /v1/models), #98 (cortex EngineDeadError).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
…e gateway (#96)

Two env vars never reached the gateway container from the base fleet
compose, both causing it to advertise things it cannot serve:

- GATEWAY_PUBLIC_URL defaulted to empty, so the advertised /capabilities
  origin fell back to Host-header inference — and, absent a Host header,
  fabricated a URL from the gateway's internal listen port rather than the
  published host port. It now defaults to
  http://localhost:${VLLM_PORT:-8000}, the same port the gateway is
  actually published on, while staying operator-overridable for a tunnel /
  Host-rewriting reverse proxy.

- AUDIO_URL only reached the gateway via the --audio overlay
  (docker-compose.audio.yml). A base-only deployment left
  ServerConfig.audio_url unset in code but 'lobes capabilities' (reading
  the merged .env) still reported stt/tts as ready=true, and
  POST /v1/audio/speech 404'd. AUDIO_URL is now declared on the base
  template too, defaulted to empty so a base-only deployment resolves it
  to unset (loaded=false) instead of pointing at a realtime container that
  was never started; the audio overlay's override still supplies the real
  value when present.

Verified the nested ${GATEWAY_PUBLIC_URL:-http://localhost:${VLLM_PORT:-8000}}
interpolation with `docker compose config` against a scratch copy of the
template, both with and without the audio overlay layered on top.

tests/test_fleet_template_gateway_env.py (new) parses the packaged fleet
compose and locks in both keys/defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
_optional_backend previously wired a backend when EITHER its *_BASE_URL
OR its *_SERVED_NAME env var was set, falling back to a hardcoded
default_url naming a compose service that need not exist. On the
reference rig this invented two phantom backends -- multimodal-coder and
middle -- that GET /v1/models advertised while no container served them.

Drop the `or name_key` clause: a served name with no URL describes a
model, not a reachable backend. This matches the contract the fleet
already documents for MINOR_BASE_URL ("empty => minor silently
unwired"). Verified on the live rig this unwires exactly
multimodal-coder and middle, keeping primary/multimodal/embed/rerank.

Repairs the tests that asserted the old permissive behaviour
(test_gateway_routing, test_fleet_minor, test_gateway_tiers, and the
test_gateway_server _cfg fixture, which relied on FALLBACK_SERVED_NAME
alone wiring the fallback backend for its failover tests) and adds
tests/test_gateway_config_wiring.py covering the new contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
New module lobes/gateway/_readiness.py: a bounded, background-cached probe
of each fleet backend's /health so the gateway can distinguish "answered
recently" from "merely configured" (today RoleInfo.ready aliases loaded).

Mirrors PressureCache's shape/naming/threading discipline: a single daemon
thread refreshes an in-memory snapshot on an interval; .current() returns a
copy without ever probing (O(1), socket-free on the request path).

Readiness is tri-state, matching probe_audio_ready: True (reached, 200) /
False (reached, non-200 e.g. warming) / None (unreachable/unknown) — False
and None are not collapsed. The probe helper catches OSError, HTTPException
AND ValueError (the non-numeric-port bug caught on PR #90) and degrades to
None. The background thread is a daemon and stop()/close() joins it cleanly.

Standalone + fully unit-tested (tests/test_gateway_readiness.py); a later
task wires .current() into GET /v1/models and GET /capabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
 for remote clients

c11 required the fleet compose to inject GATEWAY_PUBLIC_URL derived from the
published VLLM_PORT, and for reachable_origin to prefer it over the request Host
header. That is wrong: a defaulted public_url is ALWAYS set, so the Host header is
never consulted, and a LAN or tunnel client GETting /capabilities is told to dial
http://localhost:8001 — which on that client's machine is a different service.
The fix for "the advertised endpoint points at a foreign daemon" would have
reintroduced exactly that defect one rung out.

c29 replaces it: explicit operator override (GATEWAY_PUBLIC_URL, for a tunnel or
Host-rewriting proxy) > the origin the client actually dialed (Host header) >
nothing (an empty endpoint). Never an absolute URL built from the internal
GATEWAY_PORT, and never a defaulted localhost public_url. Each caller receives an
origin correct for itself.

The AUDIO_URL half of the compose change (issue #96) is unaffected and stands.

Plan coverage repointed: c29/h25 -> t4 (compose) + t6 (origin resolver).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
Amends the previous commit's GATEWAY_PUBLIC_URL half. The c11 requirement it
implemented (default the origin from the published VLLM_PORT) was defective: a
defaulted public_url reintroduces the #92 defect. reachable_origin prefers a
set public_url OVER the request Host header, so any localhost/published-port
default advertises loopback to every remote client — a LAN/tunnel caller
dialing spark.local:8001 is told to use http://localhost:8001, a foreign
service on their machine.

Amended requirement (c29): origin precedence is explicit operator override >
Host header > empty. GATEWAY_PUBLIC_URL exists ONLY as an operator override for
a tunnel / Host-rewriting reverse proxy; it must not be defaulted to a
localhost URL. So:

- docker-compose.yml: GATEWAY_PUBLIC_URL back to ${GATEWAY_PUBLIC_URL:-}, with
  a load-bearing comment explaining WHY it must stay empty (a defaulted
  public_url outranks the Host header) so it is not "helpfully" restored later.
- env.example: document GATEWAY_PUBLIC_URL as the tunnel / proxy override only;
  empty makes the gateway echo the origin the client dialed. No published-port
  default claim.
- test_fleet_template_gateway_env.py: invert the ${VLLM_PORT}-default
  assertions. Now asserts the default is exactly ${GATEWAY_PUBLIC_URL:-} AND
  the negative regression guard — the default contains neither 'localhost' nor
  'VLLM_PORT'. Kept the ports: mapping test (documents the published-vs-internal
  port distinction that is the root cause).

The AUDIO_URL=${AUDIO_URL:-} half (the #96 fix) is unchanged. Re-verified with
`docker compose config`: GATEWAY_PUBLIC_URL stays empty even with VLLM_PORT=8001
set, operator overrides win, and the --audio overlay still supplies AUDIO_URL
while leaving GATEWAY_PUBLIC_URL empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
); GATEWAY_PUBLIC_URL stays an override-only var (c29)
order_backends() previously walked every same-task generate backend as a
failover chain (e.g. cortex -> multimodal). A dead cortex vLLM engine got
silently retried against the Gemma backend with a body still naming the
Qwen model id, producing a terminal 404 that killed long-running agent
loops -- or worse, a real answer from the wrong model, violating the
final_authority role contract (#81).

order_backends(table, served) now always returns a list of length <= 1:
the served name's owning backend, or the default_model's owner for an
unknown name. No runtime cross-backend retry, ever. The static tier-alias
upward fallback in tier_aliases() is unrelated and preserved -- that
resolves an unwired capability tier at table-build time, before
order_backends runs.

Inverted tests to the new contract (renamed, not deleted, to keep the bug's
history legible):
- test_gateway_routing.py: test_order_backends_generate_still_failovers_
  between_generate_backends -> test_order_backends_generate_never_failovers_
  across_models; test_order_backends_owner_first_then_failover ->
  test_order_backends_owner_only_no_failover; added
  test_order_backends_always_returns_at_most_one_backend,
  test_order_backends_known_served_returns_its_own_owner,
  test_order_backends_unknown_served_returns_default_owner.
- test_fleet_minor.py: test_order_backends_minor_is_owner_with_primary_
  failover -> test_order_backends_minor_is_owner_with_no_failover; the
  "primary failover includes minor" test -> test_primary_never_failovers_
  to_minor_when_minor_present.
- test_gateway_server.py: test_failover_on_connection_refused ->
  test_no_failover_on_connection_refused; test_failover_on_5xx ->
  test_no_failover_on_5xx; test_explicit_fallback_routes_to_fallback_first
  -> test_explicit_fallback_routes_to_fallback_only; updated
  test_all_backends_down_returns_502 for the single-attempt attempts list.
  All still assert the existing 502 upstream_unavailable (handle_post's
  502->503+Retry-After conversion is t6's scope, not touched here).

lobes/gateway/server.py, _config.py, _readiness.py, and roles.py are
untouched -- handle_post's existing for-loop over order_backends() already
degrades correctly to a single attempt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
… gateway endpoint from internal host:port (t5)

Two bugs let GET /capabilities advertise ready: true for a role whose
endpoint 404s (advertised-implies-reachable violation):

1. RoleInfo.ready was a bare alias of loaded (a config fact) for
   cortex/senses/embedder/reranker, unlike stt/tts which already split
   the two (issue #89/#90). build_role_registry now accepts an optional
   backend_ready: Mapping[str, bool | None] keyed by ROLE_BACKEND name
   (the exact shape lobes.gateway._readiness.ReadinessCache.current()
   returns), mirroring audio_ready's shape/defaulting. ready is
   structurally clamped to False whenever a role's backend isn't wired
   or its endpoint is empty, so no caller-supplied signal can fabricate
   ready=True — generalising the #89/#90 clamp to all six roles.

2. _gateway_base_url(server) fabricated an absolute URL from the
   gateway's INTERNAL container listen port (GATEWAY_HOST/GATEWAY_PORT),
   which need not match the published host port a caller can actually
   dial. It now only ever returns server.public_url (an operator-
   declared GATEWAY_PUBLIC_URL) or "" — never a value derived from
   host:port. An empty endpoint is covered by the same ready clamp.

Repairs the resulting blast radius: tests/test_roles.py (new
backend_ready + empty-endpoint coverage, replacing the now-obsolete
GATEWAY_HOST bracket/normalize tests), tests/test_gateway_capabilities.py
(explicit gateway_url added where a test needs a dialable endpoint),
and tests/test_colleague_contract.py (the fake fleet now passes its
own real loopback origin as gateway_url, matching what the production
HTTP route does via reachable_origin()).

lobes/gateway/server.py is untouched — wiring ReadinessCache into the
live gateway route is a follow-up task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
…ess-gated /v1/models & /capabilities (#91 #92 #14)

Integration task the "advertised implies reachable" plan converges on.

Job 1 — status matrix (handle_post, #14/#91). With order_backends now
single-element (no cross-backend failover), a present-but-dead owner
(refusal / timeout / >=500) becomes a RETRYABLE 503 backend_unavailable +
Retry-After, never a terminal 404/502. The 429 server_busy shed is
untouched; a 4xx (incl. the owner's own 404 "model does not exist") is a
client error relayed verbatim; 502 upstream_unavailable survives only for
the degenerate empty order_backends (malformed routing table). Rewrote
handle_post's + the module docstring, which documented removed failover.

Job 2 — readiness wiring (#92, c15/h14). serve() constructs a
ReadinessCache, does one bounded synchronous refresh() BEFORE binding
(closes the startup window — the cache seeds to None), then start()s the
daemon; _make_handler binds it. GET /v1/models lists only backends whose
readiness is True (list_models_payload gains an additive ready filter in
_routing.py). GET /capabilities folds the snapshot into role readiness.
Bridged a semantic mismatch at the boundary (_ready_iff_true): the cache's
None means "unreachable" but roles.py reads None as "no signal → fall back
to loaded", so a raw pass-through would advertise a wired-but-dead backend
ready=True — collapse None/False→False so a dead backend is advertised
nowhere. The POST hot path opens no probe (reads socket-free .current()).

Job 3 — origin resolution (c29/h25). Locked reachable_origin precedence
(GATEWAY_PUBLIC_URL > Host > empty) with tests, incl. the regression guard
that an unset public_url + Host: spark.local:8001 yields the client's own
origin, never localhost/GATEWAY_PORT (host :8000 is a foreign daemon on the
rig), and that None → "" endpoint end to end.

Invariants: race (listed → owner killed → 503, never 404), no-Gemma (dead
primary never dials the multimodal backend, asserted on opener call sites),
converse (unknown model id routes to default owner — current behaviour
locked as a documented choice; recommend a future 404 in _routing.py).

Added public ReadinessCache.refresh() (permitted additive change) for the
seed-before-bind. Tests: 1151 passed, 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
…models + /capabilities; origin precedence locked (#91 #92)
…on tests (#74, #96)

Layer B's image/audio "confirmed" claims only proved the wire (1x1 PNG /
near-silent WAV, asserting non-empty content) -- a model ignoring the media
entirely would still pass. Add real perception tests instead:

* test_live_multimodal_image_perception_names_colour[red|blue]: a solid-colour
  PNG generated in-process (stdlib zlib/struct only) must be named correctly.
  Verified live against http://localhost:8001 (red -> 'Red', blue -> 'Blue'),
  and verified falsifiable via a negative control (asserting 'red' against a
  blue image fails).
* test_live_multimodal_audio_perception_transcribes_known_word: synthesizes a
  known word via the rig's own TTS (POST /v1/audio/speech) and asserts the
  transcription contains it. Fails loudly (not skip) on the current 404 --
  AUDIO_URL not yet wired into this running gateway, issue #96, fixed in the
  base fleet template by t4 but pending redeploy -- and gives TTS a bounded
  retry so a poisoned-CUDA-context 500 (Chatterbox's known failure mode)
  reports as "TTS backend unhealthy", distinct from "senses cannot hear".

The old placeholder-media tests are kept but renamed to
test_live_multimodal_accepts_{image,audio}_content_part_wire_check with
docstrings stating they assert transport only, not perception.

Offline suite unchanged: 1151 passed (baseline), 9 skipped (+3 new
live-gated tests, still gated on LOBES_SMOKE_BASE_URL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
Closes the two gaps t6 left as caller-discipline patches.

Gap 1 (#92/h14) — build_role_registry now self-enforces the readiness
invariant its own docstring promises. A SUPPLIED backend_ready mapping is
authoritative: a present None, a present False, and a missing key all mean
NOT ready (ready = get(name) is True), still clamped on loaded + non-empty
endpoint. Only an OMITTED mapping falls back to the loaded proxy (back-compat).
This removes the trap where the readiness cache's None (UNREACHABLE) was read
as roles.py's None (no signal -> fall back to loaded=True), resurrecting the
#92 defect. server.py's _ready_iff_true bridge is now redundant and deleted;
the /capabilities route passes ReadinessCache.current() straight through, and
the coercion is the builder's job for every caller, not one call site.

Gap 2 (h23 converse) — an unknown model id is no longer silently served under
the default backend's weights. New pure predicate is_unknown_model distinguishes
UNKNOWN (a non-empty id that is neither an alias nor any wired backend's served
name) from UNSPECIFIED (missing/blank -> default_model, still served).
handle_post 404s an unknown id (model_not_found) before routing. Unknown-ness is
decided against the ROUTING TABLE, never the readiness-filtered /v1/models list:
a wired-but-dead backend is dropped from /v1/models but is still KNOWN, so it
routes to its owner and yields the retryable 503, not a 404 (keeps #91 fixed).
resolve_model's signature is unchanged (companion-predicate design), so its many
callers and the tier tests are unaffected.

Inverted tests: test_backend_ready_missing_entry_falls_back_to_loaded ->
_is_not_ready; added present-None coverage; replaced
test_unknown_model_id_routes_to_default_owner_documented_choice with the h23
converse 404 test plus wired-but-dead 503-not-404 proofs at both the handle_post
and route level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
… ids 404 instead of being silently served (h14, h23)
…ew (#96, #99)

lobes capabilities / lobes endpoint no longer re-derive the six-role
contract from .env — they GET the gateway's own /capabilities and render
it verbatim, falling back to the offline .env-derived registry (every
role's ready forced false, source="offline"/"gateway" marks which) only
when no gateway answers. This makes the CLI/gateway honesty condition
(h3) true by construction instead of by keeping two independent
derivations in sync, which had already drifted in both directions (#92
underreported, #96 overreported stt/tts as ready=true on a 404 path).

GET /health now reports the gateway's own lobes-cli version (additive),
and `lobes doctor` gained a gateway_version_match check comparing it to
the CLI's own version: mismatch is a real error (fails the run, names
the exact MODEL_GEAR_VERSION fix), an unreachable/pre-#99 gateway
degrades to a non-fatal info result rather than a false pass. This
targets issue #99, the structural cause of #92 (a gateway image pinned
once at `lobes init` time and never re-pinned).

tests/conftest.py's autouse fixture now also neutralises the new gateway
probes (matching how it already neutralises /health), since the dev rig
has a real unrelated daemon on host port 8000 that could otherwise leak
into the "offline" test path.

Full suite: 1169 passed, 9 skipped (was 1158 passed, 9 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
test_live_main_text_returns_nonempty_content assumed a non-thinking model:
with max_tokens=16 the cortex model (a Qwen3.6 reasoning model with
preserve_thinking, issue #93) can spend the whole budget on its reasoning
trace and return content=None with finish_reason="length". Raise the
budget to 256 and accept the reasoning trace as evidence of life only
when finish_reason explains it as budget exhaustion, so the fallback
can't mask a genuine empty-content bug.

test_live_multimodal_audio_perception_transcribes_known_word fails for a
real, now-tracked reason: the deployed Gemma silently discards
input_audio content parts (vLLM gemma4_unified gap, not a checkpoint
gap -- issue #101). Mark it xfail(strict=True) citing the mechanism and
evidence; the probe body is untouched so it flips to XPASS (and fails
the suite) the day audio ingestion starts working.

Verified against the live rig (LOBES_SMOKE_BASE_URL=http://localhost:8001):
main-text passes, both colour perception tests pass, audio perception
xfails, both wire checks pass. Offline: 1169 passed, 9 skipped (unchanged
from baseline).
… retire DSpark route

Job 1 — docs/gemma-4-12b-nvfp4.md's #71/#73 admission ("not independently
re-run against the base checkpoint") is now resolvable: live evidence against
coolthor/gemma-4-12B-it-NVFP4A16 via model=multimodal shows image+text
VERIFIED (ground truth + negative control) and audio+text NOT SUPPORTED —
vLLM's gemma4_unified silently drops the input_audio content part instead of
rejecting it (200 OK, audio ignored). Tracked as issue #101. Also corrects
the provenance of the coder checkpoint's original "audio+text ✓ (transcribed
verbatim)" claim: that check only asserted HTTP 200 + non-empty content
against a placeholder clip, never ground truth — and the claim didn't hold
when tested properly. lobes/catalog.py's coolthor and coder entry comments
carry the same correction.

Job 2 (user decision) — a capability lobes cannot serve must not appear in a
contract Colleague reads. docs/colleague-stack.md and CLAUDE.md described
`senses` as "vision+audio intake/perception"; corrected to vision-only, with
a clearly-marked note pointing at issue #101 and the purpose-built `stt`
role as the supported path for speech. README.md carried the same
overclaim in two spots ("vision+audio gear"); fixed for consistency.
lobes/roles.py's ROLE_RESPONSIBILITIES tokens are untouched — they never
claimed audio.

Job 3 (user decision, closed answered-negative) — docs/gemma4-mtp-draft.md
still presented the DSpark route as "the ONE route task t3 should wire
next," inconsistent with issue #75's live finding (Gemma4DSparkModel does
not load on vLLM 0.23) already recorded in docs/gemma-4-12b-nvfp4.md and
docs/vllm-nightly-migration.md. Added a superseded banner citing #75 and
pointing at the two docs carrying the current story; the research content
stays below it as the record of how the question was answered. Also states
that issue #69's last acceptance criterion (a disabled-by-default DSpark
entry) is answered-negative: no catalog entry ships for a model that cannot
load.

Job 4 — README.md's quickstart still documented bare `lobes init --apply`
as scaffolding the single-model deployment and framed `--fleet` as the way
to opt into the multi-container deployment. Both have been false since issue
#69: the fleet duo is the default, `--fleet` is a back-compat no-op, and
`--single`/`--legacy` opts out. Reworked both sections accordingly and
added a note on the :8000-means-two-different-things ambiguity (container
port vs. gateway-published port) that issue #92 was about.

uv run pytest -n auto -q: 1169 passed, 9 skipped (matches baseline).
markdownlint-cli2 passes on every touched markdown file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
…runner)

Adds the executable local pre-PR gate the "advertised implies reachable"
plan exists to create. It is NOT a CI job (CI has no GPU/fleet): a developer
runs it against a running deployment before opening a PR.

New files:
* tests/test_live_capabilities.py — five checks, each mapped to its issue:
  1. every ready role in GET /capabilities is reachable at endpoint+path
     (404 or a bare 5xx-without-Retry-After = fail; #92/#96/#89).
  2. every GET /v1/models id is reachable on its own task lane, never a
     404 "model does not exist" (#91) — lane resolved from the live
     contract so pooling models aren't falsely dialed on a chat route.
  3. `lobes capabilities --json` agrees with GET /capabilities on
     endpoint/ready/loaded for all six roles, via source==gateway (#95/#92).
  4. gateway GET /health version == lobes.__version__; a missing version
     field is skew, not a pass (#99).
  5. a Colleague can resolve+dial cortex/senses from the contract alone,
     no hardcoded model ids (#81/#87).
* scripts/live-check.sh — the single trigger: resolves the port the way the
  CLI does (--port -> VLLM_PORT in .env -> 8000), arms the gate via
  LOBES_SMOKE_BASE_URL, runs pytest, prints a human summary + pass/fail exit.

Fail-not-skip: the module skips cleanly when LOBES_SMOKE_BASE_URL is unset
(offline suite stays green: 1169 passed, 14 skipped) but FAILS — never
skips — on any fault once armed. 429 (pressure shed #88) and 503+Retry-After
(honest warming/dead-owner) are treated as reachable so a busy box never goes
red. Stdlib only (urllib/json/struct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
CHANGELOG covers the twelve merged tasks. Also records this session's findings
into the in-repo eidetic store: the frozen fleet image pin (#99), the sticky
swap-occupancy pressure trigger (#100), and senses' silent audio drop (#101).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Enforce “advertised ⇒ reachable” across gateway, CLI, and fleet templates

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Back gateway advertisement with live readiness and return honest retryable 503s.
• Make CLI capabilities a gateway client with explicit offline, not-ready fallback.
• Add local live-check gate plus extensive tests/docs for reachability invariants.
Diagram

graph TD
  A["Client / CLI"] --> B["Gateway server"] --> C["Routing table"] --> D["Backend owner"]
  B --> E["Readiness cache"]
  B --> F["/v1/models & /capabilities"]
  A --> G["live-check gate"] --> B
  B --> H["/health version"]
  subgraph Legend
    direction LR
    _cli["Client/Script"] ~~~ _svc["Service"] ~~~ _mod["Module"]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Keep cross-backend failover but rewrite model id per attempt
  • ➕ Could preserve availability benefits of retrying another generate backend
  • ➕ Avoids some 503s when an owner is down
  • ➖ Still risks violating role/model identity expectations (final_authority) if backends differ semantically
  • ➖ Requires carefully rewriting request bodies for all lanes/streaming variants; higher complexity and risk
  • ➖ Can still yield confusing outcomes when model compatibility diverges
2. Probe readiness inline on GET /v1/models instead of a background cache
  • ➕ Simpler conceptual model: advertisement directly reflects immediate probe result
  • ➖ Adds latency and socket fan-out to every read request; can block under backend hangs
  • ➖ Harder to bound resource usage; more failure modes on request path
  • ➖ Conflicts with gateway’s dependency-free/stdlib and ‘no sockets on hot/read paths’ discipline
3. Have CLI remain .env-derived but add a ‘validate against gateway’ subcommand
  • ➕ Minimizes behavior change for existing CLI consumers
  • ➕ Keeps offline-first model for capabilities output
  • ➖ Does not make honesty-by-construction the default; drift remains the common case
  • ➖ Operators/agents may still consume the misleading default output and miss mismatches

Recommendation: The PR’s approach is the best tradeoff for correctness and operability: make the gateway authoritative, gate advertisement on a socket-free readiness snapshot, and make failures explicitly retryable (503+Retry-After) instead of masquerading as terminal 404s. The chosen ‘no cross-backend failover’ decision strongly protects model identity and the role contract; alternatives either increase complexity/risk or fail to eliminate drift by construction.

Files changed (43) +5408 / -298

Enhancement (4) +573 / -38
capabilities.pyMake 'lobes capabilities' render live gateway /capabilities when available +173/-35

Make 'lobes capabilities' render live gateway /capabilities when available

• Adds a bounded GET /capabilities probe and renders gateway-provided payload as authoritative. Falls back to offline '.env' view tagged source=offline with ready=false for all roles.

lobes/cli/_commands/capabilities.py

doctor.pyAdd gateway/CLI version skew detection to 'lobes doctor' +91/-3

Add gateway/CLI version skew detection to 'lobes doctor'

• Introduces a 'gateway_version_match' check using /health’s version field; mismatch fails doctor with concrete remediation, while unreachable/unknown version degrades to info.

lobes/cli/_commands/doctor.py

_readiness.pyAdd background readiness cache probing backend /health +275/-0

Add background readiness cache probing backend /health

• Implements tri-state readiness probing with bounded timeouts and a daemon refresh thread. Exposes socket-free '.current()' for request handlers and a one-shot '.refresh()' for pre-bind seeding.

lobes/gateway/_readiness.py

_health.pyAdd fetch_health() to parse gateway /health JSON +34/-0

Add fetch_health() to parse gateway /health JSON

• Introduces a bounded helper returning parsed /health payload (or None) to support doctor’s version skew check.

lobes/runtime/_health.py

Bug fix (5) +571 / -100
catalog.pyCorrect catalog notes about Gemma 4 audio support +17/-2

Correct catalog notes about Gemma 4 audio support

• Updates SupportedModel commentary to reflect verified image+text behavior and unserved audio+text behavior (vLLM gap).

lobes/catalog.py

_config.pyStop wiring phantom backends from *_SERVED_NAME alone +13/-5

Stop wiring phantom backends from *_SERVED_NAME alone

• Changes optional backend wiring to require *_BASE_URL; served-name-only no longer invents default URLs for nonexistent services.

lobes/gateway/_config.py

_routing.pyRemove cross-backend failover and add unknown-model predicate +111/-17

Remove cross-backend failover and add unknown-model predicate

• 'order_backends' now returns at most one backend (the owner). Adds 'is_unknown_model' to distinguish unknown vs unspecified model ids, and gates /v1/models listing by readiness snapshot when provided.

lobes/gateway/_routing.py

server.pyEnforce single-owner routing, honest 404/503 semantics, and readiness-gated ads +241/-29

Enforce single-owner routing, honest 404/503 semantics, and readiness-gated ads

• 'handle_post' rejects unknown model ids with OpenAI-shaped 404, attempts only the owning backend, and maps owner outages to 503 backend_unavailable with Retry-After. Adds readiness cache wiring to /v1/models and /capabilities and reports gateway version in /health.

lobes/gateway/server.py

roles.pyMake role readiness a live signal and stop fabricating gateway endpoints +189/-47

Make role readiness a live signal and stop fabricating gateway endpoints

• 'build_role_registry' now accepts 'backend_ready' and clamps ready based on supplied live signals; supplied None/False/missing => not ready. Gateway base URL fallback now uses only public_url (or empty), never host:port, preventing advertising internal/foreign endpoints.

lobes/roles.py

Documentation (14) +1347 / -43
lobes-never-advertises-a-capability-it-cannot-serv.jsonAdd execution frame for advertised⇒reachable work +467/-0

Add execution frame for advertised⇒reachable work

• Introduces the devague frame capturing requirements/decisions and investigation context for the invariant.

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

lobes-never-advertises-a-capability-it-cannot-serv.jsonAdd devague plan JSON for advertised⇒reachable +457/-0

Add devague plan JSON for advertised⇒reachable

• Adds the structured plan artifact describing the task breakdown and coverage targets.

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

lobes__public.jsonlUpdate eidetic public memory entries +15/-9

Update eidetic public memory entries

• Refreshes stored notes/memory for the project’s public-facing context around capabilities/reachability.

.eidetic/memory/lobes__public.jsonl

CHANGELOG.mdDocument 0.40.0 advertised⇒reachable changes +30/-0

Document 0.40.0 advertised⇒reachable changes

• Adds release notes covering readiness cache, no failover, CLI gateway-client mode, version skew detection, and the live-check gate.

CHANGELOG.md

CLAUDE.mdUpdate contributor guidance for new invariant/gate +5/-2

Update contributor guidance for new invariant/gate

• Adjusts project guidance to reflect the new reachability invariant and its validation workflow.

CLAUDE.md

README.mdClarify fleet default scaffold and endpoint reachability rules +41/-16

Clarify fleet default scaffold and endpoint reachability rules

• Updates quickstart to emphasize fleet as default, explains port/topology ambiguity, and aligns senses as vision-only intake.

README.md

colleague-stack.mdUpdate Colleague stack documentation for contract sourcing +13/-2

Update Colleague stack documentation for contract sourcing

• Refines how the Colleague stack discovers/dials roles based on the gateway-provided contract.

docs/colleague-stack.md

gateway-fleet.mdAdd/extend gateway fleet operational notes +11/-0

Add/extend gateway fleet operational notes

• Adds guidance around gateway fleet behavior and reachability expectations.

docs/gateway-fleet.md

gemma-4-12b-nvfp4.mdRecord ground-truth validation for Gemma 4 multimodal inputs +55/-6

Record ground-truth validation for Gemma 4 multimodal inputs

• Extends model notes with evidence that image+text works and audio is dropped (tracked as follow-up).

docs/gemma-4-12b-nvfp4.md

gemma4-mtp-draft.mdMark Gemma4 MTP draft as superseded/unsupported +33/-0

Mark Gemma4 MTP draft as superseded/unsupported

• Adds a banner/notes indicating the experiment is not currently viable on the referenced vLLM version.

docs/gemma4-mtp-draft.md

2026-07-09-lobes-never-advertises-a-capability-it-cannot-serv.mdAdd detailed implementation plan for invariant and gate +121/-0

Add detailed implementation plan for invariant and gate

• Introduces the plan document with tasks/waves and coverage targets for making advertised⇒reachable executable.

docs/plans/2026-07-09-lobes-never-advertises-a-capability-it-cannot-serv.md

2026-07-09-lobes-never-advertises-a-capability-it-cannot-serv.mdAdd spec for advertised⇒reachable invariant +81/-0

Add spec for advertised⇒reachable invariant

• Adds the specification describing failure modes, precedence rules, and required checks.

docs/specs/2026-07-09-lobes-never-advertises-a-capability-it-cannot-serv.md

catalog.pyUpdate 'lobes doctor' documentation text for version skew check +10/-4

Update 'lobes doctor' documentation text for version skew check

• Expands the explain text to include the new gateway_version_match behavior and its failure semantics.

lobes/explain/catalog.py

env.exampleClarify GATEWAY_PUBLIC_URL semantics and default behavior +8/-4

Clarify GATEWAY_PUBLIC_URL semantics and default behavior

• Updates comments to emphasize GATEWAY_PUBLIC_URL is override-only and must remain empty by default to avoid advertising loopback to remote clients.

lobes/templates/fleet/env.example

Other (20) +2917 / -117
currentPoint devague to the active spec +1/-1

Point devague to the active spec

• Updates the current devague pointer to the new spec context for this workstream.

.devague/current

current_planPoint devague to the active plan +1/-1

Point devague to the active plan

• Updates the current plan pointer to the advertised⇒reachable plan artifact.

.devague/current_plan

docker-compose.ymlPass AUDIO_URL and keep GATEWAY_PUBLIC_URL default empty in gateway container env +18/-0

Pass AUDIO_URL and keep GATEWAY_PUBLIC_URL default empty in gateway container env

• Ensures AUDIO_URL reaches the gateway even without the audio overlay (default empty), and documents why GATEWAY_PUBLIC_URL must not default to localhost/port-derived values.

lobes/templates/fleet/docker-compose.yml

pyproject.tomlBump version to 0.40.0 +1/-1

Bump version to 0.40.0

• Updates project version for the release containing the advertised⇒reachable invariant work.

pyproject.toml

live-check.shAdd single-trigger local live capabilities gate runner +141/-0

Add single-trigger local live capabilities gate runner

• Adds a script that resolves deployment base URL, arms the live tests via LOBES_SMOKE_BASE_URL, runs pytest, and reports a concise pass/fail summary.

scripts/live-check.sh

conftest.pyNeutralize gateway /capabilities and /health probes for offline determinism +22/-5

Neutralize gateway /capabilities and /health probes for offline determinism

• Extends the autouse offline fixture to stub fetch_health and the CLI’s gateway capabilities probe to avoid interacting with real services bound to guessed ports.

tests/conftest.py

test_cli_capabilities.pyTest CLI gateway-client mode and offline honesty fallback +200/-7

Test CLI gateway-client mode and offline honesty fallback

• Adds assertions for source=offline output, forces ready=false in offline mode, and includes loopback fake-gateway tests to prove the CLI renders live /capabilities verbatim when reachable.

tests/test_cli_capabilities.py

test_colleague_contract.pyAlign Colleague contract tests with updated capability semantics +16/-3

Align Colleague contract tests with updated capability semantics

• Adjusts contract expectations around discovery/dialing given the updated gateway-sourced capabilities behavior.

tests/test_colleague_contract.py

test_doctor.pyAdd coverage for gateway_version_match check outcomes +119/-1

Add coverage for gateway_version_match check outcomes

• Tests doctor behavior for unreachable gateway (info), missing version (info), match (pass), mismatch (fails run with remediation).

tests/test_doctor.py

test_fleet_minor.pyUpdate fleet minor behavior tests for new routing/advertisement rules +29/-13

Update fleet minor behavior tests for new routing/advertisement rules

• Adjusts minor-tier and fleet assertions to match updated readiness/advertisement and routing semantics.

tests/test_fleet_minor.py

test_fleet_template_gateway_env.pyVerify fleet template wires AUDIO_URL and preserves empty public URL default +157/-0

Verify fleet template wires AUDIO_URL and preserves empty public URL default

• Parses the fleet docker-compose template to assert gateway env includes AUDIO_URL and GATEWAY_PUBLIC_URL with empty defaults, plus validates port mapping assumptions.

tests/test_fleet_template_gateway_env.py

test_gateway_capabilities.pyExtend gateway capabilities tests for origin precedence and readiness threading +210/-6

Extend gateway capabilities tests for origin precedence and readiness threading

• Adds tests for public_url > Host > empty precedence, ensures empty origin yields empty endpoints and not-ready roles, and verifies backend_ready tri-state collapsing semantics.

tests/test_gateway_capabilities.py

test_gateway_config_wiring.pyAdd regression tests for optional backend wiring requiring BASE_URL +133/-0

Add regression tests for optional backend wiring requiring BASE_URL

• Ensures served-name-only no longer wires phantom backends, while BASE_URL still wires them correctly for real profiles.

tests/test_gateway_config_wiring.py

test_gateway_readiness.pyAdd unit tests for ReadinessCache and probe_backend_ready +323/-0

Add unit tests for ReadinessCache and probe_backend_ready

• Verifies tri-state probe semantics, ValueError/HTTPException degradation, no-socket '.current()', background refresh behavior, and daemon thread resilience.

tests/test_gateway_readiness.py

test_gateway_routing.pyUpdate routing tests for single-owner semantics and unknown-model detection +135/-16

Update routing tests for single-owner semantics and unknown-model detection

• Adds tests for is_unknown_model behavior and updates order_backends tests to assert no failover and length<=1 invariant.

tests/test_gateway_routing.py

test_gateway_server.pyUpdate server tests for 404 unknown-model and 503 backend_unavailable mapping +344/-22

Update server tests for 404 unknown-model and 503 backend_unavailable mapping

• Adjusts handle_post tests to remove failover expectations, assert Retry-After 503s on owner outage, and validate verbatim relay of owner 4xx.

tests/test_gateway_server.py

test_gateway_tiers.pyAdjust tier tests to match new routing/availability semantics +34/-7

Adjust tier tests to match new routing/availability semantics

• Updates tier-related assertions to align with owner-only routing and readiness-gated advertisement behavior.

tests/test_gateway_tiers.py

test_live_capabilities.pyAdd armed live capabilities gate validating advertised⇒reachable on real deployments +535/-0

Add armed live capabilities gate validating advertised⇒reachable on real deployments

• Introduces a skip-when-unarmed module that, when armed, fails on unreachable advertised roles/models, CLI/gateway drift, missing /health version, and broken colleague discovery/dialing paths.

tests/test_live_capabilities.py

test_roles.pyUpdate role registry tests for endpoint and readiness clamping semantics +177/-22

Update role registry tests for endpoint and readiness clamping semantics

• Adjusts unit tests to match new build_role_registry behavior: no fabricated endpoints, readiness clamping, and backend_ready authoritative mapping.

tests/test_roles.py

test_smoke_duo.pyRefine smoke tests to align with updated senses and reachability expectations +321/-12

Refine smoke tests to align with updated senses and reachability expectations

• Updates smoke coverage and assertions to reflect the clarified senses intake and the new readiness/advertisement behaviors.

tests/test_smoke_duo.py

@qodo-code-review

qodo-code-review Bot commented Jul 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (2) 📎 Requirement gaps (0) 📜 Skill insights (1)

Context used
✅ Compliance rules (platform): 26 rules

Grey Divider


Action required

1. lobes capabilities adds source key ✓ Resolved 📘 Rule violation ≡ Correctness
Description
In JSON mode, lobes capabilities emits a top-level source key alongside role keys, so the
top-level object is no longer keyed strictly by role name. This violates the standardized
capabilities contract and can break strict consumers that expect only role keys at the top level.
Code

lobes/cli/_commands/capabilities.py[R217-223]

+    payload, source = _capabilities_view(args)
    if json_mode:
-        emit_result({role: _role_payload(registry[role]) for role in ROLES}, json_mode=True)
+        # "source" is an added top-level sibling of the six role keys — it never
+        # collides with a role name, so every existing consumer that reads
+        # payload[<role>] is unaffected; only a strict `set(payload) == ROLES`
+        # check needs to account for it.
+        emit_result({**payload, "source": source}, json_mode=True)
Relevance

⭐⭐⭐ High

PR #82 tests assert JSON keys exactly ROLES; adding top-level 'source' breaks established contract.

PR-#82

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The checklist requires the lobes capabilities response top-level JSON object to be keyed by role
name. The implementation explicitly injects a sibling source key at the top level via
emit_result({**payload, "source": source}, ...).

Rule 1558345: Expose standardized capabilities contract for all fleet roles
lobes/cli/_commands/capabilities.py[215-225]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`lobes capabilities --json` returns a JSON object that includes a top-level `source` field in addition to role keys. The standardized contract requires the top-level JSON object to be keyed by role name.

## Issue Context
Extra top-level keys can break strict contract checks (e.g., `set(payload.keys()) == ROLES`) and violates the standardized capabilities contract.

## Fix Focus Areas
- lobes/cli/_commands/capabilities.py[215-225]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Readiness stop spawns duplicates ✓ Resolved 🐞 Bug ☼ Reliability
Description
ReadinessCache.stop() clears self._thread after a bounded join even if the refresh thread is still
alive, so a later start() can spawn a second refresh thread while the first is still probing. This
can create overlapping refresh loops (extra probe load) and makes the cache’s thread lifecycle state
inaccurate.
Code

lobes/gateway/_readiness.py[R234-268]

+    def start(self) -> None:
+        """Start the background refresh thread (idempotent)."""
+        if self._thread is not None:
+            return
+        # Clear the stop flag so a cache restarted after stop() runs again.
+        self._stop.clear()
+        self._thread = threading.Thread(
+            target=self._loop, name="lobes-readiness-cache", daemon=True
+        )
+        self._thread.start()
+
+    def _loop(self) -> None:
+        # Probe once immediately so the snapshot populates promptly after start()
+        # (off the request path), then refresh every interval. Event.wait(interval)
+        # returns True only when stop() is set, so it both paces the refresh and
+        # exits promptly on shutdown.
+        self._refresh_once()
+        while not self._stop.wait(self._interval):
+            self._refresh_once()
+
+    def stop(self) -> None:
+        """Signal the daemon thread to exit and join it (idempotent, clean shutdown).
+
+        Safe to call before :meth:`start` (no thread yet). Joins with a bounded
+        timeout so a caller (e.g. server shutdown) gets deterministic termination
+        without hanging on a probe in flight.
+        """
+        self._stop.set()
+        thread = self._thread
+        if thread is not None:
+            # Bound the join so shutdown cannot hang on a probe still in flight;
+            # the thread is a daemon, so a (pathological) straggler never blocks
+            # interpreter exit anyway.
+            thread.join(timeout=self._timeout + 1.0)
+            self._thread = None
Relevance

⭐⭐ Medium

No historical evidence found for thread stop/join timeout clearing reference causing duplicate
background threads.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The cache’s refresh pass probes every backend sequentially, so an in-flight pass can outlast the
stop() join timeout. Because stop() clears _thread regardless, and start() only guards on
_thread is not None, the cache can start a second thread while the first is still alive.

lobes/gateway/_readiness.py[188-207]
lobes/gateway/_readiness.py[234-268]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ReadinessCache.stop()` joins the background thread with a short timeout and then unconditionally sets `self._thread = None`. If the thread is still running (e.g., mid-refresh pass), a subsequent `start()` call will see `_thread is None` and spawn a second background thread, causing overlapping refresh loops and incorrect lifecycle reporting.

### Issue Context
A single refresh pass probes all targets sequentially, so its worst-case duration scales with `len(targets) * timeout`. `stop()` currently only waits `timeout + 1.0`, which can be shorter than an in-flight pass.

### Fix Focus Areas
- lobes/gateway/_readiness.py[188-268]

### Implementation notes
- In `stop()`:
 - After `join(timeout=...)`, check `thread.is_alive()`.
 - Only set `self._thread = None` if the thread has actually exited.
 - Consider increasing the join bound to cover a full refresh pass (e.g., `len(self._targets) * self._timeout + 1.0`) or refactor probing to be interruptible.
- In `start()`:
 - Treat an existing but dead thread as restartable (e.g., if `_thread is not None and not _thread.is_alive(): self._thread = None` before creating a new one), so the cache can recover cleanly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. CLI version read via __version__ 📘 Rule violation ≡ Correctness
Description
CLI-facing code reads the CLI version via lobes.__version__ / from lobes import __version__
instead of querying importlib.metadata.version("lobes-cli"). This violates the requirement and can
hide packaging/metadata issues where the module attribute diverges from installed package metadata.
Code

lobes/cli/_commands/doctor.py[18]

+from lobes import __version__
Relevance

⭐ Low

Similar compliance suggestion to use importlib.metadata for __version__ was definitely rejected in
PR #3.

PR-#3

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rule requires CLI version reporting to use importlib.metadata.version("lobes-cli") rather than
lobes.__version__. lobes doctor imports __version__ from lobes, and scripts/live-check.sh
shells out to Python to print lobes.__version__.

Rule 1167660: Read lobes package version from importlib.metadata for lobes-cli
lobes/cli/_commands/doctor.py[15-18]
scripts/live-check.sh[105-114]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
CLI-related code must obtain the `lobes-cli` version via `importlib.metadata.version("lobes-cli")` (with an appropriate fallback), not via `lobes.__version__`.

## Issue Context
The compliance rule explicitly disallows using `lobes.__version__` as the source of truth for CLI version reporting.

## Fix Focus Areas
- lobes/cli/_commands/doctor.py[15-179]
- scripts/live-check.sh[105-114]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. live-check.sh missing CLI dependency checks 📜 Skill insight ☼ Reliability
Description
scripts/live-check.sh invokes non-core external tools (e.g., grep, sed) without validating
their presence via command -v before use. This can cause confusing failures on systems missing
these tools instead of exiting with a clear install hint.
Code

scripts/live-check.sh[R48-82]

+_usage() {
+  grep '^#' "$0" | sed -n '/^# Usage:/,/^# Exit code:/p' | head -n -1 | sed 's/^# \?//'
+  exit 0
+}
+
+while [[ $# -gt 0 ]]; do
+  case "$1" in
+    --port)        PORT="$2";        shift 2 ;;
+    --compose-dir) COMPOSE_DIR="$2"; shift 2 ;;
+    --base-url)    BASE_URL="$2";    shift 2 ;;
+    -h|--help)     _usage ;;
+    --)            shift; PYTEST_EXTRA=("$@"); break ;;
+    *) printf 'error: unknown option: %s\n' "$1" >&2; exit 2 ;;
+  esac
+done
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "${REPO_ROOT}"
+
+# ---------------------------------------------------------------------------
+# Resolve the base URL the way the CLI resolves its port:
+#   --base-url wins outright; else --port; else VLLM_PORT in the deployment's
+#   .env; else 8000. Deployment dir precedence mirrors lobes.runtime._compose
+#   (and scripts/validate-tiers.sh): --compose-dir → $LOBES_DIR →
+#   $MODEL_GEAR_DIR → ~/.lobes → ~/.model-gear.
+# ---------------------------------------------------------------------------
+_read_env_port() {
+  # Echo the VLLM_PORT value from an .env file (last assignment wins), stripping
+  # an inline "# comment", surrounding quotes, and whitespace. Empty if absent.
+  local envf="$1"
+  [[ -f "${envf}" ]] || return 0
+  grep -E '^[[:space:]]*VLLM_PORT[[:space:]]*=' "${envf}" \
+    | tail -n1 \
+    | sed -E 's/^[[:space:]]*VLLM_PORT[[:space:]]*=[[:space:]]*//; s/[[:space:]]*#.*$//; s/^"//; s/"$//; s/[[:space:]]*$//'
+}
Relevance

⭐ Low

Dependency-check strictness suggestions were definitely rejected previously (PR #70); no precedent
for command -v grep/sed.

PR-#70

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The compliance rule requires command -v <tool> checks for external CLI dependencies before use.
The script calls grep/sed in _usage() and _read_env_port() without any prior dependency
validation.

scripts/live-check.sh[48-82]
Skill: cicd

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Shell scripts must validate external CLI dependencies (non-coreutils) before invoking them, emitting a clear stderr message with install instructions and exiting non-zero when missing.

## Issue Context
`live-check.sh` uses tools like `grep` and `sed` before any dependency validation.

## Fix Focus Areas
- scripts/live-check.sh[48-82]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. live-check.sh deploy dir order wrong 📘 Rule violation ≡ Correctness
Description
scripts/live-check.sh resolves the deployment directory with $MODEL_GEAR_DIR checked before
~/.lobes, violating the required precedence order. This can cause the gate to target the wrong
deployment even when the primary ~/.lobes deployment exists.
Code

scripts/live-check.sh[R67-92]

+# ---------------------------------------------------------------------------
+# Resolve the base URL the way the CLI resolves its port:
+#   --base-url wins outright; else --port; else VLLM_PORT in the deployment's
+#   .env; else 8000. Deployment dir precedence mirrors lobes.runtime._compose
+#   (and scripts/validate-tiers.sh): --compose-dir → $LOBES_DIR →
+#   $MODEL_GEAR_DIR → ~/.lobes → ~/.model-gear.
+# ---------------------------------------------------------------------------
+_read_env_port() {
+  # Echo the VLLM_PORT value from an .env file (last assignment wins), stripping
+  # an inline "# comment", surrounding quotes, and whitespace. Empty if absent.
+  local envf="$1"
+  [[ -f "${envf}" ]] || return 0
+  grep -E '^[[:space:]]*VLLM_PORT[[:space:]]*=' "${envf}" \
+    | tail -n1 \
+    | sed -E 's/^[[:space:]]*VLLM_PORT[[:space:]]*=[[:space:]]*//; s/[[:space:]]*#.*$//; s/^"//; s/"$//; s/[[:space:]]*$//'
+}
+
+if [[ -z "${BASE_URL}" ]]; then
+  if [[ -z "${PORT}" ]]; then
+    if [[ -z "${COMPOSE_DIR}" ]]; then
+      if   [[ -n "${LOBES_DIR:-}" ]];      then COMPOSE_DIR="${LOBES_DIR}"
+      elif [[ -n "${MODEL_GEAR_DIR:-}" ]]; then COMPOSE_DIR="${MODEL_GEAR_DIR}"
+      elif [[ -d "${HOME}/.lobes" ]];      then COMPOSE_DIR="${HOME}/.lobes"
+      elif [[ -d "${HOME}/.model-gear" ]]; then COMPOSE_DIR="${HOME}/.model-gear"
+      fi
+    fi
Relevance

⭐ Low

Same deployment-dir precedence fix was definitely rejected earlier for validate-tiers.sh (PR #70).

PR-#70

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rule mandates a strict precedence order placing ~/.lobes before $MODEL_GEAR_DIR. The
script’s documented and implemented order checks $MODEL_GEAR_DIR prior to ~/.lobes (`elif [[ -n
"${MODEL_GEAR_DIR:-}" ]]; then ... before the ~/.lobes` branch).

Rule 1167742: Resolve model-ops deployment directory using explicit precedence order
scripts/live-check.sh[67-92]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Deployment directory resolution must follow the explicit precedence order:
1) `--compose-dir`
2) `$LOBES_DIR`
3) `~/.lobes`
4) `$MODEL_GEAR_DIR` (legacy)
5) `~/.model-gear` (legacy)

`scripts/live-check.sh` currently checks `$MODEL_GEAR_DIR` before `~/.lobes`.

## Issue Context
Incorrect precedence can silently point checks at a legacy deployment instead of the current default deployment.

## Fix Focus Areas
- scripts/live-check.sh[67-92]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread lobes/cli/_commands/capabilities.py Outdated
Comment thread lobes/gateway/_readiness.py Outdated
OriNachum and others added 7 commits July 9, 2026 13:59
reachable_origin echoed the request Host header into every role's
endpoint field unsanitised — a client-controlled value (path traversal,
script markup, userinfo-style credential injection like
127.0.0.1:8001@attacker.test) reflected straight into the /capabilities
JSON body, flagged as a SonarCloud BLOCKER (pythonsecurity:S5131).

Add a strict host-authority allowlist regex (hostname/IPv4 or bracketed
IPv6, optional :port) and gate the Host-header echo behind it. A Host
that fails validation degrades to None, same as no Host header at all,
so the endpoint comes back empty rather than reflecting attacker input.
GATEWAY_PUBLIC_URL (trusted operator config) is untouched and keeps
winning first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
stop() joined the background refresh thread with a bounded timeout and
then unconditionally cleared self._thread. Since a single refresh pass
probes every target sequentially (worst case len(targets) * timeout),
a slow probe / short timeout could outlast the join, leaving the
thread alive while self._thread was already nulled. A later start()
then saw _thread is None and spawned a second, overlapping refresh
thread — extra probe load and inaccurate lifecycle state (Qodo finding
on PR #102).

- stop() only clears self._thread when the join actually observes the
  thread exit; otherwise it leaves the reference in place so a live
  thread is never orphaned. The join bound is also widened to
  len(targets) * timeout + 1.0 so a clean shutdown normally completes
  within the call instead of racing it.
- start() now treats an existing-but-dead thread reference as
  restartable (clears it before spawning a fresh thread) but still
  refuses to spawn a second thread while one is genuinely alive.
- Added tests/test_gateway_readiness.py coverage that reproduces the
  race with an Event-blocked probe (fails against the old stop()),
  proves start() after an incomplete stop() never creates a second
  live thread, proves a stale-but-dead reference is replaced on
  restart, and proves stop() stays non-hanging and idempotent under
  the race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
Qodo action-required finding on PR #102: t7 added a top-level `source`
sibling next to the six role keys, so `lobes capabilities --json` no
longer matched the gateway's own `GET /capabilities` shape
byte-for-byte — a strict `set(payload.keys()) == ROLES` check broke,
ironic given t7's whole point was making the CLI and gateway agree.

`--json` output is now the bare six-role dict in every mode, verbatim
in gateway mode and identically shaped in the offline fallback. The
offline/gateway distinction moves out-of-band: the human table keeps
its `# source: ...` header, and `--json` mode writes a one-line
offline notice to stderr (never into the stdout JSON object) only
when it degrades to the `.env` fallback.

Updates the CLI-and-gateway-agree live gate (test_live_capabilities.py)
to detect the offline fallback via stderr instead of the now-removed
`source` key, and adds a dedicated regression test asserting gateway-mode
JSON keys equal exactly the six role names.
- Security (S5131 BLOCKER): validate the Host header against a strict
  host-authority allowlist before advertising it as an origin. The c29 change
  reflected unsanitized client input into every role's /capabilities endpoint;
  an invalid host now yields an empty endpoint, like an absent header. The
  operator override GATEWAY_PUBLIC_URL is unaffected. +13 tests.
- Qodo (correctness): `lobes capabilities --json` top level is keyed strictly by
  role; the live/offline signal moved from a top-level `source` key to a stderr
  notice, so the CLI payload is byte-identical to the gateway's and a
  set(keys)==ROLES consumer never trips. +1 test.
- Qodo (reliability): ReadinessCache.stop() only clears its thread reference
  once the thread has actually exited, and start() refuses to spawn a second
  live thread — no overlapping refresh loops. +4 tests.

Pre-existing realtime/* code smells Sonar surfaced are out of scope (not in this
PR's diff; last touched 2026-05-31 / 06-21) and are left untouched.

1187 passed, 14 skipped. black/isort/flake8/bandit clean; afi doctor 26/26.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
@OriNachum

Copy link
Copy Markdown
Contributor Author

Review round — SonarCloud + Qodo triage (48954ff)

Three findings addressed, one class pushed back. 1187 passed, 14 skipped; black/isort/flake8/bandit clean; afi cli doctor . --strict 26/26.

Fixed

  • SonarCloud pythonsecurity:S5131 (BLOCKER)lobes/gateway/server.py. This one was ours, and it was pointed: the c29 origin change echoed an unsanitized, client-controlled Host header straight into every role's advertised endpoint. Confirmed live before the fix:

    $ curl -s -H 'Host: evil.example/../<script>' :8001/capabilities | jq '.cortex.endpoint'
    "http://evil.example/../<script>"
    $ curl -s -H 'Host: 127.0.0.1:8001@attacker.test' :8001/capabilities | jq '.cortex.endpoint'
    "http://127.0.0.1:8001@attacker.test"

    A PR whose thesis is "never advertise a capability you can't serve" was advertising http://evil.example on command. The Host header is now validated against a strict host-authority allowlist (DNS hostname / IPv4 / bracketed IPv6, optional :port) before it is echoed; anything else resolves to an empty endpoint, exactly as an absent header does. The operator override GATEWAY_PUBLIC_URL is trusted config and is unaffected. +13 tests, including an end-to-end check that a malicious Host yields empty endpoints through the real HTTP handler.

  • Qodo — lobes capabilities --json added a top-level source key. The top level is role-keyed again (byte-identical to the gateway); the live/offline signal moved to stderr. Replied on the thread.

  • Qodo — ReadinessCache.stop() could spawn a duplicate refresh thread. stop() now only clears its reference once the thread has exited; start() never spawns a second live thread. Replied on the thread.

Pushback — the 8 realtime/* findings are out of scope

SonarCloud's full-project scan also surfaces 8 CODE_SMELLs in lobes/realtime/tts_client.py, app.py, and _settings.py (S3776 cognitive complexity, S8513 chained endswith, S8786 regex backtracking, S8410 Annotated hints, S8572 logging.exception). None of those files are in this PR's diff — git blame dates the flagged lines to 2026-05-31 and 2026-06-21. They are pre-existing debt on main, not introduced here, and they do not affect the new-code quality gate (which was failing only on the S5131 blocker above, now fixed). I've left them untouched rather than fold an unrelated realtime refactor into a gateway/CLI PR; they deserve their own issue if they're worth acting on.

  • lobes (Claude)

OriNachum and others added 3 commits July 9, 2026 15:12
ReadinessCache seeds every backend to None via a constant-value dict
comprehension; SonarCloud flags this as S7519 (prefer dict.fromkeys).
None is immutable so there's no shared-mutable-default hazard here.
S8714 (test try/except in tests/test_live_capabilities.py) accepted in SonarCloud
with rationale: it is a unit-test rule applied to an operator-facing live gate
that consumes an arbitrary base URL, where the caught body preview is the
diagnostic that reveals an operator dialing the wrong endpoint (200 + HTML).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrghbUdRuVa2Y98EV8RHQg
@sonarqubecloud

sonarqubecloud Bot commented Jul 9, 2026

Copy link
Copy Markdown

@OriNachum

Copy link
Copy Markdown
Contributor Author

SonarCloud triage — 2 remaining issues (6354726)

Both were MINOR code smells in this PR's new code. PR gate is now OK with zero open issues.

  • python:S7519 (lobes/gateway/_readiness.py:169) — fixed. Seeded the initial per-backend snapshot with dict.fromkeys(self._targets, None) instead of a dict comprehension. Safe here (the fill value is the immutable None, so no shared-mutable-default trap). Already pinned by test_seed_is_all_unknown_and_construction_opens_no_socket.

  • python:S8714 (tests/test_live_capabilities.py:300, "remove the try/except and let the test fail naturally") — accepted in SonarCloud with rationale. This is a unit-test hygiene rule, and the flagged code is in an operator-facing live gate that consumes an arbitrary base URL (LOBES_SMOKE_BASE_URL / --base-url). The block catches a JSON parse failure and re-raises via pytest.fail with the response body dumped in. That body is the diagnostic: the realistic failure isn't our gateway misbehaving (it always returns JSON on a 200 /capabilities) but an operator pointing the gate at the wrong port or a proxy that answers 200 + an HTML error page — where a bare JSONDecodeError: Expecting value: line 1 column 1 discards the one fact (what the body actually was) that reveals they're dialing the wrong thing. Every other failure in this gate follows the same fail-loud-with-actionable-context design; keeping this one consistent is deliberate.

  • lobes (Claude)

@OriNachum

Copy link
Copy Markdown
Contributor Author

Diverse review — second model, clean pass

Ran an independent review of main...HEAD on a different backend (local vLLM Qwen3.6-27B, via colleague — a second mind, not a stronger one) focused on the five load-bearing changes: the Host-header validation, the 503/404/502 decision, the ReadinessCache thread lifecycle, ready-vs-dead-backend clamping, and the CLI offline fallback.

Verdict: no bugs. It independently confirmed each control:

  • reachable_origin uses re.fullmatch() — properly anchored, not bypassable with a prefix/suffix; public_url (trusted operator config) correctly bypasses validation.
  • The status tree is sound: unknown model → 404, empty order_backends → 502, owner 5xx/refused/timeout → 503, owner 2xx/4xx → relay verbatim. The routing-table-unknown (404) vs owner-down (503) distinction holds.
  • ReadinessCache.start()/stop() maintain the single-live-thread invariant; current() returns a locked copy, socket-free.
  • The offline CLI path forces ready=False for every role — a config file is never a readiness claim.

One observation worth recording (pre-existing, out of scope): read_chunked_body (lobes/gateway/server.py, last touched in #16, not in this PR's diff) loops while len(body) <= max_bytes, so the body can reach max_bytes + one chunk before the cap fires — the 64 MB bound can be overshot by a single chunk. Minor, not a practical DoS (the client still has to send the bytes), and it belongs to the request-parser, not this PR. Noting it here rather than folding an unrelated fix into a focused change.

  • lobes (Claude)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment