Skip to content

dev: local-stack — full tier-routing dev environment (real CPU engine, real token counts) - #862

Open
PierreLeGuen wants to merge 1 commit into
mainfrom
local-dev-stack
Open

dev: local-stack — full tier-routing dev environment (real CPU engine, real token counts)#862
PierreLeGuen wants to merge 1 commit into
mainfrom
local-dev-stack

Conversation

@PierreLeGuen

Copy link
Copy Markdown
Contributor

Stacked on #860 (rebase onto main once it merges). Adds dev/local-stack/: a reusable one-machine environment that runs the real cloud-api against a real inference engine on CPU with the GLM-5.2-style two-tier context topology — no staging, no GPUs, no TEE.

curl ─► cloud-api :13000 (cargo run, mock auth, ephemeral signing keys)
           ├─ base tier ─► tier_shim :18100 (ctx 1000) ──┐
           └─ long tier ─► tier_shim :18101 (ctx 8000) ──┴─► llama.cpp :18090
         postgres :15432                     (docker, CPU, Qwen2.5-0.5B GGUF)

make up && make api && make seed && make demo — the demo prints an 8-check table, all green on this machine:

  • small → +base (real Qwen reply; long tier sees zero traffic)
  • oversize (~3,000 REAL tokens) → +long
  • boundary (~800 real tokens, byte-heuristic ambiguous) → exact /v1/tokenize count decides — shim log shows count=801 keeping it on base where the heuristic alone (~1,201) would have mis-routed
  • streaming oversize → long over SSE
  • touch SATURATE-long → oversize returns retryable 429, never a context-400 (the feat: context-length tier routing (262k fleet vs 1M tier, best-effort Chutes overflow) #860 clobber fix, observable with curl)

The tier shims emulate the per-tier SGLang fleets faithfully: vLLM/SGLang-shaped /v1/tokenize backed by the engine's real tokenizer, SGLang-phrased context-window 400s (the fall-through matcher fires exactly as in prod), .model tier tags, saturation toggle. Engine-agnostic: swap a vLLM-CPU image into the compose file and nothing else changes.

make proxy-demo drives the REAL model-proxy binary (checkout path via MODEL_PROXY_DIR) through the long-context registration flow — dual same-IP probes sharing one routed backend, the probe-cleanup ownership guard, and the health-gated stub + non-2xx breaker. 9/9 against nearai/model-proxy#42; against unpatched main the shared backend measurably drops to total_backends: 0 for a full discovery cycle on probe unregister (the #42 repro).

README documents what is intentionally NOT local: the TLS/SNI data path through model-proxy (providers fail closed on unattested HTTPS — the TEE trust model working) and the Chutes wire client (ML-KEM + TDX; covered at the pool boundary by the e2e suite in #860).

No src/ changes — tooling only.

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review — dev/local-stack (tooling-only, no src/ changes)

Read the full diff across all 11 files. This is self-contained dev tooling with no production code, migrations, or API surface touched, so the production-safety / DB / concurrency priority checks are N/A. The scripts are well-structured (isolated ports, 127.0.0.1 binds, set -euo pipefail where mutation matters, -uo for the read-only demo table). No critical blockers.

A few minor correctness notes worth a look before merge — none blocking:

  • model-proxy-demo/run.sh:475 — misleading log + dead variable. The say prints + fake engine (:18010) but nothing is ever started on :18010; ENGINE_PID is referenced in cleanup_all (line 466) but never assigned. The health gate is actually driven by probe_stub.py via the ENGINE_DOWN file, so the engine line is vestigial. Suggest dropping the :18010 phrasing and the unused ENGINE_PID to avoid confusing anyone debugging the demo.

  • tier_shim.py:713/v1/tokenize only reads prompt/content, not messages. count_tokens(payload.get("prompt") or payload.get("content") or "") silently counts 0 if cloud-api ever sends a messages-shaped tokenize body. The PR description shows count=801 in the shim log, so the current cloud-api caller clearly sends a field this handles — but a future caller-format change would degrade to a silent count=0 and mis-route the boundary case rather than failing loudly. Consider falling back to request_text(payload) when both keys are absent.

  • demo.sh:172 / seed.sh — prompts interpolated straight into JSON string bodies. Safe for the fixed hello-repeat prompts here (no quotes/backslashes), just fragile if anyone edits the prompt strings to include a ". A jq -n --arg build would be sturdier, but not needed for the current fixed inputs.

Verified good: port isolation vs. the e2e 5432 postgres, .gitignore covers .api-key / SATURATE-* / *.pid / *.log, saturate-file path agrees between tier_shim.py state-dir default and demo.sh/Makefile (touch SATURATE-long in the stack dir), and docker exec ... psql seed insert is a fixed-UUID upsert (no injection surface).

✅ Approved — tooling-only, non-blocking notes above.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a local development stack (dev/local-stack) to test the two-tier context routing of cloud-api against a CPU-based llama.cpp engine. It includes Docker Compose configurations, seeding and demo scripts, and a Python-based tier shim to emulate SGLang fleets. The review feedback highlights several key improvements for the local stack's robustness: flushing the HTTP response stream immediately in the tier shim to support real-time SSE streaming, properly catching and propagating upstream HTTP errors instead of masking them as internal server errors, and implementing robust trap-based cleanups in the shell scripts to prevent temporary state files like ENGINE_DOWN and SATURATE-long from persisting upon interruption.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +150 to +152
while chunk := r.read(8192):
self.wfile.write(chunk)
sent += len(chunk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Python's http.server, self.wfile is buffered by default. For streaming/SSE responses, chunks written to self.wfile must be flushed immediately using self.wfile.flush(). Otherwise, the response will be buffered and sent all at once when the stream finishes, defeating the purpose of real-time streaming.

Suggested change
while chunk := r.read(8192):
self.wfile.write(chunk)
sent += len(chunk)
while chunk := r.read(8192):
self.wfile.write(chunk)
self.wfile.flush()
sent += len(chunk)

import os
import sys
import threading
import urllib.request

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import urllib.error to handle HTTP errors from the upstream engine properly.

Suggested change
import urllib.request
import urllib.error
import urllib.request

Comment on lines +99 to +115
def do_POST(self):
try:
if self.path == "/v1/tokenize":
payload = json.loads(self._read_body())
n = count_tokens(payload.get("prompt") or payload.get("content") or "")
log(f"POST /v1/tokenize -> count={n}")
self._json(200, {"count": n})
elif self.path == "/v1/chat/completions":
self.handle_completion()
else:
self._json(404, {"error": {"message": f"no route {self.path}"}})
except Exception as e: # keep the shim alive; surface as a 500
log(f"ERROR {self.path}: {e}")
try:
self._json(500, {"error": {"message": str(e)}})
except Exception:
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When the upstream engine returns a non-2xx error (such as a 400 Bad Request or 422 Unprocessable Entity), urllib.request.urlopen raises a urllib.error.HTTPError. Currently, this is caught by the generic except Exception block and mapped to a 500 Internal Server Error, masking the original status code and JSON error body. Catching urllib.error.HTTPError explicitly and propagating the status code and response body back to the client will greatly improve debugging and API compatibility.

    def do_POST(self):
        try:
            if self.path == "/v1/tokenize":
                payload = json.loads(self._read_body())
                n = count_tokens(payload.get("prompt") or payload.get("content") or "")
                log(f"POST /v1/tokenize -> count={n}")
                self._json(200, {"count": n})
            elif self.path == "/v1/chat/completions":
                self.handle_completion()
            else:
                self._json(404, {"error": {"message": f"no route {self.path}"}})
        except urllib.error.HTTPError as e:
            log(f"HTTPError {self.path}: {e.code}")
            self.send_response(e.code)
            self.send_header("Content-Type", e.headers.get("Content-Type", "application/json"))
            body = e.read()
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except Exception as e:  # keep the shim alive; surface as a 500
            log(f"ERROR {self.path}: {e}")
            try:
                self._json(500, {"error": {"message": str(e)}})
            except Exception:
                pass

Comment on lines +30 to +34
cleanup_all() {
[ -n "${MP_PID:-}" ] && kill "$MP_PID" 2>/dev/null || true
[ -n "${STUB_PID:-}" ] && kill "$STUB_PID" 2>/dev/null || true
[ -n "${ENGINE_PID:-}" ] && kill "$ENGINE_PID" 2>/dev/null || true
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the script fails or is interrupted after touch ENGINE_DOWN but before rm -f ENGINE_DOWN, the ENGINE_DOWN file will persist in the directory. This will cause subsequent runs of the script to fail because the engine will be considered down from the start. Adding rm -f ENGINE_DOWN to the cleanup_all trap function ensures it is always cleaned up on exit.

Suggested change
cleanup_all() {
[ -n "${MP_PID:-}" ] && kill "$MP_PID" 2>/dev/null || true
[ -n "${STUB_PID:-}" ] && kill "$STUB_PID" 2>/dev/null || true
[ -n "${ENGINE_PID:-}" ] && kill "$ENGINE_PID" 2>/dev/null || true
}
cleanup_all() {
[ -n "${MP_PID:-}" ] && kill "$MP_PID" 2>/dev/null || true
[ -n "${STUB_PID:-}" ] && kill "$STUB_PID" 2>/dev/null || true
[ -n "${ENGINE_PID:-}" ] && kill "$ENGINE_PID" 2>/dev/null || true
rm -f ENGINE_DOWN
}

Comment thread dev/local-stack/demo.sh
Comment on lines +5 to +6
set -uo pipefail
cd "$(dirname "$0")"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the script is interrupted or fails after touch SATURATE-long but before rm -f SATURATE-long, the saturation file will persist, leaving the long tier saturated for subsequent runs. Adding an EXIT trap to clean up SATURATE-long ensures the environment is always left in a clean state.

Suggested change
set -uo pipefail
cd "$(dirname "$0")"
set -uo pipefail
cd "$(dirname "$0")"
trap 'rm -f SATURATE-long' EXIT

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f56ad99d8b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

DATABASE_NAME=platform_api
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
DATABASE_MAX_CONNECTIONS=5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable TLS for the local Postgres connection

When running the documented make api, this env file leaves DATABASE_TLS_ENABLED unset; DatabaseConfig::from_env defaults it to true, and with POSTGRES_PRIMARY_APP_ID=postgres-test the API builds a native-TLS pool for the local database. The postgres:16 service in this stack is started without any SSL configuration, so the API fails to connect/run migrations before seed or demo can work. Add DATABASE_TLS_ENABLED=false for this local stack.

Useful? React with 👍 / 👎.

Comment thread dev/local-stack/seed.sh
"maxOutputLength": 512,
"verifiable": true,
"isActive": true,
"providerType": "vllm",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark the local shim model as non-attested

For this seeded providerType: "vllm" model, omitting attestationSupported makes the repository default attestation_supported to true for non-external providers, but the local tier shims do not implement /v1/signature. In the streaming demo, cloud-api therefore tries to fetch provider signatures in the finalization path before [DONE], causing avoidable timeout delay and error logs for every local streaming completion. Seed this model with attestationSupported: false or add a signature stub.

Useful? React with 👍 / 👎.

trap cleanup_all EXIT

say "building model-proxy from $MODEL_PROXY_DIR"
(cd "$MODEL_PROXY_DIR" && cargo build -q 2>&1 | tail -1 || true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not mask model-proxy build failures

If this checkout already has a previous target/debug/model-proxy, a failing cargo build is hidden by || true, and the executable check below still passes against the stale binary. That makes make proxy-demo capable of validating the wrong model-proxy revision after a compile error, which undermines the control-plane demo. Let the build failure abort instead of continuing.

Useful? React with 👍 / 👎.

Comment on lines +29 to +30
--ctx-size 8192
--parallel 2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match llama.cpp context to the advertised long tier

The local stack advertises an 8000-token long tier, but llama.cpp has to share/partition its --ctx-size budget across the configured parallel slots. With --ctx-size 8192 and --parallel 2, long-tier requests near the advertised 8000-token window can pass the shim's check and then be rejected by the engine (or fail under concurrent use) because there is not enough per-request context behind it. Use one slot or increase the engine context so the backing server can actually serve the shim's declared window.

Useful? React with 👍 / 👎.

Reusable local dev environment that runs the real cloud-api against a REAL
inference engine on CPU (llama.cpp + Qwen2.5-0.5B GGUF, docker) with the
GLM-5.2-style two-tier context topology:

  curl -> cloud-api :13000 (mock auth, ephemeral signing keys)
            |- base tier -> tier_shim :18100 (ctx 1000) --+
            '- long tier -> tier_shim :18101 (ctx 8000) --+-> llama.cpp :18090
          postgres :15432 (docker, isolated from the e2e test-postgres)

The tier shims play the per-tier SGLang fleets: real completions and REAL
token counts through the vLLM/SGLang-shaped /v1/tokenize, SGLang-phrased
context-window 400s (so the fall-through matcher fires as in prod),
response .model tier tags (+base/+long), and a saturation drill
(touch SATURATE-long -> 503). make up/api/seed/demo/down; demo prints an
8-check pass/fail table (small->base, oversize->long, boundary->exact
count decides, streaming, saturated-long->retryable 429).

model-proxy-demo/: drives the REAL model-proxy binary through the
long-context registration flow with fast intervals — dual same-IP probes
sharing one routed backend, the probe-cleanup ownership guard
(model-proxy#42), and the health-gated discovery stub + non-2xx circuit
breaker (cvm-compose-files#129 + model-proxy#42 interplay).

Intentionally NOT local (documented in the README): the TLS/SNI data path
through model-proxy (providers fail closed on unattested HTTPS — the TEE
trust model) and the Chutes wire client (ML-KEM + TDX verification;
covered at the pool boundary in the e2e suite).
@PierreLeGuen
PierreLeGuen changed the base branch from glm52-context-tier-routing to main July 3, 2026 18:38
@PierreLeGuen

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #860 is merged — the diff is dev tooling only (dev/local-stack/, no src/ changes), CI green. Blocked only by the 1-approval branch-protection rule; needs a reviewer's approve to merge (I'm the author, can't self-approve). make up && make api && make seed && make demo runs the full tier-routing stack on a real CPU model.

@Evrard-Nil Evrard-Nil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thorough read of all 11 files (729 additions, zero src/ / Cargo.* changes). No production code paths are touched — everything lives under dev/local-stack/.

What this does

Adds a self-contained local dev environment for exercising the GLM-5.2-style two-tier context routing end-to-end on one machine without staging, GPUs, or a TEE:

  • docker-compose.yml — Postgres 16 + llama.cpp (Qwen2.5-0.5B GGUF, CPU) on isolated ports
  • tier_shim.py — Python HTTP shim that emulates a per-tier SGLang fleet: real /v1/tokenize counts, SGLang-phrased context-400s, saturation drill via sentinel file
  • seed.sh — bootstraps mock admin user, two-tier model config, org/credits/API key via the admin API
  • demo.sh — 8-check table: small→base, oversize→long, boundary (exact tokenize count), streaming, saturated-long→retryable 429/5xx
  • model-proxy-demo/ — drives the REAL model-proxy binary through the dual-probe / ownership-guard / circuit-breaker flow (model-proxy#42 already merged)

Security

  • All local ports are bound to 127.0.0.1 (loopback only). The llama.cpp container uses --host 0.0.0.0 internally but the host-side binding is 127.0.0.1:18090.
  • env.local-stack values are intentional dev placeholders (local-stack-not-a-secret, local-stack-inference-key, zero S3 key) with clear comments. No real credentials.
  • .api-key is gitignored. All generated state (cache/, run/, logs, PIDs, SATURATE-*) is gitignored.
  • DEV=1 uses ephemeral signing keys — correct for a non-TEE machine; this path is already guarded in the existing codebase to be debug-build-only.

Minor findings (non-blocking for tooling code)

  1. make up called twice leaves orphan shims. The up target spawns new shims without killing any existing ones on the same ports. The new processes will fail to bind (port already in use) and the old ones keep running silently — make up reports green because it health-checks the still-running originals. make down && make up is the correct re-run path, but a short kill $(cat .shim-*.pid) 2>/dev/null || true guard at the top of up would prevent confusion. Not blocking.

  2. Upstream HTTP errors masked in tier_shim.py. urllib.request.urlopen() raises urllib.error.HTTPError on non-2xx. This falls through to the blanket except Exception in do_POST and is re-served as a 500 with the Python exception string. The actual upstream status code and body are lost. For a dev tool this is tolerable (the error text mentions the upstream URL), but catching HTTPError separately and forwarding the status + body would make debugging easier.

  3. demo.sh missing trap for SATURATE-long. If the script is interrupted between touch SATURATE-long (line 53) and rm -f SATURATE-long (line 56), the sentinel file lingers and subsequent demo runs fail test 2 (oversize→long). run.sh has the same gap for ENGINE_DOWN. Both could be fixed with a trap 'rm -f SATURATE-long' EXIT / trap 'rm -f ENGINE_DOWN' EXIT. The Gemini review flagged this; it appears the current HEAD didn't address it.

  4. ENGINE_PID in run.sh cleanup is dead code. cleanup_all() references $ENGINE_PID but it is never assigned — the "fake engine" role is handled by probe_stub.py via the ENGINE_DOWN file, not a separate process. The variable is harmless (:-} guard) but confusing. Worth removing.

  5. demo.sh uses set -uo but not -e. Intentional (partial failures are accumulated into the PASS/FAIL table rather than aborting), but worth a comment so readers don't assume it's an oversight.

None of these block the PR — they're all contained within the dev tooling directory and have no impact on CI or production paths.

Verdict

Approved. The stacking dependency (#860) is already merged to main, the branch rebases cleanly, CI (security_audit) is green, no production code is touched, no real credentials, all sockets loopback-only. The tier shim correctly mirrors the SGLang phrasing that cloud-api's fall-through matcher depends on. The findings above are worth a follow-up but don't block merging dev tooling.

@Evrard-Nil
Evrard-Nil requested a review from lloydmak99 July 6, 2026 07:43
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.

2 participants