File size: 5,717 Bytes
f313e74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0a1a6fb
f313e74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""
ClinIQ — Modal deployment.
Runs Qwen2.5-3B-Instruct GGUF via llama.cpp on A10G GPU.

Earns: 🦙 Llama Champion (llama.cpp) + 🐜 Tiny Titan (≤4B params)

Deploy:   modal deploy modal_inference.py
Test:     modal run modal_inference.py
Endpoint: printed after deploy — set as MODAL_ENDPOINT Space secret
"""

import modal

# ── Image: llama.cpp + CUDA + model baked in ───────────────────────────────────

REPO_ID  = "bartowski/Qwen2.5-3B-Instruct-GGUF"
FILENAME = "Qwen2.5-3B-Instruct-Q4_K_M.gguf"
MODEL_PATH = f"/model/{FILENAME}"

image = (
    # Use CUDA 12.4 devel image — includes nvcc and toolkit needed to build llama.cpp with GPU
    modal.Image.from_registry(
        "nvidia/cuda:12.4.0-devel-ubuntu22.04",
        add_python="3.11",
    )
    .apt_install("git", "cmake", "build-essential", "libcurl4-openssl-dev", "wget")
    .run_commands(
        "git clone --depth 1 https://github.com/ggerganov/llama.cpp /llama.cpp",
        # Disable tests (they fail to link libcuda stubs in build containers)
        # Link against CUDA stubs so the shared libs resolve during build
        "cd /llama.cpp && cmake -B build "
        "  -DGGML_CUDA=ON "
        "  -DCMAKE_CUDA_ARCHITECTURES=86 "
        "  -DLLAMA_BUILD_TESTS=OFF "
        "  -DLLAMA_BUILD_EXAMPLES=OFF "
        "  -DCMAKE_EXE_LINKER_FLAGS='-L/usr/local/cuda/lib64/stubs -lcuda' "
        "  -DCMAKE_SHARED_LINKER_FLAGS='-L/usr/local/cuda/lib64/stubs -lcuda' "
        "&& cmake --build build --config Release --target llama-server -j$(nproc)",
        "cp /llama.cpp/build/bin/llama-server /usr/local/bin/llama-server",
    )
    .pip_install("huggingface_hub>=0.24.0", "httpx", "fastapi[standard]")
    .env({"HF_XET_HIGH_PERFORMANCE": "1"})
    .run_commands(
        f"hf download {REPO_ID} {FILENAME} --local-dir /model"
    )
)

app = modal.App("cliniq-inference", image=image)


# ── Inference class ────────────────────────────────────────────────────────────

@app.cls(
    gpu="A10G",
    scaledown_window=300,
)
@modal.concurrent(max_inputs=8)
class LlamaCppServer:

    @modal.enter()
    def start(self):
        import subprocess, time, httpx

        self._proc = subprocess.Popen(
            [
                "llama-server",
                "--model",        MODEL_PATH,
                "--ctx-size",     "4096",
                "--n-gpu-layers", "99",
                "--port",         "8080",
                "--host",         "127.0.0.1",
                "--threads",      "4",
            ],
        )
        # Poll until healthy
        for _ in range(90):
            try:
                if httpx.get("http://127.0.0.1:8080/health", timeout=2).status_code == 200:
                    print("✅ llama-server ready")
                    return
            except Exception:
                pass
            time.sleep(2)
        raise RuntimeError("llama-server did not start in 3 minutes")

    @modal.exit()
    def stop(self):
        self._proc.terminate()

    @modal.method()
    def generate(self, prompt: str, max_tokens: int = 600, json_mode: bool = False) -> str:
        import httpx

        payload = {
            "prompt":      prompt,
            "n_predict":   max_tokens,
            "temperature": 0.0,
            "stop":        ["<|im_end|>", "<|endoftext|>", "<|im_start|>"],
        }
        r = httpx.post("http://127.0.0.1:8080/completion", json=payload, timeout=120)
        r.raise_for_status()
        return r.json()["content"].strip()


# ── Web endpoint (called from Gradio Space) ────────────────────────────────────

@app.function()
@modal.fastapi_endpoint(method="POST", label="cliniq-infer")
def infer(item: dict) -> dict:
    """
    POST body: {"prompt": str, "max_tokens": int, "json_mode": bool}
    Returns:   {"text": str}
    """
    server = LlamaCppServer()
    text = server.generate.remote(
        item["prompt"],
        item.get("max_tokens", 600),
        item.get("json_mode", False),
    )
    return {"text": text}


# ── Health check endpoint ──────────────────────────────────────────────────────

@app.function()
@modal.fastapi_endpoint(method="GET", label="cliniq-health")
def health() -> dict:
    return {"status": "ok", "model": FILENAME}


# ── Local test ─────────────────────────────────────────────────────────────────

@app.local_entrypoint()
def test():
    server = LlamaCppServer()
    prompt = (
        "<|im_start|>system\nYou are a helpful clinical assistant.<|im_end|>\n"
        "<|im_start|>user\nWhat are the first-line treatments for community-acquired pneumonia?<|im_end|>\n"
        "<|im_start|>assistant\n"
    )
    print("\n=== Test Output ===")
    print(server.generate.remote(prompt, max_tokens=300))
    print("\n=== Structured Test ===")
    struct_prompt = (
        "<|im_start|>system\nExtract as JSON only.<|im_end|>\n"
        "<|im_start|>user\n"
        "Document: Patient takes Metformin 1000mg BID and Lisinopril 10mg daily. "
        "Allergic to Penicillin (rash).\n"
        'List medications as JSON: [{"name":"...","dose":"...","frequency":"..."}]<|im_end|>\n'
        "<|im_start|>assistant\n"
    )
    print(server.generate.remote(struct_prompt, max_tokens=200, json_mode=True))