Skip to content

Fallback model + single front OpenAI gateway (3-container fleet) - #16

Merged
OriNachum merged 5 commits into
mainfrom
feat/fleet-gateway-fallback
May 28, 2026
Merged

Fallback model + single front OpenAI gateway (3-container fleet)#16
OriNachum merged 5 commits into
mainfrom
feat/fleet-gateway-fallback

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

What

Adds the two joined features requested: a fallback model and a single front OpenAI API that fronts both, with model-gear managing all three containers.

model init --fleet scaffolds a 3-container deployment — two always-warm vLLM backends (a dense primary + an MoE fallback) behind one stdlib gateway — as model-gear-gateway / model-gear-vllm-primary / model-gear-vllm-fallback. The gateway listens on the host port acp already expects (8000) and:

  • routes each request by its model field (plus GATEWAY_ALIASES),
  • defaults an unknown/missing model to the primary (so existing single-model clients keep working),
  • fails over to the other backend when the chosen one refuses the connection or returns a 5xx before the response body (4xx returned verbatim; no mid-stream retry),
  • relays SSE streams chunk-by-chunk, lists both models on /v1/models, and exposes /health.

Both models stay loaded (no swap, no queue) — the design follows the confirmed decisions: custom stdlib gateway (not llama-swap/LiteLLM), both warm, default + failover + name routing.

Design decisions (confirmed up front)

Question Choice
Gateway impl Custom stdlib (http.server + http.client, zero runtime deps)
Fallback role default model + failover-on-error + 2nd addressable model
Lifecycle Both warm, route only (each capped via *_GPU_MEM_UTIL)
Default fallback mmangkad/Qwen3.6-35B-A3B-NVFP4 (MoE, ~3B active → fast decode)

Changes

  • New model_gear/gateway/_routing.py (pure name/alias/default + failover ordering), _config.py (env → routing table + server config), server.py (the handle_post failover seam, open_upstream http.client, ThreadingHTTPServer handler), run as python -m model_gear.gateway.
  • New verbsmodel init --fleet (pins MODEL_GEAR_VERSION to the running release) and model fleet up | down | status (up/down dry-run by default; status read-only, reports all 3 containers + gateway /health + /v1/models).
  • Fleet templatesmodel_gear/templates/fleet/{docker-compose.yml,env.example,Dockerfile.gateway}. Single-model deployment is unchanged and remains the default (added, not replaced).
  • _compose.py — template registry (SINGLE_TEMPLATES / FLEET_TEMPLATES) + templates= arg (existing callers unchanged), compose_up_build, FLEET_CONTAINERS.
  • Fleet .env mirrors VLLM_* (= primary) so status / whoami / doctor stay coherent. model switch stays single-model only (documented).
  • Docsdocs/gateway-fleet.md, docs/qwen3.6-35b-a3b-nvfp4.md, README fleet section, model explain fleet / model explain gateway.

Testing

  • 36 new tests: pure routing/config/body helpers, handle_post failover matrix (refused → fallback, 5xx → fallback, 4xx → no failover, both down → 502, default/alias routing + body rewrite), a loopback integration covering the handler relay + chunked SSE framing + open_upstream, and the fleet CLI verbs + init --fleet.
  • Full suite: 125 passed; coverage 90% (gateway package 94–100%).
  • black / isort / flake8 / bandit clean; afi cli doctor . --strict passes; markdownlint clean. Version bumped 0.8.1 → 0.9.0.

Not done here (out of band)

Live validation on the DGX Spark — model fleet up --apply, confirm both backends co-resident without OOM (the 0.40/0.35 GPU_MEM_UTIL split is an estimate), and confirm the mmangkad checkpoint's --quantization / --tool-call-parser. The fallback per-model doc marks its benchmark table pending rather than fabricating numbers.

🤖 Generated with Claude Code

  • Claude

`model init --fleet` scaffolds a three-container deployment — two always-warm
vLLM backends (a dense primary + an MoE fallback) behind one stdlib gateway —
managed by model-gear as `model-gear-gateway` / `model-gear-vllm-primary` /
`model-gear-vllm-fallback`. The gateway fronts both models on the host port acp
already expects (8000): it routes each request by its `model` field, defaults an
unknown/missing name to the primary, and fails over to the other backend when the
chosen one refuses the connection or returns a 5xx before the response body (4xx
verbatim; no mid-stream retry). SSE streams are relayed chunk-by-chunk.

- New `model_gear/gateway/` package (pure stdlib http.server + http.client, no
  runtime deps): `_routing.py` (routing/failover order), `_config.py` (env →
  config), `server.py` (`handle_post` failover seam + upstream client + handler).
- New verbs: `model init --fleet` (pins MODEL_GEAR_VERSION to the running
  release) and `model fleet up | down | status` (up/down dry-run by default).
- `_compose.py`: template registry (SINGLE/FLEET) + `templates=` arg (single-model
  stays default), `compose_up_build`, `FLEET_CONTAINERS`.
- Fleet `.env` mirrors VLLM_* (= primary) so status/whoami/doctor stay coherent;
  `model switch` remains single-model only.
- Docs: docs/gateway-fleet.md, docs/qwen3.6-35b-a3b-nvfp4.md, README fleet
  section, `model explain fleet` / `gateway` entries.
- Tests: 36 new (routing/config/body helpers, handle_post failover matrix,
  loopback relay + chunked streaming, open_upstream, fleet verbs, init --fleet).
  Full suite 125 passed; coverage 90%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Review Summary by Qodo

Fallback model + single front OpenAI gateway (3-container fleet)

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add fallback model + single front OpenAI gateway (3-container fleet deployment)
• New model fleet up|down|status verbs to manage the gateway deployment
• New model init --fleet scaffolds 3-container templates with gateway Dockerfile
• Pure-stdlib gateway package with routing, failover, and streaming support
• Comprehensive docs and 36 new tests covering routing, failover, and integration
Diagram
flowchart LR
  A["Client requests<br/>on :8000"] -->|"model field<br/>routing"| B["model-gear-gateway<br/>stdlib reverse proxy"]
  B -->|"primary or<br/>failover"| C["vllm-primary<br/>Qwen3-32B"]
  B -->|"fallback or<br/>failover"| D["vllm-fallback<br/>Qwen3.6-35B-A3B"]
  E["model init --fleet"] -->|"scaffolds"| F["docker-compose.yml<br/>.env<br/>Dockerfile.gateway"]
  G["model fleet up/down/status"] -->|"manages"| B

Loading

Grey Divider

File Changes

1. model_gear/gateway/__init__.py ✨ Enhancement +19/-0

New gateway package public API

model_gear/gateway/init.py


2. model_gear/gateway/__main__.py ✨ Enhancement +19/-0

Gateway container entrypoint

model_gear/gateway/main.py


3. model_gear/gateway/_routing.py ✨ Enhancement +75/-0

Pure routing and failover logic

model_gear/gateway/_routing.py


View more (23)
4. model_gear/gateway/_config.py ✨ Enhancement +83/-0

Environment to config conversion

model_gear/gateway/_config.py


5. model_gear/gateway/server.py ✨ Enhancement +355/-0

HTTP server with failover and streaming

model_gear/gateway/server.py


6. model_gear/cli/__init__.py ✨ Enhancement +2/-0

Register fleet command in CLI

model_gear/cli/init.py


7. model_gear/cli/_commands/fleet.py ✨ Enhancement +181/-0

New fleet up/down/status verbs

model_gear/cli/_commands/fleet.py


8. model_gear/cli/_commands/init.py ✨ Enhancement +37/-11

Add --fleet flag to init command

model_gear/cli/_commands/init.py


9. model_gear/cli/_commands/learn.py 📝 Documentation +8/-1

Document fleet commands in help

model_gear/cli/_commands/learn.py


10. model_gear/cli/_commands/overview.py 📝 Documentation +3/-1

Add fleet to capabilities list

model_gear/cli/_commands/overview.py


11. model_gear/explain/catalog.py 📝 Documentation +64/-1

Add fleet and gateway explain entries

model_gear/explain/catalog.py


12. model_gear/runtime/_compose.py ✨ Enhancement +52/-10

Template registry and fleet support

model_gear/runtime/_compose.py


13. model_gear/templates/fleet/__init__.py ✨ Enhancement +6/-0

Fleet templates package marker

model_gear/templates/fleet/init.py


14. model_gear/templates/fleet/docker-compose.yml ✨ Enhancement +144/-0

Fleet docker-compose with 3 services

model_gear/templates/fleet/docker-compose.yml


15. model_gear/templates/fleet/env.example ✨ Enhancement +56/-0

Fleet environment configuration template

model_gear/templates/fleet/env.example


16. model_gear/templates/fleet/Dockerfile.gateway ✨ Enhancement +15/-0

Gateway container Dockerfile

model_gear/templates/fleet/Dockerfile.gateway


17. tests/test_cli.py 🧪 Tests +9/-1

Update CLI tests for fleet verbs

tests/test_cli.py


18. tests/test_cli_fleet.py 🧪 Tests +119/-0

New fleet command integration tests

tests/test_cli_fleet.py


19. tests/test_gateway_routing.py 🧪 Tests +155/-0

Pure routing and config unit tests

tests/test_gateway_routing.py


20. tests/test_gateway_server.py 🧪 Tests +258/-0

Gateway server failover and integration tests

tests/test_gateway_server.py


21. tests/test_init.py 🧪 Tests +36/-0

Add fleet scaffold tests

tests/test_init.py


22. docs/gateway-fleet.md 📝 Documentation +107/-0

Fleet topology and operations guide

docs/gateway-fleet.md


23. docs/qwen3.6-35b-a3b-nvfp4.md 📝 Documentation +82/-0

MoE fallback model documentation

docs/qwen3.6-35b-a3b-nvfp4.md


24. README.md 📝 Documentation +33/-0

Add fleet section and model docs

README.md


25. CHANGELOG.md 📝 Documentation +42/-0

Document 0.9.0 fleet release

CHANGELOG.md


26. pyproject.toml ⚙️ Configuration changes +1/-1

Bump version to 0.9.0

pyproject.toml


Grey Divider

Qodo Logo

@qodo-code-review

qodo-code-review Bot commented May 28, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Docs reference ~/.model-gear 📘 Rule violation ⚙ Maintainability
Description
New documentation includes a per-user dotfile path (~/.model-gear), which is disallowed outside
the specified carve-outs. This reduces portability and violates the doc/config dotfile reference
policy.
Code

README.md[91]

Evidence
PR Compliance ID 796119 prohibits per-user dotfile references matching ~/\.[A-Za-z] in .md files
except for narrow carve-outs. The added README and fleet docs include ~/.model-gear, which
triggers the forbidden pattern and is not an allowed carve-out.

Rule 796119: No per-user dotfile config references in docs/configs (with specified carve-outs)
README.md[90-92]
docs/gateway-fleet.md[79-81]

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

## Issue description
Docs contain `~/.model-gear`, which matches the forbidden per-user dotfile path pattern `~/\.[A-Za-z]` and is not covered by the allowed carve-outs.

## Issue Context
Rule allows only `~/.claude/skills/<name>/scripts/` and `~/.culture/` dotfile references in eligible doc/config formats. For model-gear deployment paths, use portable references like `$MODEL_GEAR_DIR` and/or `$HOME/.model-gear` (avoid the `~/...` form).

## Fix Focus Areas
- README.md[90-95]
- docs/gateway-fleet.md[79-82]

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


2. Chunked body ignored ✓ Resolved 🐞 Bug ≡ Correctness
Description
The gateway only reads POST bodies via Content-Length, so HTTP/1.1 requests sent with
Transfer-Encoding: chunked are treated as empty and forwarded incorrectly (default routing + invalid
JSON to upstream). This can silently misroute requests or cause upstream 4xx/parse errors for
clients/proxies that legitimately use chunked request bodies.
Code

model_gear/gateway/server.py[R301-304]

Evidence
The handler reads the body solely from Content-Length; if it’s missing (as with chunked), the body
becomes b"" and the routing/model extraction logic operates on an empty payload.

model_gear/gateway/server.py[279-304]
model_gear/gateway/server.py[216-220]

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

## Issue description
`_Handler._read_body()` reads only `Content-Length`, so requests that use `Transfer-Encoding: chunked` (valid in HTTP/1.1) are treated as empty bodies.

## Issue Context
The gateway declares `HTTP/1.1` and acts as a reverse proxy; it should either decode chunked request bodies or fail fast with a clear error (e.g., 411 Length Required / 400) instead of silently forwarding an empty payload.

## Fix Focus Areas
- model_gear/gateway/server.py[301-304]
- model_gear/gateway/server.py[279-288]

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



Remediation recommended

3. Unvalidated alias/default 🐞 Bug ☼ Reliability
Description
build_config() accepts GATEWAY_DEFAULT_MODEL and GATEWAY_ALIASES targets without validating
they match any backend’s served_name; when they don’t, the gateway rewrites the forwarded JSON
body’s model to an unserved name and upstream returns a 4xx that is intentionally not retried.
This makes a small env typo manifest as confusing runtime 400s rather than a clear startup/config
error.
Code

model_gear/gateway/_config.py[R62-76]

Evidence
build_config() sets default_model and aliases from env without checks; resolve_model()
returns alias targets verbatim; handle_post() rewrites the JSON model to that value and returns
4xx without failover, so invalid config becomes hard failures.

model_gear/gateway/_config.py[62-76]
model_gear/gateway/_routing.py[31-43]
model_gear/gateway/server.py[216-246]
model_gear/gateway/server.py[79-90]

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

## Issue description
Gateway routing config does not validate that `default_model` and alias targets exist in `table.backends[*].served_name`.

## Issue Context
`handle_post()` rewrites the request body’s `model` to the resolved served name and commits any 4xx (no failover by design). Invalid config therefore produces deterministic 4xx responses.

## Fix Focus Areas
- model_gear/gateway/_config.py[62-76]
- model_gear/gateway/_routing.py[31-64]
- model_gear/gateway/server.py[216-246]

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


4. Gateway health not verified 🐞 Bug ≡ Correctness
Description
model fleet up waits for /health using a generic 2xx probe; because vLLM backends also expose
/health, the command can declare success even if something other than the gateway is serving on
that port. This can lead to false-positive “gateway up” output and misleading fleet status in
environments where the port is already occupied.
Code

model_gear/cli/_commands/fleet.py[R62-83]

Evidence
cmd_fleet_up() waits on _health.wait_health(), which returns immediately on any 2xx from
/health and does not validate the payload or container state before returning success.

model_gear/cli/_commands/fleet.py[62-83]
model_gear/runtime/_health.py[17-43]

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

## Issue description
`model fleet up` treats any 2xx from `GET /health` as readiness, which is not specific to the gateway.

## Issue Context
The gateway’s `/health` response body includes gateway-identifying fields; vLLM also responds 2xx on `/health`. The readiness check should confirm it is talking to the gateway (or at least that the gateway container is running) before printing success.

## Fix Focus Areas
- model_gear/cli/_commands/fleet.py[62-83]
- model_gear/runtime/_health.py[17-43]

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


5. Bad backend URL crashes ✓ Resolved 🐞 Bug ☼ Reliability
Description
open_upstream() assumes urlsplit(base_url) yields a hostname; if PRIMARY_URL/FALLBACK_URL is
malformed (e.g., missing scheme), parts.hostname can be None and HTTPConnection(None, ...)
raises a non-OSError exception that bypasses UpstreamError handling and can crash the request
handler. This turns a configuration mistake into runtime 500s and thread crashes instead of a clean
502 failover response.
Code

model_gear/gateway/server.py[R152-171]

Evidence
open_upstream() uses parts.hostname directly when constructing the connection and only catches
OSError around connect()/request(), so malformed URLs can raise before those try/except blocks
and bypass failover.

model_gear/gateway/server.py[152-171]
model_gear/gateway/_config.py[62-71]

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

## Issue description
`open_upstream()` can raise uncaught exceptions (e.g., `TypeError`) when `backend.base_url` is malformed and `urlsplit()` produces `hostname=None`.

## Issue Context
`handle_post()` only catches `UpstreamError` to perform failover. Any other exception escapes and can crash the handler for that request.

## Fix Focus Areas
- model_gear/gateway/server.py[152-171]
- model_gear/gateway/_config.py[62-71]

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


Grey Divider

Qodo Logo

Comment thread README.md
Comment thread model_gear/gateway/server.py
- Dockerfile.gateway: pin the model-gear install unconditionally
  (`==${MODEL_GEAR_VERSION}`, no unpinned "latest" fallback) so the image is
  reproducible (docker:S8544), and run as a non-root `gateway` user (clears the
  root-user security hotspot). MODEL_GEAR_VERSION is now required; init --fleet
  fills it and dev boxes set a TestPyPI .devN.
- fleet.py: restructure cmd_fleet_up / cmd_fleet_down to a single return path
  (python:S3516) and hoist the repeated --json / --port help strings into
  _JSON_HELP / _PORT_HELP constants (python:S1192).
- Docs/env.example updated for the required pinned version.

The remaining new-code hotspots are the internal-network `http://vllm-*` URLs
(no TLS between sibling compose containers, by design) — reviewed as safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`_read_body` honored only Content-Length, so a valid HTTP/1.1
`Transfer-Encoding: chunked` request body was forwarded empty (misrouted to the
default + invalid JSON upstream). Add a stdlib `read_chunked_body` decoder
(ignores chunk extensions, caps total size) and use it when Content-Length is
absent but the request is chunked. Adds a unit test for the decoder and a
loopback integration test posting a chunked body through the gateway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`open_upstream` caught only OSError, so a malformed `base_url` — e.g. a
non-numeric port (`parts.port` raises ValueError) or a bad host/path
(`http.client.InvalidURL`) — propagated as an uncaught 500 instead of failing
over. Wrap the parse + connect + request in one guard that maps
(OSError, http.client.HTTPException, ValueError) to UpstreamError, so a
misconfigured backend fails over (or 502s) cleanly. Adds a test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@OriNachum

Copy link
Copy Markdown
Contributor Author

Thanks for the review — addressed the rest of the findings:

  • docs: add CLAUDE.md dev guide grounded in sibling pattern #2 Chunked request body — fixed (read_chunked_body now decodes a Transfer-Encoding: chunked request body when there's no Content-Length, instead of forwarding an empty payload). Commit ccfc475, with unit + loopback tests.

  • Scaffold lepenseur as a full CLI/PyPI AgentCulture sibling (#1) #3 Bad backend URL crashes — fixed (open_upstream now maps (OSError, http.client.HTTPException, ValueError) to UpstreamError, so a malformed base_url — e.g. a non-numeric port — fails over instead of 500ing). Commit 56a7f5d, with a test.

  • Vendor the devague workflow trio — think / spec-to-plan / assign-to-workforce #4 Unvalidated alias/default — pushing back. GATEWAY_DEFAULT_MODEL defaults to the primary's served name (always valid); aliases are operator config, and a target no backend serves surfaces as a clear 404 from the backend (4xx is returned verbatim, no failover — by design). Startup validation is a reasonable future hardening but isn't needed for correctness.

  • Add vLLM docker-compose and switch runtime model to Qwen3-32B-NVFP4 #5 Backend health not verified on fleet up — intentional and documented. model fleet up waits only for the gateway /health (up in seconds; it routes and fails over per request). The vLLM backends load in the background (first-run weight download can take many minutes); blocking up on backend health would stall the command. model fleet status reports each backend's state + the gateway /v1/models. See docs/gateway-fleet.md.

  • model-gear (Claude)

The pip-install and useradd both run as root before the USER switch, so there's
no reason for two image layers. Combine them with `&&`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant