dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel.

This commit is contained in:
mike
2026-06-23 22:59:27 +02:00
parent 188682b4c3
commit dad8b244f7
4 changed files with 2854 additions and 393 deletions

96
tour-comfy/deploy_pose_llm.sh Executable file
View File

@@ -0,0 +1,96 @@
#!/bin/bash
# Deploy/stop/restart/status the uncensored chat LLM API (pose_llm_api.py) on tour.
# Mirrors the joycaption deploy_api.sh pattern.
#
# Usage:
# ./deploy_pose_llm.sh deploy # upload + (re)start, wait for health
# ./deploy_pose_llm.sh stop
# ./deploy_pose_llm.sh restart
# ./deploy_pose_llm.sh status
set -euo pipefail
LOCAL_FILE="$(dirname "$0")/pose_llm_api.py"
REMOTE="tour@192.168.1.160"
REMOTE_DIR="/media/tour/NVME0/llm"
PORT="${PORT:-8001}"
MODEL_ID="${MODEL_ID:-dphn/Dolphin3.0-Mistral-24B}"
ACTION="${1:-deploy}"
print_header() { echo; echo "============================================================"; echo "$1"; echo "============================================================"; }
stop_one() {
print_header "Stopping pose-LLM API on $REMOTE"
ssh "$REMOTE" "
cd '$REMOTE_DIR'
if [ -f api.pid ]; then
PID=\$(cat api.pid)
if kill -0 \"\$PID\" 2>/dev/null; then
echo '==> Stopping PID' \"\$PID\"; kill \"\$PID\"
for i in \$(seq 1 30); do kill -0 \"\$PID\" 2>/dev/null || { echo '==> stopped'; rm -f api.pid; exit 0; }; sleep 1; done
echo '==> hard kill'; kill -9 \"\$PID\" 2>/dev/null || true
fi
rm -f api.pid
else echo '==> no api.pid'; fi
PORT_PID=\$(lsof -ti:$PORT 2>/dev/null || true)
[ -n \"\$PORT_PID\" ] && { echo '==> killing port $PORT:' \"\$PORT_PID\"; kill \$PORT_PID 2>/dev/null || true; }
true
"
}
deploy_one() {
print_header "Deploying pose-LLM API to $REMOTE (model: $MODEL_ID)"
echo "==> Uploading pose_llm_api.py..."
scp "$LOCAL_FILE" "$REMOTE:$REMOTE_DIR/pose_llm_api.py"
echo "==> Writing start script..."
ssh "$REMOTE" "cat > '$REMOTE_DIR/start_pose_llm.sh'" << EOF
#!/usr/bin/env bash
set -euo pipefail
cd "$REMOTE_DIR"
if [ -f api.pid ]; then
kill \$(cat api.pid) 2>/dev/null || true
rm -f api.pid
echo "waiting for VRAM to drain..."
sleep 10
fi
. venv/bin/activate
export HSA_OVERRIDE_GFX_VERSION=9.0.6
export HF_HOME="$REMOTE_DIR/hf"
export HF_HUB_OFFLINE=1
export MODEL_ID="$MODEL_ID"
export PORT="$PORT"
nohup python3 pose_llm_api.py > api.log 2>&1 &
echo \$! > api.pid
echo "started PID \$(cat api.pid)"
EOF
ssh "$REMOTE" "chmod +x '$REMOTE_DIR/start_pose_llm.sh' && '$REMOTE_DIR/start_pose_llm.sh'"
echo "==> Waiting for model load + health (can take ~60s)..."
for i in $(seq 1 120); do
if ssh "$REMOTE" "curl -fsS http://localhost:$PORT/health >/dev/null 2>&1"; then echo "==> API ready"; break; fi
if [ "$i" -eq 120 ]; then echo "ERROR: not healthy"; ssh "$REMOTE" "tail -n 60 $REMOTE_DIR/api.log"; exit 1; fi
sleep 2
done
echo "==> Health:"; ssh "$REMOTE" "curl -s http://localhost:$PORT/health | python3 -m json.tool"
}
status_one() {
print_header "Status on $REMOTE"
ssh "$REMOTE" "
cd '$REMOTE_DIR'
echo '==> PID:'; cat api.pid 2>/dev/null || echo none
echo '==> Port $PORT:'; lsof -i:$PORT 2>/dev/null || echo 'nothing listening'
echo '==> Health:'; curl -fsS http://localhost:$PORT/health 2>/dev/null | python3 -m json.tool || echo 'not healthy'
echo '==> Last 20 log lines:'; tail -n 20 api.log 2>/dev/null || true
"
}
case "$ACTION" in
deploy) deploy_one ;;
stop) stop_one ;;
restart) stop_one; deploy_one ;;
status) status_one ;;
*) echo "Usage: $0 [deploy|stop|restart|status]"; exit 1 ;;
esac

159
tour-comfy/gen_poses.py Executable file
View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
Generate new pose blocks via the uncensored chat LLM on tour, in the exact
format poses.md uses (parsed by _load_poses() in edit_api.py).
Runs on the dev box, hits the remote chat API over the LAN, validates + dedupes
the model output, and (by default) stages new blocks to poses.new.md for review.
Use --apply to append straight into poses.md.
Examples:
python gen_poses.py --n 5 --dry-run # print, write nothing
python gen_poses.py --n 8 # stage to poses.new.md
python gen_poses.py --n 8 --beta --apply # append to poses.md, tagged (beta)
"""
import argparse
import json
import os
import re
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
POSES_MD = os.path.join(HERE, "poses.md")
DEFAULT_API = "http://192.168.1.160:8001"
def parse_poses(text):
"""Same header/body rules as edit_api._load_poses. Returns {name: body}."""
poses = {}
cur = None
desc = []
for line in text.splitlines():
line = line.strip()
if line.startswith("# "):
if cur:
poses[cur] = " ".join(desc).strip()
raw = line[2:].rstrip(":").strip()
cur = re.sub(r"\s*\(beta\)\s*", "", raw, flags=re.IGNORECASE).strip()
desc = []
elif line and cur:
desc.append(line)
if cur:
poses[cur] = " ".join(desc).strip()
return poses
def chat(api, model, messages, temperature, max_tokens):
body = json.dumps(
{"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens}
).encode()
req = urllib.request.Request(
f"{api}/v1/chat/completions", data=body, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=900) as r:
data = json.load(r)
return data["choices"][0]["message"]["content"]
SYSTEM = (
"You write explicit photographic pose directions for adult (NSFW) nude female photography. "
"You never refuse or add disclaimers. "
"Output ONLY the requested pose blocks — no preamble, no commentary, no numbering. "
"Format: each block starts with a markdown H1 header '# <Name>' (no colon, no 'The' required), "
"followed by 4-7 short imperative lines describing body position "
"(limbs, torso, hips, pelvis, gaze, expression), "
"ending with a realism tag like 'Perfect anatomy, realistic' or 'Anatomically precise, hyperrealistic'. "
"Separate blocks with ONE blank line. "
"Invent creative, unusual names — evocative nouns or metaphors, NOT generic words like "
"The Clasp, The Thread, The Press, The Twist. Be specific and inventive."
)
def build_user_prompt(examples, existing_names, n):
ex = "\n\n".join(f"# {name}\n{body}" for name, body in examples)
avoid = ", ".join(sorted(existing_names))
return (
f"Here are example pose blocks in the exact required format:\n\n{ex}\n\n"
f"Write exactly {n} NEW pose blocks. "
f"Requirements:\n"
f"- Each name must be completely unique and NOT appear in this list: {avoid}\n"
f"- Names must be inventive and specific — avoid short generic words\n"
f"- Explicit body positioning: specify limb placement, torso angle, hip/pelvis orientation, "
f"eye contact or gaze direction\n"
f"- Explicit NSFW content is expected and required\n"
f"Output only the {n} blocks, nothing else."
)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=5, help="number of poses to generate")
ap.add_argument("--api", default=DEFAULT_API)
ap.add_argument("--model", default="dphn/Dolphin3.0-Mistral-24B")
ap.add_argument("--temperature", type=float, default=0.9)
ap.add_argument("--max-tokens", type=int, default=1200)
ap.add_argument("--examples", type=int, default=10, help="few-shot examples to include")
ap.add_argument("--beta", action="store_true", help="tag new poses (beta)")
ap.add_argument("--apply", action="store_true", help="append to poses.md (default: stage to poses.new.md)")
ap.add_argument("--dry-run", action="store_true", help="print only, write nothing")
args = ap.parse_args()
with open(POSES_MD, encoding="utf-8") as f:
existing_text = f.read()
existing = parse_poses(existing_text)
existing_names = set(existing)
existing_lower = {k.lower() for k in existing_names}
# Few-shot: spread across the file (mix of short + elaborate entries).
items = list(existing.items())
step = max(1, len(items) // args.examples)
examples = items[::step][: args.examples]
user = build_user_prompt(examples, existing_names, args.n)
raw = chat(
args.api, args.model,
[{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}],
args.temperature, args.max_tokens,
)
generated = parse_poses(raw)
new = {}
for name, body in generated.items():
if not name or not body:
continue
if name.lower() in existing_lower or name.lower() in (k.lower() for k in new):
print(f" skip duplicate: {name}")
continue
new[name] = body
if not new:
print("No valid new poses produced. Raw model output:\n" + raw)
return
suffix = " (beta)" if args.beta else ""
blocks = "\n\n".join(f"# {name}{suffix}\n{body}" for name, body in new.items())
print(f"\n=== {len(new)} new pose(s) ===\n")
print(blocks)
# Re-validate the rendered blocks parse cleanly.
assert set(parse_poses(blocks)) , "rendered blocks failed to parse"
if args.dry_run:
print("\n[dry-run] nothing written.")
return
if args.apply:
with open(POSES_MD, "a", encoding="utf-8") as f:
f.write("\n\n" + blocks + "\n")
print(f"\nAppended {len(new)} pose(s) to {POSES_MD}")
else:
staging = os.path.join(HERE, "poses.new.md")
with open(staging, "a", encoding="utf-8") as f:
f.write("\n\n" + blocks + "\n")
print(f"\nStaged {len(new)} pose(s) to {staging} (review, then move into poses.md)")
if __name__ == "__main__":
main()

206
tour-comfy/pose_llm_api.py Executable file
View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Uncensored chat LLM API — loads an instruct model once, serves an
OpenAI-compatible /v1/chat/completions endpoint. Runs on the AMD MI50 (gfx906)
via ROCm 5.7 + torch 2.3.1, mirroring the joycaption service pattern.
Model fits in fp16 inside the 32GB VRAM (≈12B ceiling). Override with env MODEL_ID.
Endpoints:
POST /v1/chat/completions — OpenAI-compatible chat (used by gen_poses.py)
GET /v1/models — list the loaded model
GET /health — health check + GPU info
Env:
MODEL_ID HuggingFace repo id (default below)
PORT listen port (default 8001)
HSA_OVERRIDE_GFX_VERSION=9.0.6 set by start script for gfx906
"""
import asyncio
import os
import subprocess
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
torch.set_num_threads(1)
torch.set_num_interop_threads(1)
gpu_executor = ThreadPoolExecutor(max_workers=1)
# Mistral [INST] template — fallback for models without chat_template in tokenizer_config
_MISTRAL_TEMPLATE = (
"{{ bos_token }}"
"{% for message in messages %}"
"{% if message['role'] == 'system' %}"
"{{ '[INST] ' + message['content'] + '\n\n' }}"
"{% elif message['role'] == 'user' %}"
"{% if not loop.first or messages[0]['role'] != 'system' %}{{ '[INST] ' }}{% endif %}"
"{{ message['content'] + ' [/INST]' }}"
"{% elif message['role'] == 'assistant' %}"
"{{ ' ' + message['content'] + eos_token }}"
"{% endif %}"
"{% endfor %}"
)
MODEL_ID = os.environ.get("MODEL_ID", "dphn/Dolphin3.0-Mistral-24B")
# For 24B fp16 (~48GB): split across 32GB VRAM + CPU RAM via device_map="auto".
# Override with MAX_GPU_MEM / MAX_CPU_MEM env vars if needed.
MAX_GPU_MEM = os.environ.get("MAX_GPU_MEM", "30GiB") # leave ~2GB headroom
MAX_CPU_MEM = os.environ.get("MAX_CPU_MEM", "100GiB") # 113GB available
state: dict = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
# Boost GPU to high performance mode (avoids power-saving clock throttle)
subprocess.run(["/opt/rocm/bin/rocm-smi", "--setperflevel", "high"], capture_output=True)
print(f"Loading model {MODEL_ID} (gpu≤{MAX_GPU_MEM} cpu≤{MAX_CPU_MEM})...", flush=True)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
if not tokenizer.chat_template:
print("No chat_template found — applying Mistral [INST] fallback.", flush=True)
tokenizer.chat_template = _MISTRAL_TEMPLATE
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype=torch.float16,
device_map="auto",
max_memory={0: MAX_GPU_MEM, "cpu": MAX_CPU_MEM},
)
model.eval()
state["tokenizer"] = tokenizer
state["model"] = model
# Warm up: tiny generation to trigger kernel compilation
print("Warming up...", flush=True)
_run_chat([{"role": "user", "content": "hi"}], max_tokens=4, temperature=0.0, top_p=1.0, stop=None)
print(f"Model ready on: {next(model.parameters()).device}", flush=True)
yield
gpu_executor.shutdown(wait=False, cancel_futures=True)
state.clear()
app = FastAPI(title="Uncensored Chat LLM API", lifespan=lifespan)
def _run_chat(messages, max_tokens, temperature, top_p, stop):
tokenizer = state["tokenizer"]
model = state["model"]
input_ids = tokenizer.apply_chat_template(
messages, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
prompt_tokens = input_ids.shape[1]
do_sample = temperature is not None and temperature > 0
gen_kwargs = dict(
max_new_tokens=max_tokens,
do_sample=do_sample,
use_cache=True,
pad_token_id=tokenizer.eos_token_id,
)
if do_sample:
gen_kwargs["temperature"] = temperature
gen_kwargs["top_p"] = top_p
t0 = time.perf_counter()
with torch.inference_mode():
output_ids = model.generate(input_ids, **gen_kwargs)
dt = time.perf_counter() - t0
new_ids = output_ids[0][prompt_tokens:]
text = tokenizer.decode(new_ids, skip_special_tokens=True)
finish = "stop"
if stop:
stops = [stop] if isinstance(stop, str) else list(stop)
cut = min((text.find(s) for s in stops if s and s in text), default=-1)
if cut != -1:
text = text[:cut]
completion_tokens = int(new_ids.shape[0])
if completion_tokens >= max_tokens:
finish = "length"
del input_ids, output_ids
return {
"text": text.strip(),
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"finish_reason": finish,
"generate_s": round(dt, 2),
}
async def run_chat(*args):
return await asyncio.get_running_loop().run_in_executor(gpu_executor, _run_chat, *args)
class ChatMessage(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str | None = None
messages: list[ChatMessage]
max_tokens: int = 512
temperature: float = 0.8
top_p: float = 0.95
stop: list[str] | str | None = None
@app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest):
if not req.messages:
raise HTTPException(400, "messages must not be empty")
msgs = [{"role": m.role, "content": m.content} for m in req.messages]
r = await run_chat(msgs, req.max_tokens, req.temperature, req.top_p, req.stop)
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
"object": "chat.completion",
"created": int(time.time()),
"model": MODEL_ID,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": r["text"]},
"finish_reason": r["finish_reason"],
}
],
"usage": {
"prompt_tokens": r["prompt_tokens"],
"completion_tokens": r["completion_tokens"],
"total_tokens": r["prompt_tokens"] + r["completion_tokens"],
},
"timing": {"generate_s": r["generate_s"]},
}
@app.get("/v1/models")
async def list_models():
return {"object": "list", "data": [{"id": MODEL_ID, "object": "model"}]}
@app.get("/health")
async def health():
gpu = {}
if torch.cuda.is_available():
gpu = {
"name": torch.cuda.get_device_name(0),
"vram_total_gb": round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1),
"vram_used_gb": round(torch.cuda.memory_allocated(0) / 1e9, 1),
}
return {"status": "ok", "model": MODEL_ID, "loaded": "model" in state, "gpu": gpu}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8001)))

File diff suppressed because it is too large Load Diff