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:
96
tour-comfy/deploy_pose_llm.sh
Executable file
96
tour-comfy/deploy_pose_llm.sh
Executable 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
159
tour-comfy/gen_poses.py
Executable 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
206
tour-comfy/pose_llm_api.py
Executable 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)))
|
||||
2786
tour-comfy/poses.md
2786
tour-comfy/poses.md
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user