Jelajahi Sumber

initial files

Krasimir Kazakov 5 hari lalu
melakukan
8ed9b64020
6 mengubah file dengan 894 tambahan dan 0 penghapusan
  1. 26 0
      .gitignore
  2. 272 0
      README.md
  3. 83 0
      benchmark.sh
  4. 104 0
      docker-compose.yaml
  5. 34 0
      download-model.sh
  6. 375 0
      proxy-passthrough.py

+ 26 - 0
.gitignore

@@ -0,0 +1,26 @@
+# Model files (never commit weights)
+*.gguf
+*.bin
+*.safetensors
+
+# Python
+__pycache__/
+*.py[cod]
+
+# Environment
+.env
+.venv
+venv/
+
+# Docker
+docker-compose.override.yml
+
+# Logs
+*.log
+logs/
+
+# IDE / OS
+.vscode/
+.idea/
+*.swp
+.DS_Store

+ 272 - 0
README.md

@@ -0,0 +1,272 @@
+# vLLM backend — Huihui Qwen3.8-27B-abliterated (AWQ W4A16)
+
+Self-contained vLLM version of the parent `coding-stack-huihui-qwen3.8-27b` project.
+Instead of the Q8 GGUF it serves the pre-quantized INT4 checkpoint
+**`shawnw3i/Huihui-Qwen3.8-27B-abliterated-AWQ-MTP`** (auto-round AWQ, group_size 64,
+18.65 GiB on disk) as the model **`foundation`**, behind the same OpenAI-compatible
+proxy that injects a SearXNG `web_search` tool.
+
+This is the fast backend: vLLM's paged KV cache, chunked prefill and Marlin INT4 kernels
+are what make the 250k-token context practical on one 48 GB card. It is also the least
+flexible one — one sequence at a time, no vision, no FP8 on this GPU.
+
+Deliberately **not** included: the llama.cpp and Ollama compose files, the
+LibreChat/MongoDB/Meilisearch stack, GGUF/mmproj handling, and the parent's shared
+container names/API key.
+
+```
+                   ┌─────────────── web_search ──────────────► SearXNG :8889 (external)
+                   │
+Client ──► :11433 proxy (Flask) ──► :18080 vLLM (OpenAI API)
+                   ▲
+                   └── :18080 is also published directly, bypassing the proxy
+```
+
+Containers: `coding-stack-vllm` (`vllm/vllm-openai:nightly`) and
+`coding-stack-proxy` (`python:3.11-slim`). Port **11433** and the proxy container name
+clash with the parent and Ollama stacks — stop those first if you switch back and forth.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `docker-compose.yaml` | vLLM server + web_search proxy (the whole stack) |
+| `proxy-passthrough.py` | OpenAI facade on `:11433`; injects and executes `web_search` |
+| `download-model.sh` | Pulls the AWQ checkpoint into the HF cache vLLM reads |
+| `benchmark.sh` | TTFT + decode tok/s from a streaming request |
+| `.gitignore` | Keeps caches and weights out of git |
+
+There is no Modelfile and no import step: vLLM loads the HF checkpoint directly, and the
+chat template comes from the checkpoint's `chat_template.jinja`.
+
+## Requirements
+
+- Docker + Docker Compose v2, NVIDIA driver + Container Toolkit
+- **One whole 48 GB GPU** (RTX A6000 here): 18.65 GiB weights + ~15.5 GiB KV at 253952
+  context + CUDA graphs/activations, at `--gpu-memory-utilization 0.93`
+- **Compute capability ≥ 8.9 only for FP8** — the A6000 is 8.6, so weights and KV stay
+  FP16 and INT4 comes from AWQ + Marlin kernels. Don't add `--kv-cache-dtype fp8`
+  (or an FP8 checkpoint) on this card: it won't run.
+- ~62 GB RAM is comfortable; vLLM reported 55 GiB available while loading 18.65 GiB
+- Image: `vllm/vllm-openai:nightly` (~29 GB, **0.27.2rc1** at the time of writing,
+  2 months old in this host's cache). It is a moving tag — re-pull deliberately.
+- Optional: SearXNG at `host.docker.internal:8889` for `web_search`
+
+The checkpoint is **already in this host's cache**:
+`/storage-models/vllm/hub/models--shawnw3i--Huihui-Qwen3.8-27B-abliterated-AWQ-MTP`
+(7 shards + `model_extra_tensors.safetensors`, size-verified in the parent project).
+`download-model.sh` is a no-op when it is there.
+
+## Quick start
+
+```bash
+cd vllm
+./download-model.sh          # ~19 GB; no-op if the HF cache already has it
+docker compose up -d         # first start: load + compile + CUDA graph capture (minutes)
+docker compose ps            # wait for (healthy) — start_period is 900 s
+./benchmark.sh               # TTFT + tok/s
+```
+
+Cold start is slow by design: vLLM compiles the model's kernels (`torch.compile`,
+Triton GDN kernels) and captures CUDA graphs before it reports healthy. Expect several
+minutes on first boot; subsequent boots reuse the compile cache in the image's
+`/root/.cache` only if you persist it (this stack does not — add a volume if you care).
+
+## Verify
+
+```bash
+# Direct to vLLM (OpenAI-compatible)
+curl http://localhost:18080/v1/models
+curl http://localhost:18080/v1/chat/completions -H "Authorization: Bearer x" \
+  -d '{"model":"foundation","messages":[{"role":"user","content":"Hello"}],"max_tokens":64}'
+
+# Through the proxy (+ web_search injected)
+curl http://localhost:11433/v1/chat/completions -H "Authorization: Bearer x" \
+  -d '{"model":"foundation","messages":[{"role":"user","content":"Hello"}],"max_tokens":64}'
+
+# Streaming (proxy fast-path: SSE passed straight through)
+curl -N http://localhost:11433/v1/chat/completions -H "Authorization: Bearer x" \
+  -d '{"model":"foundation","messages":[{"role":"user","content":"Hello"}],"stream":true,"max_tokens":64}'
+
+curl http://localhost:18080/health
+```
+
+vLLM is started **without** `--api-key`, so any `Authorization` value works (or none).
+Add `--api-key` and `BACKEND_API_KEY` to the proxy if the published port must be closed.
+
+## Endpoints
+
+| Port | Service | Notes |
+|------|---------|-------|
+| `11433` | proxy (Flask) | OpenAI `/v1/*`; injects `web_search`; everything else is transparent |
+| `18080` | vLLM | OpenAI `/v1/*`, `/health`, `/metrics`; no auth |
+
+Model name: **`foundation`** (`--served-model-name`), regardless of the HF repo name.
+
+## Configuration
+
+### vLLM flags (`docker-compose.yaml`)
+
+| Flag | Value | Why |
+|------|-------|-----|
+| `--max-model-len` | `253952` | fits 18.65 GiB weights + ~15.5 GiB f16 KV on 48 GB; native max is 262144 |
+| `--gpu-memory-utilization` | `0.93` | leaves ~3 GiB for the desktop/other processes |
+| `--max-num-seqs` | `1` | one 250k sequence consumes the whole KV pool |
+| `--max-num-batched-tokens` | `16384` | chunked prefill granularity for long prompts |
+| `--enable-chunked-prefill` / `--enable-prefix-caching` | on | keeps a 250k-token agent prompt responsive and cached across turns |
+| `--reasoning-parser` | `qwen3` | splits the reasoning trace out of `content` |
+| `--tool-call-parser` | `qwen3_coder` | required for tool calls (the proxy's `web_search`) |
+| `--enable-auto-tool-choice` | on | without it vLLM rejects requests that carry `tools` |
+| `--language-model-only` | on | skips the vision tower (see Limitations) |
+| `--dtype` | `float16` | the checkpoint's dtype; bf16 would double nothing but adds no benefit here |
+| `--trust-remote-code` | on | auto-round checkpoint ships custom config |
+
+The `VLLM_*` environment variables in the compose file are the parent project's tuning
+for this exact card (Marlin atomic add, FlashInfer sampler, graph estimation, spawn
+workers); they are not required for correctness.
+
+### Changing context length
+
+KV is f16 on the 16 full-attention layers only (the 48 GDN layers keep a small recurrent
+state), so 64 KiB/token:
+
+| Context | KV (f16) | Weights | Approx. total |
+|---------|----------|---------|---------------|
+| 32k | ~2.0 GiB | 18.65 GiB | ~22 GiB |
+| 128k | ~8.2 GiB | 18.65 GiB | ~29 GiB |
+| 253952 (default) | ~15.5 GiB | 18.65 GiB | ~37 GiB |
+| 262144 (native max) | ~16 GiB | 18.65 GiB | ~38 GiB |
+
+Lower `--max-model-len` (and drop `VLLM_ALLOW_LONG_MAX_MODEL_LEN` if you go below the
+model's 262144) if you need to share the GPU. Going **up** is not possible without a
+second card or a smaller/quantized KV cache — and FP8 KV needs CC ≥ 8.9.
+
+## Thinking / reasoning
+
+`--reasoning-parser qwen3` moves the trace out of `content`. The model keeps Qwen3.8's
+default reasoning level, so it thinks by default and **reasoning tokens count against
+`max_tokens`** — give it 2048–4096 for real questions. Verified on this build (vLLM
+0.27.2rc1): the trace arrives in the message's **`reasoning`** field (streaming:
+`delta.reasoning`), the answer in `content`, and the split is reported in
+`usage.completion_tokens_details.reasoning_tokens`. (Older vLLM releases used
+`reasoning_content`; the proxy re-streams either.)
+
+Thinking controls pass through `chat_template_kwargs` (verified):
+
+```bash
+# no reasoning at all
+curl localhost:18080/v1/chat/completions -H 'Content-Type: application/json' \
+  -d '{"model":"foundation","messages":[{"role":"user","content":"Hi"}],
+       "chat_template_kwargs":{"enable_thinking":false}}'
+
+# shorter trace (named level from the Qwen3.8 template)
+  ... "chat_template_kwargs":{"reasoning_effort":"low"}
+```
+
+With default settings a 300- and even a 1000-token reply came back as **pure reasoning**
+(`finish_reason: "length"`, empty `content`) — this checkpoint thinks a lot. Set a high
+`max_tokens` or lower the effort.
+
+The proxy does not disable thinking and does not touch the `reasoning` field — it passes
+completions through unchanged.
+
+## Tools and `web_search`
+
+`--enable-auto-tool-choice --tool-call-parser qwen3_coder` is what makes the proxy's
+injected `web_search` tool usable. Verified: a `get_weather` request came back as
+`tool_calls: [{"function":{"name":"get_weather","arguments":"{\"city\": \"Sofia\"}"}}]`
+with `finish_reason: "tool_calls"`. Behaviour is the same as the other backends:
+
+- **Non-streaming** through `:11433`: the proxy runs the search loop server-side
+  (`MAX_SEARCH_ITER`, default 10) against SearXNG and returns only the final answer. The
+  client's own tool calls are returned untouched, unless the model mixes them into a search
+  turn (then they are stubbed — inherited edge case).
+- **Streaming**: passed through natively; if the model calls `web_search`, the client gets
+  the `tool_call` and must execute it.
+- `SEARXNG_URL` / `SEARXNG_RESULTS` tune the backend (defaults
+  `http://host.docker.internal:8889`, 5). `extra_hosts` is what resolves that hostname.
+- When SearXNG returns nothing, this checkpoint keeps retrying with new queries and the
+  loop ends with an explicit error instead of an answer — verified during testing (SearXNG
+  had all engines rate-limited). The error now reports the counts:
+  `Exceeded max search iterations (2 searches, 2 empty) — SearXNG returned no usable results`.
+  Lower `MAX_SEARCH_ITER` (env on the proxy) if you prefer to fail fast.
+
+## Pointing a client at it
+
+```
+base_url = http://localhost:11433/v1   # or http://localhost:18080/v1 to skip web_search
+model    = foundation
+api_key  = anything
+```
+
+Use the proxy for coding agents so `web_search` works, and set `max_tokens` high enough
+for the reasoning trace. Direct vLLM (`:18080`) is also OpenAI-compatible, including
+native `json_schema` structured output — the vLLM build does not need the grammar
+stripping that llama.cpp did (dropped from this proxy).
+
+## Limitations
+
+- **No vision.** `--language-model-only` drops the visual tower and vLLM logs
+  `running in text-only mode`. Remove the flag and lower `--max-model-len` if you need
+  images; the processor configs are in the checkpoint.
+- **One sequence at a time.** `--max-num-seqs 1`: a second concurrent request queues.
+- **No FP8.** A6000 is compute capability 8.6; FP8 weights and FP8 KV need 8.9+.
+- **No auth** out of the box (`--api-key` omitted).
+- **Nightly image.** `vllm/vllm-openai:nightly` moves; `v0.18.0` (also cached on this host)
+  predates `qwen3_5` support. Pin the digest if you need reproducibility.
+- **INT4 is below the Q8 GGUF in fidelity.** AWQ keeps `mtp`, `lm_head`, the visual merger
+  and some `linear_attn.in_proj_a/b` tensors in fp16, but the bulk of the weights are
+  4-bit; the llama.cpp/Ollama stacks with `UD-DW-Q8_K_L` are the near-lossless option.
+
+## Performance
+
+Measured with `./benchmark.sh` on this RTX A6000 with the GPU otherwise idle (only ~0.3 GiB
+of desktop usage), default settings, 253952 context:
+
+| Metric | Value |
+|--------|-------|
+| decode (300 tokens) | **38.5 tok/s** |
+| decode (1000 tokens) | 38.3 tok/s |
+| TTFT (92-token prompt, warm) | **0.10 s** |
+| prefill (9,062-token prompt) | ~1.8k tok/s (5.9 s wall, including 32 decoded tokens) |
+| cold start → healthy | **~4 min** (06:52:28 → 06:56:41, kernels compiled + graphs captured) |
+| VRAM at steady state | 42.9 GiB: 17.7 GiB weights/non-torch, 2.74 GiB peak activations, 0.04 GiB CUDA graphs, 23.6 GiB KV pool |
+
+Comparison: llama.cpp with the Q8 GGUF measures **9.95 tok/s** on the same card, so vLLM
+INT4 is **~3.9×** the decode rate (the parent project hoped for 2–3×). The trade is
+fidelity: 4-bit AWQ vs near-lossless Q8.
+
+The KV pool is **379,388 tokens** (65 KiB/token f16) — `--max-model-len 253952` only uses
+~67% of it, so the context could be raised to the native 262144; the rest is headroom for
+one long prompt, not a second sequence (`--max-num-seqs 1`).
+
+Two implementation details that affect speed on this card (from the vLLM log):
+
+- `Using MarlinLinearKernel for AutoAWQMarlinLinearMethod` — the INT4 path is Marlin.
+- `Falling back to the Triton GDN decode path` — the fused CUDA GDN kernel requires a
+  BF16 model with K=V=128; this FP16 checkpoint uses the Triton kernel instead.
+
+## Troubleshooting
+
+| Problem | Fix |
+|---------|-----|
+| `ValueError: No available memory for the cache blocks` / OOM at startup | lower `--max-model-len` (e.g. 131072) or `--gpu-memory-utilization`; free the GPU first (`nvidia-smi`) |
+| Container stuck in `health: starting` | it is compiling/capturing graphs — watch `docker compose logs -f vllm`; allow up to 15 min on first boot |
+| `model ... not found` / 404 | `./download-model.sh`; confirm `HF_HOME=/models` and the cache path under `/storage-models/vllm/hub` |
+| AWQ kernel / quant errors | try `--quantization compressed-tensors`, or add `--enforce-eager` (slower but skips CUDA graphs) |
+| Requests queue behind each other | expected with `--max-num-seqs 1`; raise it only if you lower `--max-model-len` |
+| Tool calls rejected / not parsed | the parsers are mandatory: `--enable-auto-tool-choice --tool-call-parser qwen3_coder` |
+| `reasoning` empty | check `--reasoning-parser qwen3` is present; it is what extracts the trace |
+| Empty `content`, `finish_reason: "length"` | thinking ate the budget — raise `max_tokens` or pass `chat_template_kwargs: {"enable_thinking": false}` |
+| `Exceeded max search iterations (N searches, M empty)` | SearXNG returned nothing; check its engines, or lower `MAX_SEARCH_ITER` on the proxy |
+| Proxy returns 502 | `docker compose logs vllm` — usually a failed load; also confirm `BACKEND_URL=http://vllm:8000` |
+| `web_search` → `Search failed` | SearXNG on `host.docker.internal:8889` is down (external to this stack) |
+| Startup warning about multimodal limits | harmless: it is the text-only mode you asked for |
+
+## Reset
+
+```bash
+docker compose down                      # keeps the HF cache and the image
+# nuclear: delete the checkpoint cache (19 GB) and re-download with ./download-model.sh
+sudo rm -rf /storage-models/vllm/hub/models--shawnw3i--Huihui-Qwen3.8-27B-abliterated-AWQ-MTP
+```

+ 83 - 0
benchmark.sh

@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+# Benchmark the vLLM server: time-to-first-token and decode tok/s from a streaming
+# /v1/chat/completions response (usage included in the final chunk).
+#
+# Usage: ./benchmark.sh [num_predict]
+#   BASE_URL=http://localhost:18080   # direct; use :11433 to go through the proxy
+set -euo pipefail
+
+BASE_URL="${BASE_URL:-http://localhost:18080}"
+MODEL="${MODEL:-foundation}"
+NUM_PREDICT="${1:-300}"
+PROMPT="Write a comprehensive explanation of how quantum computing works, including the principles of superposition, entanglement, and quantum gates. Provide examples of real-world applications and discuss the current state of quantum hardware."
+
+if ! curl -sf -m 10 "$BASE_URL/v1/models" >/dev/null; then
+    echo "ERROR: cannot reach $BASE_URL/v1/models — is the stack up? (docker compose up -d)" >&2
+    exit 1
+fi
+
+python3 - "$BASE_URL" "$MODEL" "$PROMPT" "$NUM_PREDICT" <<'PY'
+import json, sys, time, urllib.request
+
+base, model, prompt, n = sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4])
+
+def chat(stream, max_tokens):
+    body = json.dumps({
+        "model": model,
+        "messages": [{"role": "user", "content": prompt}],
+        "max_tokens": max_tokens,
+        "stream": stream,
+        "stream_options": {"include_usage": True} if stream else None,
+    }).encode()
+    req = urllib.request.Request(base + "/v1/chat/completions", data=body,
+                                 headers={"Content-Type": "application/json",
+                                          "Authorization": "Bearer x"})
+    return urllib.request.urlopen(req, timeout=1800)
+
+# warmup (also loads/compiles if the server is fresh)
+t0 = time.monotonic()
+with chat(False, 16) as r:
+    json.load(r)
+print(f"warmup done in {time.monotonic() - t0:.1f}s")
+
+t0 = time.monotonic()
+t_first = None
+usage = {}
+answer = ""
+with chat(True, n) as r:
+    for raw in r:
+        line = raw.decode().strip()
+        if not line.startswith("data: "):
+            continue
+        payload = line[6:]
+        if payload == "[DONE]":
+            break
+        chunk = json.loads(payload)
+        if chunk.get("usage"):
+            usage = chunk["usage"]
+        for ch in chunk.get("choices", []):
+            delta = ch.get("delta") or {}
+            # vLLM 0.27 puts the reasoning trace in `reasoning` (older builds:
+            # `reasoning_content`); either is the first generated token.
+            piece = (delta.get("content") or delta.get("reasoning")
+                     or delta.get("reasoning_content") or "")
+            if piece:
+                if t_first is None:
+                    t_first = time.monotonic()
+                answer += piece
+t_end = time.monotonic()
+
+pt = usage.get("prompt_tokens", 0)
+ct = usage.get("completion_tokens", 0)
+rt = (usage.get("completion_tokens_details") or {}).get("reasoning_tokens", 0) or 0
+ttft = (t_first - t0) if t_first else float("nan")
+decode_window = (t_end - t_first) if t_first else 0.0
+
+print(f"prompt tokens     : {pt}")
+print(f"completion tokens : {ct} (reasoning: {rt})")
+print(f"TTFT              : {ttft:.2f} s")
+print(f"wall              : {t_end - t0:.2f} s")
+if decode_window > 0 and ct > 1:
+    print(f"decode            : {(ct - 1) / decode_window:.2f} tok/s")
+print(f"sample            : {answer[:140].replace(chr(10), ' ')!r}")
+PY

+ 104 - 0
docker-compose.yaml

@@ -0,0 +1,104 @@
+services:
+  # vLLM server - serves the Huihui Qwen3.8-27B AWQ W4A16 checkpoint as "foundation"
+  vllm:
+    # Moving target: re-pull deliberately. 0.27.2rc1 (nightly) is the tested build;
+    # v0.18.0 (also cached locally) predates qwen3_5 support.
+    image: vllm/vllm-openai:nightly
+    container_name: coding-stack-vllm
+    restart: unless-stopped
+    volumes:
+      # HF cache root: /storage-models/vllm/hub/models--shawnw3i--…
+      - /storage-models/vllm:/models
+    environment:
+      - HF_HOME=/models
+      - VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=1
+      - VLLM_NO_USAGE_STATS=1
+      - PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True,max_split_size_mb:512
+      - VLLM_FLOAT32_MATMUL_PRECISION=high
+      - VLLM_USE_FLASHINFER_SAMPLER=1
+      - OMP_NUM_THREADS=1
+      - CUDA_DEVICE_MAX_CONNECTIONS=8
+      - VLLM_ALLOW_LONG_MAX_MODEL_LEN=1
+      - TRITON_CACHE_DIR=/root/.triton/cache
+      - VLLM_WORKER_MULTIPROC_METHOD=spawn
+      - VLLM_MARLIN_USE_ATOMIC_ADD=1
+      - VLLM_LOGGING_LEVEL=INFO
+    shm_size: 8g
+    ports:
+      - "18080:8000"
+    entrypoint: ["python3", "-m", "vllm.entrypoints.openai.api_server"]
+    command:
+      - --model
+      - shawnw3i/Huihui-Qwen3.8-27B-abliterated-AWQ-MTP
+      - --port
+      - "8000"
+      - --served-model-name
+      - foundation
+      # ~248k tokens: 20 GB AWQ weights + ~15.5 GiB f16 KV + overhead on 48 GB.
+      - --max-model-len
+      - "253952"
+      - --reasoning-parser
+      - qwen3
+      - --tool-call-parser
+      - qwen3_coder
+      - --dtype
+      - float16
+      # Vision tower skipped. Drop this and lower --max-model-len to ~200000
+      # if you ever want images through vLLM.
+      - --language-model-only
+      - --gpu-memory-utilization
+      - "0.93"
+      # One 250k-token sequence needs the whole KV pool; more seqs would starve it.
+      - --max-num-seqs
+      - "1"
+      - --max-num-batched-tokens
+      - "16384"
+      - --enable-chunked-prefill
+      - --enable-prefix-caching
+      - --enable-auto-tool-choice
+      - --trust-remote-code
+      - --host
+      - 0.0.0.0
+      - --disable-log-stats
+    networks:
+      - coding-stack-network
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
+      interval: 30s
+      timeout: 10s
+      retries: 5
+      start_period: 900s
+    deploy:
+      resources:
+        reservations:
+          devices:
+            - driver: nvidia
+              count: all
+              capabilities: [gpu]
+
+  # OpenAI-compatible facade on :11433 that injects a SearXNG web_search tool
+  proxy:
+    image: python:3.11-slim
+    container_name: coding-stack-proxy
+    restart: unless-stopped
+    ports:
+      - "11433:11536"
+    volumes:
+      - ./proxy-passthrough.py:/app/proxy.py:ro
+    environment:
+      - BACKEND_URL=http://vllm:8000
+      - SEARXNG_URL=http://host.docker.internal:8889
+      - SEARXNG_RESULTS=5
+      # vLLM serves without auth unless --api-key is added above
+      # - BACKEND_API_KEY=
+    extra_hosts:
+      - "host.docker.internal:host-gateway"
+    command: sh -c "pip install -q flask requests && python /app/proxy.py"
+    networks:
+      - coding-stack-network
+    depends_on:
+      - vllm
+
+networks:
+  coding-stack-network:
+    driver: bridge

+ 34 - 0
download-model.sh

@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Download the Huihui Qwen3.8-27B AWQ W4A16 checkpoint (auto-round INT4, ~20 GB)
+# into the Hugging Face cache that vLLM reads (HF_HOME=/storage-models/vllm).
+#
+# Usage:
+#   ./download-model.sh
+#   HF_HOME=/some/other/cache ./download-model.sh
+#
+# Re-running is cheap: huggingface_hub resumes/verifies already-downloaded files.
+set -euo pipefail
+
+REPO="shawnw3i/Huihui-Qwen3.8-27B-abliterated-AWQ-MTP"
+export HF_HOME="${HF_HOME:-/storage-models/vllm}"
+
+SNAPSHOT_GLOB="$HF_HOME/hub/models--${REPO//\//--}/snapshots/*/config.json"
+if compgen -G "$SNAPSHOT_GLOB" >/dev/null; then
+    echo "Already present in $HF_HOME/hub ($(du -sh "$HF_HOME/hub/models--${REPO//\//--}" | cut -f1))"
+    echo "Nothing to do. (Delete that cache entry to force a re-download.)"
+    exit 0
+fi
+
+if command -v hf >/dev/null 2>&1; then
+    hf download "$REPO"
+elif command -v huggingface-cli >/dev/null 2>&1; then
+    huggingface-cli download "$REPO"
+else
+    echo "No Hugging Face CLI found. Install it first:" >&2
+    echo "  pip install -U 'huggingface_hub[cli]'" >&2
+    exit 1
+fi
+
+echo
+du -sh "$HF_HOME/hub/models--${REPO//\//--}"
+echo "Done. Next: docker compose up -d  (first start compiles kernels / captures CUDA graphs)"

+ 375 - 0
proxy-passthrough.py

@@ -0,0 +1,375 @@
+#!/usr/bin/env python3
+"""
+OpenAI-compatible facade in front of Ollama.
+
+1. Injects a SearXNG-backed `web_search` tool into every chat/completions request
+2. Executes web_search tool calls server-side (non-streaming requests) and feeds
+   the results back to the model before answering the client
+3. All other tool calls (the client's own tools) are returned to the client
+   untouched — the client runs its own tool loop
+4. Streaming requests are passed straight through to Ollama (no probe), so the
+   client keeps native SSE and thinking output. In that case, if the model calls
+   web_search the client receives that tool_call.
+
+Ollama ignores the Authorization header (no API key), so clients may send any key.
+"""
+from flask import Flask, request, Response, stream_with_context
+import requests
+import json
+import sys
+import os
+
+app = Flask(__name__)
+
+sys.stdout.reconfigure(line_buffering=True)
+sys.stderr.reconfigure(line_buffering=True)
+
+BACKEND_URL = os.environ.get("BACKEND_URL", "http://vllm:8000")
+BACKEND_API_KEY = os.environ.get("BACKEND_API_KEY", "")
+SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://host.docker.internal:8889")
+SEARXNG_RESULTS = int(os.environ.get("SEARXNG_RESULTS", "5"))
+MAX_SEARCH_ITER = int(os.environ.get("MAX_SEARCH_ITER", "10"))
+
+SEARXNG_TOOL = {
+    "type": "function",
+    "function": {
+        "name": "web_search",
+        "description": (
+            "Search the web for current information. Use this whenever you need "
+            "up-to-date facts, recent events, or anything that may have changed "
+            "since your training cutoff. Do not guess — call web_search instead."
+        ),
+        "parameters": {
+            "type": "object",
+            "properties": {
+                "query": {
+                    "type": "string",
+                    "description": "The search query string"
+                }
+            },
+            "required": ["query"]
+        }
+    }
+}
+
+SEARCH_SYSTEM_MSG = (
+    "You have access to a web_search tool. "
+    "Use it whenever the user asks about current events, real-time data, recent news, "
+    "live prices, weather, sports scores, or anything else that may have changed since "
+    "your training cutoff. Do not say you cannot access the internet — call web_search instead."
+)
+
+
+def searxng_search(query):
+    """Execute a search against SearXNG and return a formatted result string."""
+    try:
+        print(f"[SEARXNG] Searching: {query}")
+        resp = requests.get(
+            f"{SEARXNG_URL}/search",
+            params={"q": query, "format": "json", "categories": "general"},
+            timeout=15
+        )
+        resp.raise_for_status()
+        data = resp.json()
+        results = data.get("results", [])[:SEARXNG_RESULTS]
+        if not results:
+            return f"No results found for: {query}"
+        lines = [f"Search results for: {query}\n"]
+        for i, r in enumerate(results, 1):
+            title = r.get("title", "No title")
+            url = r.get("url", "")
+            content = r.get("content", "").strip()
+            lines.append(f"{i}. {title}")
+            if url:
+                lines.append(f"   URL: {url}")
+            if content:
+                lines.append(f"   {content}")
+            lines.append("")
+        return "\n".join(lines).strip()
+    except Exception as e:
+        print(f"[ERROR] SearXNG search failed: {e}")
+        return f"Search failed: {e}"
+
+
+def inject_search(json_data):
+    """Inject web_search tool and system prompt nudge if not already present."""
+    tools = json_data.setdefault('tools', [])
+    names = {t.get('function', {}).get('name') for t in tools if isinstance(t, dict)}
+    if 'web_search' not in names:
+        tools.append(SEARXNG_TOOL)
+        print("[INJECTED] Added web_search tool")
+
+    messages = json_data.setdefault('messages', [])
+    already = any('web_search' in str(m.get('content') or '')
+                  for m in messages if m.get('role') == 'system')
+    if not already:
+        if messages and messages[0].get('role') == 'system':
+            # Append to the existing first system message so we stay at position 0
+            messages[0]['content'] = str(messages[0]['content']) + '\n\n' + SEARCH_SYSTEM_MSG
+            print("[INJECTED] Appended web_search to existing system prompt")
+        else:
+            # No system message yet — prepend one at position 0
+            messages.insert(0, {"role": "system", "content": SEARCH_SYSTEM_MSG})
+            print("[INJECTED] Added web_search system prompt at position 0")
+
+
+def call_backend_nonstream(json_data, headers):
+    """Call backend with streaming off, return parsed JSON or raise."""
+    data = dict(json_data)
+    data['stream'] = False
+    data.pop('stream_options', None)
+    body = json.dumps(data).encode('utf-8')
+    hdrs = {k: v for k, v in headers.items() if k.lower() != 'host'}
+    if BACKEND_API_KEY:
+        hdrs['Authorization'] = f'Bearer {BACKEND_API_KEY}'
+    hdrs['Content-Length'] = str(len(body))
+    hdrs['Content-Type'] = 'application/json'
+    resp = requests.post(f"{BACKEND_URL}/v1/chat/completions",
+                         headers=hdrs, data=body, timeout=600)
+    resp.raise_for_status()
+    return resp.json()
+
+
+def call_backend_passthrough(json_data, headers):
+    """Forward request to backend as-is (preserving streaming), return raw Response."""
+    body = json.dumps(json_data).encode('utf-8')
+    hdrs = {k: v for k, v in headers.items() if k.lower() != 'host'}
+    if BACKEND_API_KEY:
+        hdrs['Authorization'] = f'Bearer {BACKEND_API_KEY}'
+    hdrs['Content-Length'] = str(len(body))
+    hdrs['Content-Type'] = 'application/json'
+    sess = requests.Session()
+    sess.headers.update(hdrs)
+    resp = sess.post(f"{BACKEND_URL}/v1/chat/completions",
+                     data=body, stream=True, timeout=(10, 600))
+
+    def generate():
+        count = 0
+        try:
+            for line in resp.iter_lines():
+                if line:
+                    count += 1
+                    yield line + b'\n\n'
+            print(f"[STREAM] Completed: {count} SSE lines forwarded")
+        except Exception as e:
+            print(f"[STREAM] ERROR after {count} lines: {e}")
+            import traceback; traceback.print_exc()
+
+    out_headers = {k: v for k, v in resp.headers.items()
+                   if k.lower() in {'content-type', 'cache-control', 'x-accel-buffering'}}
+    return Response(generate(), status=resp.status_code, headers=out_headers)
+
+
+def run_loop(json_data, headers):
+    """
+    Non-streaming path. Calls the model, handles web_search calls transparently,
+    returns to client when the model produces non-search output.
+
+    If the model calls the client's own tools alongside or instead of
+    web_search, those are returned to the client immediately.
+    """
+    is_streaming = json_data.get('stream', False)
+    internal = dict(json_data)
+    internal['messages'] = list(json_data.get('messages', []))
+
+    searches = 0
+    empty = 0
+    for iteration in range(MAX_SEARCH_ITER):
+        try:
+            completion = call_backend_nonstream(internal, headers)
+        except Exception as e:
+            print(f"[ERROR] Backend call failed: {e}")
+            import traceback; traceback.print_exc()
+            return Response(
+                json.dumps({'error': {'message': str(e), 'type': 'proxy_error'}}).encode(),
+                status=502, headers={'Content-Type': 'application/json'}
+            )
+
+        choice = completion.get('choices', [{}])[0]
+        message = choice.get('message', {})
+        tool_calls = message.get('tool_calls') or []
+
+        search_calls = [tc for tc in tool_calls if tc.get('function', {}).get('name') == 'web_search']
+        client_calls = [tc for tc in tool_calls if tc.get('function', {}).get('name') != 'web_search']
+
+        if not search_calls:
+            print(f"[LOOP] Done after {iteration} search iteration(s)")
+            if client_calls:
+                print(f"[LOOP] Returning {len(client_calls)} client tool call(s) to client")
+            if iteration == 0 and is_streaming:
+                print("[LOOP] No search needed, passing through natively")
+                return call_backend_passthrough(json_data, headers)
+            if is_streaming:
+                return _stream_completion(completion)
+            return Response(json.dumps(completion).encode(),
+                            status=200, headers={'Content-Type': 'application/json'})
+
+        # Model wants to search — execute and loop
+        print(f"[LOOP] Iteration {iteration}: {len(search_calls)} search call(s), "
+              f"{len(client_calls)} client call(s)")
+
+        asst = {"role": "assistant", "tool_calls": tool_calls}
+        if message.get('content'):
+            asst['content'] = message['content']
+        internal['messages'].append(asst)
+
+        for tc in search_calls:
+            try:
+                args = json.loads(tc.get('function', {}).get('arguments', '{}'))
+            except Exception:
+                args = {}
+            result = searxng_search(args.get('query', ''))
+            searches += 1
+            if result.startswith(("No results found", "Search failed")):
+                empty += 1
+            print(f"[LOOP] Search returned {len(result)} chars")
+            internal['messages'].append({
+                "role": "tool",
+                "tool_call_id": tc.get('id', 'search_result'),
+                "content": result
+            })
+
+        # Client tool calls that arrive alongside a search cannot be executed
+        # here — stub them so the model does not stall waiting for results
+        for tc in client_calls:
+            name = tc.get('function', {}).get('name', 'unknown')
+            internal['messages'].append({
+                "role": "tool",
+                "tool_call_id": tc.get('id', 'stub'),
+                "content": f"[{name} result will be provided by the user's environment]"
+            })
+
+    print(f"[ERROR] Exceeded max search iterations ({searches} searches, {empty} empty)")
+    hint = " — SearXNG returned no usable results; check the SearXNG engines" if empty else ""
+    return Response(
+        json.dumps({'error': {
+            'message': f'Exceeded max search iterations ({searches} searches, {empty} empty){hint}',
+            'type': 'proxy_error'}}).encode(),
+        status=500, headers={'Content-Type': 'application/json'}
+    )
+
+
+def _stream_completion(completion):
+    """Re-stream a non-streaming completion as SSE."""
+    choice = completion.get('choices', [{}])[0]
+    message = choice.get('message', {})
+    content = message.get('content') or ''
+    reasoning = message.get('reasoning') or message.get('reasoning_content') or ''
+    tool_calls = message.get('tool_calls') or []
+    finish_reason = choice.get('finish_reason', 'stop')
+    cid = completion.get('id', 'chatcmpl-search')
+    model = completion.get('model', 'unknown')
+    usage = completion.get('usage')
+
+    def generate():
+        yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model,
+                    'choices': [{'index': 0, 'delta': {'role': 'assistant', 'content': ''},
+                                 'finish_reason': None}]})
+
+        if tool_calls:
+            for i, tc in enumerate(tool_calls):
+                yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model,
+                            'choices': [{'index': 0, 'delta': {'tool_calls': [{
+                                'index': i,
+                                'id': tc.get('id', ''),
+                                'type': 'function',
+                                'function': {
+                                    'name': tc.get('function', {}).get('name', ''),
+                                    'arguments': tc.get('function', {}).get('arguments', '')
+                                }
+                            }]}, 'finish_reason': None}]})
+        else:
+            if reasoning:
+                for i in range(0, len(reasoning), 64):
+                    yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model,
+                                'choices': [{'index': 0, 'delta': {'reasoning_content': reasoning[i:i + 64]},
+                                             'finish_reason': None}]})
+            for i in range(0, max(len(content), 1), 64):
+                yield _sse({'id': cid, 'object': 'chat.completion.chunk', 'model': model,
+                            'choices': [{'index': 0, 'delta': {'content': content[i:i + 64]},
+                                         'finish_reason': None}]})
+
+        final = {'id': cid, 'object': 'chat.completion.chunk', 'model': model,
+                 'choices': [{'index': 0, 'delta': {}, 'finish_reason': finish_reason}]}
+        if usage:
+            final['usage'] = usage
+        yield _sse(final)
+        yield b"data: [DONE]\n\n"
+
+    return Response(stream_with_context(generate()), status=200,
+                    headers={'Content-Type': 'text/event-stream',
+                             'Cache-Control': 'no-cache',
+                             'X-Accel-Buffering': 'no'})
+
+
+def _sse(obj):
+    return f"data: {json.dumps(obj)}\n\n".encode()
+
+
+def forward(method, url, headers, data):
+    """Transparently forward a request and stream the response back."""
+    hdrs = {k: v for k, v in headers.items() if k.lower() != 'host'}
+    if BACKEND_API_KEY:
+        hdrs['Authorization'] = f'Bearer {BACKEND_API_KEY}'
+    sess = requests.Session()
+    sess.headers.update(hdrs)
+    resp = sess.request(method=method, url=url, data=data,
+                        stream=True, timeout=(10, 600))
+
+    def generate():
+        for line in resp.iter_lines():
+            if line:
+                yield line + b'\n\n'
+
+    out_headers = {k: v for k, v in resp.headers.items()
+                   if k.lower() in {'content-type', 'cache-control', 'x-accel-buffering'}}
+    return Response(generate(), status=resp.status_code, headers=out_headers)
+
+
[email protected]('/<path:path>', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
+def proxy(path):
+    url = f"{BACKEND_URL}/{path}"
+    headers = dict(request.headers)
+    headers.pop('Host', None)
+    data = request.get_data()
+
+    if request.method == 'POST' and '/chat/completions' in path:
+        try:
+            json_data = json.loads(data)
+            client_tools = json_data.get('tools', [])
+            is_streaming = json_data.get('stream', False)
+            print(f"[DEBUG] Request keys: {list(json_data.keys())}, "
+                  f"tools={len(client_tools)}, stream={is_streaming}")
+
+            inject_search(json_data)
+
+            if is_streaming:
+                print("[FASTPATH] Streaming request — skipping probe, passing through natively")
+                return call_backend_passthrough(json_data, headers)
+
+            return run_loop(json_data, headers)
+
+        except Exception as e:
+            print(f"[ERROR] {e}")
+            import traceback; traceback.print_exc()
+            return Response(
+                json.dumps({'error': {'message': str(e), 'type': 'proxy_error'}}).encode(),
+                status=500, headers={'Content-Type': 'application/json'}
+            )
+
+    # Non-chat requests — forward directly
+    try:
+        return forward(request.method, url, headers, data)
+    except Exception as e:
+        print(f"[ERROR] Request failed: {e}")
+        return Response(
+            json.dumps({'error': {'message': str(e)}}).encode(),
+            status=500, headers={'Content-Type': 'application/json'}
+        )
+
+
+if __name__ == '__main__':
+    print("Starting PASSTHROUGH+SEARCH proxy (Ollama backend)")
+    print(f"Forwarding to: {BACKEND_URL}")
+    print(f"SearXNG: {SEARXNG_URL} (max {SEARXNG_RESULTS} results per search)")
+    app.run(host='0.0.0.0', port=11536, threaded=True)