Leon4gr45 commited on
Commit
4baaa2f
·
verified ·
1 Parent(s): 0add691

port verified llama.cpp CPU proxy: LFM2.5-VL-1.6B vision via mmproj, native OpenAI multimodal API, prebuilt binary

Browse files
Files changed (6) hide show
  1. Dockerfile +43 -12
  2. README.md +67 -6
  3. __pycache__/app.cpython-313.pyc +0 -0
  4. app.py +335 -65
  5. requirements.txt +4 -13
  6. test_vision.py +31 -0
Dockerfile CHANGED
@@ -1,19 +1,50 @@
1
- FROM python:3.9-slim
 
2
 
3
- WORKDIR /app
 
 
 
 
 
 
 
 
 
4
 
5
- # Install dependencies
6
- COPY requirements.txt requirements.txt
7
- RUN pip install --no-cache-dir --default-timeout=100 --retries=5 -r requirements.txt
 
 
8
 
9
- ENV HF_HOME=/app/.cache
10
- RUN mkdir -p $HF_HOME && chown -R 1000:1000 $HF_HOME
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- # Copy the app files
13
  COPY . .
14
 
15
- # Expose the port
16
- EXPOSE 7860
 
 
 
17
 
18
- # START THE SERVER (This line is critical!)
19
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
+ # Ubuntu 24.04 base to match the glibc of the official llama.cpp release binary.
2
+ FROM ubuntu:24.04
3
 
4
+ ENV DEBIAN_FRONTEND=noninteractive
5
+
6
+ RUN apt-get update && apt-get install -y \
7
+ curl \
8
+ ca-certificates \
9
+ libgomp1 \
10
+ libcurl4 \
11
+ python3 \
12
+ python3-pip \
13
+ && rm -rf /var/lib/apt/lists/*
14
 
15
+ RUN pip install --no-cache-dir --break-system-packages \
16
+ fastapi \
17
+ uvicorn \
18
+ httpx \
19
+ huggingface_hub
20
 
21
+ # ---------------------------------------------------------------------------
22
+ # Prebuilt llama.cpp CPU server (includes libmtmd -> vision support). No source
23
+ # compile: avoids the multimodal OOM that hangs a from-source build on 2 vCPUs.
24
+ # Ships per-microarch CPU variants selected at runtime -> portable & safe.
25
+ # ---------------------------------------------------------------------------
26
+ ARG LLAMA_TAG=b9895
27
+ RUN mkdir -p /llama.cpp/build/bin \
28
+ && curl -fsSL -o /tmp/llama.tar.gz \
29
+ "https://github.com/ggml-org/llama.cpp/releases/download/${LLAMA_TAG}/llama-${LLAMA_TAG}-bin-ubuntu-x64.tar.gz" \
30
+ && tar -xzf /tmp/llama.tar.gz -C /llama.cpp/build/bin --strip-components=1 \
31
+ && rm /tmp/llama.tar.gz \
32
+ && chmod +x /llama.cpp/build/bin/llama-server \
33
+ && /llama.cpp/build/bin/llama-server --version 2>&1 | head -5
34
+
35
+ ENV LD_LIBRARY_PATH=/llama.cpp/build/bin
36
+
37
+ WORKDIR /app
38
+
39
+ # Models (text GGUF + mmproj projector) are pulled at container startup by app.py.
40
+ RUN mkdir -p /models && chmod -R 777 /models
41
 
 
42
  COPY . .
43
 
44
+ EXPOSE 7860 8081
45
+
46
+ # Vision model + projector are small (~1.5GB total); start-period covers download.
47
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=300s --retries=3 \
48
+ CMD curl -f http://localhost:7860/health || exit 1
49
 
50
+ CMD ["python3", "app.py"]
 
README.md CHANGED
@@ -1,13 +1,74 @@
1
  ---
2
- title: Phi 3.5 & Chatbot & Vision App
3
- emoji: 🦋🦋🦋
4
  colorFrom: purple
5
  colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 4.42.0
8
- app_file: app.py
9
  pinned: false
10
  license: mit
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: LFM2.5-VL 1.6B Vision Inference (CPU)
3
+ emoji: 🦋
4
  colorFrom: purple
5
  colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
 
8
  pinned: false
9
  license: mit
10
  ---
11
 
12
+ # LFM2.5-VL 1.6B Vision Inference API (CPU)
13
+
14
+ CPU-only **vision-language** inference using `llama.cpp` behind a thin FastAPI
15
+ reverse proxy that speaks the **native OpenAI API** — including multimodal
16
+ `image_url` inputs, streaming, and the model's real chat template.
17
+
18
+ **Model**: [`LiquidAI/LFM2.5-VL-1.6B-GGUF`](https://huggingface.co/LiquidAI/LFM2.5-VL-1.6B-GGUF)
19
+ (Q4_0 text model + Q8_0 `mmproj` vision projector).
20
+
21
+ ## Why this design
22
+
23
+ Ported from the verified `gemma-inference` pattern:
24
+ - **Prebuilt llama.cpp CPU binary** (no source compile → no multimodal-build OOM);
25
+ the release ships `libmtmd` so **vision works out of the box** via `--mmproj`.
26
+ - **One instance, all cores**, concurrency via continuous-batching slots.
27
+ - **Native OpenAI passthrough** — send images as `image_url` data-URIs; llama.cpp
28
+ handles the CLIP encode + chat template. Streaming supported.
29
+ - Runtime CPU-variant dispatch → portable and safe on `cpu-basic`.
30
+
31
+ ## Usage
32
+
33
+ ### Describe an image (OpenAI multimodal format)
34
+ ```bash
35
+ IMG_B64=$(base64 -w0 photo.jpg)
36
+ curl -X POST https://Leon4gr45-phi-inference.hf.space/v1/chat/completions \
37
+ -H "Content-Type: application/json" \
38
+ -d "{
39
+ \"model\": \"lfm2.5-vl-1.6b\",
40
+ \"messages\": [{\"role\":\"user\",\"content\":[
41
+ {\"type\":\"text\",\"text\":\"Describe this image.\"},
42
+ {\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/jpeg;base64,${IMG_B64}\"}}
43
+ ]}],
44
+ \"max_tokens\": 256
45
+ }"
46
+ ```
47
+
48
+ ### Text-only chat also works
49
+ ```bash
50
+ curl -X POST https://Leon4gr45-phi-inference.hf.space/v1/chat/completions \
51
+ -H "Content-Type: application/json" \
52
+ -d '{"model":"lfm2.5-vl-1.6b","messages":[{"role":"user","content":"Hello"}],"max_tokens":64}'
53
+ ```
54
+
55
+ ### Health / config
56
+ ```bash
57
+ curl https://Leon4gr45-phi-inference.hf.space/health # "vision": true when mmproj loaded
58
+ curl https://Leon4gr45-phi-inference.hf.space/v1/config
59
+ ```
60
+
61
+ ## Configuration (env vars)
62
+
63
+ | Variable | Default | Description |
64
+ |----------|---------|-------------|
65
+ | `MODEL_FILENAME` | `LFM2.5-VL-1.6B-Q4_0.gguf` | text GGUF in the repo |
66
+ | `MMPROJ_FILENAME` | `mmproj-LFM2.5-VL-1.6b-Q8_0.gguf` | vision projector |
67
+ | `CPU_THREADS` | all vCPUs | generation threads |
68
+ | `PARALLEL` | `2` | continuous-batching slots |
69
+ | `CONTEXT_SIZE` | `8192` | total context (includes image tokens) |
70
+ | `KV_CACHE_QUANT` | `q8_0` | KV cache type |
71
+ | `FLASH_ATTN` | `true` | flash attention |
72
+
73
+ ## License
74
+ MIT
__pycache__/app.cpython-313.pyc ADDED
Binary file (20.3 kB). View file
 
app.py CHANGED
@@ -1,73 +1,343 @@
1
  import os
2
- import torch
3
- from fastapi import FastAPI, UploadFile, File, Depends
4
- from pydantic import BaseModel
5
- from transformers import AutoTokenizer, AutoModel
6
- from PIL import Image
7
- from typing import Optional
8
- import io
9
-
10
- app = FastAPI()
11
-
12
- model_id = "OpenGVLab/InternVL2_5-2B"
13
- device = "cpu"
14
-
15
- # Global variables for the model, initially empty
16
- model = None
17
- tokenizer = None
18
-
19
- def load_model_if_needed():
20
- global model, tokenizer
21
- if model is None:
22
- print("Loading model... this may take a moment.")
23
-
24
- # Load Tokenizer
25
- tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=True)
26
-
27
- # Load Model
28
- # We use bfloat16 to save memory (5GB vs 10GB)
29
- # We use low_cpu_mem_usage to load efficiently
30
- model = AutoModel.from_pretrained(
31
- model_id,
32
- trust_remote_code=True,
33
- torch_dtype=torch.bfloat16,
34
- low_cpu_mem_usage=True
35
- ).to(device).eval()
36
-
37
- print("Model loaded successfully!")
38
-
39
- class GenerateRequest(BaseModel):
40
- text_input: Optional[str] = None
41
- max_new_tokens: int = 1024
42
- do_sample: bool = False
43
-
44
- @app.post("/generate")
45
- async def generate(image: UploadFile = File(...), request: GenerateRequest = Depends()):
46
- # 1. Trigger the load on the first request
47
- load_model_if_needed()
48
-
49
- # Read image
50
- image_bytes = await image.read()
51
- pil_image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
52
-
53
- if request.text_input:
54
- prompt = f"<s><image>\n{request.text_input}</s>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  else:
56
- prompt = f"<s><image>\nDescribe the image.</s>"
 
 
 
 
57
 
58
- inputs = tokenizer(prompt, pil_image, return_tensors="pt").to(device)
 
 
59
 
60
- generation_args = {
61
- "max_new_tokens": request.max_new_tokens,
62
- "do_sample": request.do_sample,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
64
 
65
- with torch.no_grad():
66
- output_ids = model.generate(**inputs, **generation_args)
67
 
68
- response = tokenizer.decode(output_ids[0], skip_special_tokens=True)
69
- return {"generated_text": response}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- @app.get("/")
72
- async def read_root():
73
- return {"message": "InternVL2_5-2B API. Go to /docs for API documentation."}
 
1
  import os
2
+ import subprocess
3
+ import asyncio
4
+ import time
5
+ import threading
6
+ import httpx
7
+ from fastapi import FastAPI, Request, HTTPException
8
+ from fastapi.openapi.docs import get_swagger_ui_html
9
+ from fastapi.responses import JSONResponse, StreamingResponse
10
+
11
+ app = FastAPI(title="LFM2.5-VL 1.6B Vision Inference API (CPU)", version="2.0.0")
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Configuration — LFM2.5-VL-1.6B (vision) via llama.cpp + mmproj projector
15
+ # ---------------------------------------------------------------------------
16
+ MODEL_PATH = os.getenv("MODEL_PATH", "/models/LFM2.5-VL-1.6B-Q4_0.gguf")
17
+ MMPROJ_PATH = os.getenv("MMPROJ_PATH", "/models/mmproj-LFM2.5-VL-1.6b-Q8_0.gguf")
18
+ MODEL_REPO = os.getenv("MODEL_REPO", "LiquidAI/LFM2.5-VL-1.6B-GGUF")
19
+ MODEL_FILENAME = os.getenv("MODEL_FILENAME", "LFM2.5-VL-1.6B-Q4_0.gguf")
20
+ MMPROJ_FILENAME = os.getenv("MMPROJ_FILENAME", "mmproj-LFM2.5-VL-1.6b-Q8_0.gguf")
21
+ MODEL_ALIAS = os.getenv("MODEL_ALIAS", "lfm2.5-vl-1.6b")
22
+ MAIN_PORT = int(os.getenv("MAIN_PORT", "7860"))
23
+
24
+ NUM_INSTANCES = int(os.getenv("NUM_INSTANCES", "1"))
25
+ BASE_PORT = int(os.getenv("BASE_PORT", "8081"))
26
+ INSTANCE_PORTS = [BASE_PORT + i for i in range(NUM_INSTANCES)]
27
+ INSTANCE_URLS = [f"http://127.0.0.1:{p}" for p in INSTANCE_PORTS]
28
+ MODEL_LOADED = [False] * NUM_INSTANCES
29
+ LLAMA_PROCESSES = [None] * NUM_INSTANCES
30
+
31
+ _CPU = os.cpu_count() or 4
32
+ CPU_THREADS = int(os.getenv("CPU_THREADS", str(_CPU)))
33
+ CPU_THREADS_BATCH = int(os.getenv("CPU_THREADS_BATCH", str(_CPU)))
34
+ CONTEXT_SIZE = int(os.getenv("CONTEXT_SIZE", "8192")) # incl. image tokens; per-slot = ctx/parallel
35
+ PARALLEL = int(os.getenv("PARALLEL", "2"))
36
+ BATCH_SIZE = int(os.getenv("BATCH_SIZE", "512"))
37
+ UBATCH_SIZE = int(os.getenv("UBATCH_SIZE", "512"))
38
+ KV_CACHE_QUANT = os.getenv("KV_CACHE_QUANT", "q8_0") # q8_0 default: safer for VL quality
39
+ _KV_ALIASES = {"2bit": "q4_0", "4bit": "q4_0", "8bit": "q8_0",
40
+ "16bit": "f16", "fp16": "f16", "q4": "q4_0", "q8": "q8_0"}
41
+ _VALID_KV = {"f16", "q8_0", "q4_0", "q4_1", "q5_0", "q5_1", "iq4_nl"}
42
+ KV_CACHE_QUANT = _KV_ALIASES.get(KV_CACHE_QUANT.lower(), KV_CACHE_QUANT)
43
+ if KV_CACHE_QUANT not in _VALID_KV:
44
+ KV_CACHE_QUANT = "q8_0"
45
+ FLASH_ATTN = os.getenv("FLASH_ATTN", "true").lower() == "true"
46
+ MMAP_ENABLED = os.getenv("MMAP_ENABLED", "true").lower() == "true"
47
+ MLOCK_ENABLED = os.getenv("MLOCK_ENABLED", "false").lower() == "true"
48
+
49
+ HTTP: httpx.AsyncClient | None = None
50
+ _rr_counter = 0
51
+ _rr_lock = asyncio.Lock()
52
+
53
+ print("=" * 60)
54
+ print("LFM2.5-VL 1.6B Vision Inference API (v2.0.0)")
55
+ print(f" detected vCPUs : {_CPU}")
56
+ print(f" model : {MODEL_PATH}")
57
+ print(f" mmproj (vision) : {MMPROJ_PATH}")
58
+ print(f" threads : {CPU_THREADS} (batch {CPU_THREADS_BATCH})")
59
+ print(f" context (total) : {CONTEXT_SIZE} parallel slots: {PARALLEL}")
60
+ print(f" kv-cache quant : {KV_CACHE_QUANT} flash-attn: {FLASH_ATTN}")
61
+ print("=" * 60)
62
+
63
+
64
+ def _find_binary():
65
+ for p in ("/llama.cpp/build/bin/llama-server",
66
+ "/llama.cpp/build/bin/server",
67
+ "/usr/local/bin/llama-server"):
68
+ if os.path.exists(p):
69
+ return p
70
+ try:
71
+ found = subprocess.run(["find", "/llama.cpp", "-name", "llama-server"],
72
+ capture_output=True, text=True).stdout.strip().split("\n")
73
+ if found and found[0]:
74
+ return found[0]
75
+ except Exception:
76
+ pass
77
+ return None
78
+
79
+
80
+ def _help_text(binary):
81
+ try:
82
+ r = subprocess.run([binary, "--help"], capture_output=True, text=True, timeout=30)
83
+ return (r.stdout or "") + (r.stderr or "")
84
+ except Exception:
85
+ return ""
86
+
87
+
88
+ def _download_one(dest_path, filename):
89
+ if os.path.exists(dest_path):
90
+ return True
91
+ print(f"Downloading {filename} from {MODEL_REPO} -> {dest_path} ...")
92
+ try:
93
+ from huggingface_hub import hf_hub_download
94
+ import shutil
95
+ dl = hf_hub_download(repo_id=MODEL_REPO, filename=filename,
96
+ repo_type="model", token=os.getenv("HF_TOKEN"))
97
+ if dl != dest_path:
98
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
99
+ shutil.copy2(dl, dest_path)
100
+ print(f"✅ ready: {dest_path}")
101
+ return True
102
+ except Exception as e:
103
+ print(f"❌ download failed for {filename}: {e}")
104
+ return False
105
+
106
+
107
+ def _download_model():
108
+ ok = _download_one(MODEL_PATH, MODEL_FILENAME)
109
+ # mmproj is what enables vision; without it the model is text-only.
110
+ _download_one(MMPROJ_PATH, MMPROJ_FILENAME)
111
+ return ok
112
+
113
+
114
+ def build_cmd(binary, port, help_text):
115
+ def has(flag):
116
+ return flag in help_text
117
+
118
+ cmd = [binary,
119
+ "--model", MODEL_PATH,
120
+ "--host", "127.0.0.1",
121
+ "--port", str(port),
122
+ "--ctx-size", str(CONTEXT_SIZE),
123
+ "--threads", str(CPU_THREADS),
124
+ "--batch-size", str(BATCH_SIZE)]
125
+
126
+ # Vision projector — enables multimodal (image_url) input.
127
+ if os.path.exists(MMPROJ_PATH) and has("--mmproj"):
128
+ cmd += ["--mmproj", MMPROJ_PATH]
129
+
130
+ if has("--threads-batch"):
131
+ cmd += ["--threads-batch", str(CPU_THREADS_BATCH)]
132
+ if has("--ubatch-size"):
133
+ cmd += ["--ubatch-size", str(UBATCH_SIZE)]
134
+ if has("--parallel"):
135
+ cmd += ["--parallel", str(PARALLEL)]
136
+ if has("--cont-batching"):
137
+ cmd += ["--cont-batching"]
138
+ if has("--alias"):
139
+ cmd += ["--alias", MODEL_ALIAS]
140
+
141
+ if FLASH_ATTN and has("--flash-attn"):
142
+ fa_line = next((l for l in help_text.splitlines() if "--flash-attn" in l), "")
143
+ if any(t in fa_line for t in ("{on", "[on", "on|off", "on,off")):
144
+ cmd += ["--flash-attn", "on"]
145
+ else:
146
+ cmd += ["--flash-attn"]
147
+
148
+ if KV_CACHE_QUANT and KV_CACHE_QUANT != "f16":
149
+ if has("--cache-type-k"):
150
+ cmd += ["--cache-type-k", KV_CACHE_QUANT]
151
+ if FLASH_ATTN and has("--cache-type-v"):
152
+ cmd += ["--cache-type-v", KV_CACHE_QUANT]
153
+
154
+ if has("--jinja"):
155
+ cmd += ["--jinja"]
156
+
157
+ if MLOCK_ENABLED and has("--mlock"):
158
+ cmd += ["--mlock"]
159
+ if has("--mmap") or has("--no-mmap"):
160
+ cmd += ["--mmap"] if MMAP_ENABLED else ["--no-mmap"]
161
+
162
+ return cmd
163
+
164
+
165
+ def start_llama_server(idx):
166
+ port = INSTANCE_PORTS[idx]
167
+ if not _download_model():
168
+ MODEL_LOADED[idx] = False
169
+ return False
170
+
171
+ binary = _find_binary()
172
+ if not binary:
173
+ print(f"Instance {idx}: ❌ llama-server binary not found")
174
+ return False
175
+
176
+ cmd = build_cmd(binary, port, _help_text(binary))
177
+ print(f"Instance {idx}: launching -> {' '.join(cmd)}")
178
+
179
+ try:
180
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
181
+ LLAMA_PROCESSES[idx] = proc
182
+
183
+ def log_output(p, i):
184
+ for line in p.stdout:
185
+ print(f"Instance {i} LOG: {line.rstrip()}")
186
+ threading.Thread(target=log_output, args=(proc, idx), daemon=True).start()
187
+
188
+ # Sleep on every non-200 (llama-server returns 503 while loading), not
189
+ # only on connection exceptions, or the loop would exit instantly.
190
+ for _ in range(900):
191
+ if proc.poll() is not None:
192
+ print(f"Instance {idx}: ❌ exited with code {proc.returncode}")
193
+ return False
194
+ try:
195
+ r = httpx.get(f"{INSTANCE_URLS[idx]}/health", timeout=3.0)
196
+ if r.status_code == 200:
197
+ MODEL_LOADED[idx] = True
198
+ print(f"Instance {idx}: ✅ ready on port {port}")
199
+ return True
200
+ except Exception:
201
+ pass
202
+ time.sleep(1)
203
+ print(f"Instance {idx}: ❌ startup timeout")
204
+ return False
205
+ except Exception as e:
206
+ print(f"Instance {idx}: error: {e}")
207
+ return False
208
+
209
+
210
+ async def pick_instance():
211
+ global _rr_counter
212
+ async with _rr_lock:
213
+ for k in range(NUM_INSTANCES):
214
+ idx = (_rr_counter + k) % NUM_INSTANCES
215
+ if MODEL_LOADED[idx]:
216
+ _rr_counter = (idx + 1) % NUM_INSTANCES
217
+ return idx
218
+ return None
219
+
220
+
221
+ @app.on_event("startup")
222
+ async def startup_event():
223
+ global HTTP
224
+ HTTP = httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=600.0),
225
+ limits=httpx.Limits(max_connections=64, max_keepalive_connections=32))
226
+ print(f"Starting {NUM_INSTANCES} llama.cpp instance(s)...")
227
+ results = await asyncio.gather(
228
+ *[asyncio.to_thread(start_llama_server, i) for i in range(NUM_INSTANCES)],
229
+ return_exceptions=True)
230
+ loaded = sum(1 for r in results if r is True)
231
+ print(f"✅ {loaded}/{NUM_INSTANCES} instance(s) loaded")
232
+
233
+
234
+ @app.on_event("shutdown")
235
+ async def shutdown_event():
236
+ if HTTP:
237
+ await HTTP.aclose()
238
+ for p in LLAMA_PROCESSES:
239
+ if p and p.poll() is None:
240
+ p.terminate()
241
+
242
+
243
+ async def proxy(request: Request, path: str):
244
+ idx = await pick_instance()
245
+ if idx is None:
246
+ raise HTTPException(status_code=503, detail="No healthy instances available")
247
+ url = f"{INSTANCE_URLS[idx]}{path}"
248
+ body = await request.body()
249
+ try:
250
+ payload = await request.json()
251
+ except Exception:
252
+ payload = {}
253
+ stream = bool(payload.get("stream", False))
254
+ headers = {"Content-Type": "application/json"}
255
+
256
+ if stream:
257
+ req = HTTP.build_request("POST", url, content=body, headers=headers)
258
+ r = await HTTP.send(req, stream=True)
259
+
260
+ async def gen():
261
+ try:
262
+ async for chunk in r.aiter_raw():
263
+ yield chunk
264
+ finally:
265
+ await r.aclose()
266
+
267
+ return StreamingResponse(gen(), status_code=r.status_code,
268
+ media_type=r.headers.get("content-type", "text/event-stream"),
269
+ headers={"X-Backend-Instance": str(idx)})
270
  else:
271
+ r = await HTTP.post(url, content=body, headers=headers)
272
+ return JSONResponse(status_code=r.status_code,
273
+ content=r.json() if r.content else {},
274
+ headers={"X-Backend-Instance": str(idx)})
275
+
276
 
277
+ @app.get("/", include_in_schema=False)
278
+ async def swagger():
279
+ return get_swagger_ui_html(openapi_url=app.openapi_url, title=app.title + " - Swagger UI")
280
 
281
+
282
+ @app.get("/health")
283
+ async def health():
284
+ return {
285
+ "status": "ok" if any(MODEL_LOADED) else "degraded",
286
+ "instances": [
287
+ {"id": i, "port": INSTANCE_PORTS[i], "loaded": MODEL_LOADED[i], "url": INSTANCE_URLS[i]}
288
+ for i in range(NUM_INSTANCES)
289
+ ],
290
+ "active_instances": sum(MODEL_LOADED),
291
+ "total_instances": NUM_INSTANCES,
292
+ "vision": os.path.exists(MMPROJ_PATH),
293
+ "cpu_threads": CPU_THREADS,
294
+ "context_size": CONTEXT_SIZE,
295
+ "parallel_slots": PARALLEL,
296
+ "kv_cache_quant": KV_CACHE_QUANT,
297
+ "flash_attn": FLASH_ATTN,
298
  }
299
 
 
 
300
 
301
+ @app.get("/v1/config")
302
+ async def config():
303
+ return {
304
+ "model_path": MODEL_PATH, "mmproj_path": MMPROJ_PATH,
305
+ "vision_enabled": os.path.exists(MMPROJ_PATH),
306
+ "model_alias": MODEL_ALIAS, "detected_vcpus": _CPU,
307
+ "instances": NUM_INSTANCES, "ports": INSTANCE_PORTS,
308
+ "cpu_threads": CPU_THREADS, "threads_batch": CPU_THREADS_BATCH,
309
+ "context_size": CONTEXT_SIZE, "parallel_slots": PARALLEL,
310
+ "batch_size": BATCH_SIZE, "ubatch_size": UBATCH_SIZE,
311
+ "kv_cache_quant": KV_CACHE_QUANT, "flash_attn": FLASH_ATTN,
312
+ "mmap_enabled": MMAP_ENABLED, "mlock_enabled": MLOCK_ENABLED,
313
+ "instances_status": [{"id": i, "loaded": MODEL_LOADED[i]} for i in range(NUM_INSTANCES)],
314
+ }
315
+
316
+
317
+ @app.get("/v1/models")
318
+ async def models():
319
+ idx = await pick_instance()
320
+ if idx is not None:
321
+ try:
322
+ r = await HTTP.get(f"{INSTANCE_URLS[idx]}/v1/models", timeout=10.0)
323
+ if r.status_code == 200:
324
+ return JSONResponse(content=r.json())
325
+ except Exception:
326
+ pass
327
+ return {"object": "list", "data": [{"id": MODEL_ALIAS, "object": "model", "owned_by": "Leon4gr45"}]}
328
+
329
+
330
+ # OpenAI-compatible; vision works via image_url content parts (mmproj loaded).
331
+ @app.post("/v1/chat/completions")
332
+ async def chat_completions(request: Request):
333
+ return await proxy(request, "/v1/chat/completions")
334
+
335
+
336
+ @app.post("/v1/completions")
337
+ async def completions(request: Request):
338
+ return await proxy(request, "/v1/completions")
339
+
340
 
341
+ if __name__ == "__main__":
342
+ import uvicorn
343
+ uvicorn.run(app, host="0.0.0.0", port=MAIN_PORT)
requirements.txt CHANGED
@@ -1,13 +1,4 @@
1
- fastapi==0.112.4
2
- uvicorn==0.40.0
3
- torch
4
- transformers==4.43.0
5
- accelerate==0.30.0
6
- sentencepiece
7
- python-dotenv
8
- pydantic==2.12.5
9
- einops
10
- numpy==1.24.4
11
- Pillow==10.3.0
12
- Requests==2.31.0
13
- timm
 
1
+ fastapi
2
+ uvicorn
3
+ httpx
4
+ huggingface_hub
 
 
 
 
 
 
 
 
 
test_vision.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke test: text chat + vision (image_url) against the phi_inference Space."""
2
+ import base64
3
+ import json
4
+ import sys
5
+ import requests
6
+
7
+ BASE = "https://Leon4gr45-phi-inference.hf.space"
8
+
9
+ # 1) text-only
10
+ r = requests.post(f"{BASE}/v1/chat/completions", json={
11
+ "model": "lfm2.5-vl-1.6b",
12
+ "messages": [{"role": "user", "content": "Say hello in one sentence."}],
13
+ "max_tokens": 48,
14
+ }, timeout=180)
15
+ print("TEXT:", json.dumps(r.json(), indent=2)[:500])
16
+
17
+ # 2) vision — pass an image path as argv[1], else skip
18
+ if len(sys.argv) > 1:
19
+ with open(sys.argv[1], "rb") as f:
20
+ b64 = base64.b64encode(f.read()).decode()
21
+ r = requests.post(f"{BASE}/v1/chat/completions", json={
22
+ "model": "lfm2.5-vl-1.6b",
23
+ "messages": [{"role": "user", "content": [
24
+ {"type": "text", "text": "Describe this image in detail."},
25
+ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
26
+ ]}],
27
+ "max_tokens": 256,
28
+ }, timeout=300)
29
+ print("VISION:", r.json()["choices"][0]["message"]["content"])
30
+ else:
31
+ print("(pass an image path as argv[1] to test vision)")