llama.cpp-based implementation of the countbayesie Future-Entropy Sampler, built via a direct ctypes binding to libllama.so. Includes a one-shot CLI (entropy_cli.py), an OpenAI-compatible server (entropy_server.py), and the tuning/benchmark scripts used to derive the alpha/confidence-threshold defaults documented in the README. Model path and llama.cpp library path are now read from environment variables (ENTROPY_SAMPLER_MODEL, LLAMA_CPP_LIB) instead of being hardcoded, and gguf-py is pulled from PyPI instead of a local llama.cpp checkout, so this runs on any machine with a compatible llama.cpp build and model.
326 lines
14 KiB
Python
326 lines
14 KiB
Python
"""OpenAI-compatible HTTP server for the Future-Entropy Sampler prototype -
|
|
meant to run alongside the stock qwen3.5-llama-start server (port 30000) so
|
|
the two can be compared side by side from the same chat UI (e.g. Open WebUI
|
|
as two separate model connections).
|
|
|
|
Sampler configuration (alpha/sine, top_k, confidence_threshold, ...) is fixed
|
|
at startup via CLI flags, same spirit as entropy_cli.py - there is no
|
|
per-request override and no runtime reconfiguration; restart with different
|
|
flags to try a different configuration.
|
|
|
|
Usage:
|
|
python entropy_server.py --port 30001
|
|
python entropy_server.py --port 30001 --sine 16,0.6,-0.2 --confidence-threshold 0.8
|
|
"""
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
import gguf
|
|
import jinja2
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel
|
|
import uvicorn
|
|
|
|
import llama_capi as C
|
|
from entropy_sampler import EntropySampler, sine_alpha_schedule, MODEL_PATH
|
|
from entropy_cli import parse_sine, SAFE_PEAK_ALPHA
|
|
|
|
|
|
DEFAULT_MODEL_ID = "qwen3.5-35b-a3b-entropy"
|
|
|
|
|
|
def load_chat_template(model_path):
|
|
reader = gguf.GGUFReader(model_path)
|
|
field = reader.fields.get("tokenizer.chat_template")
|
|
if field is None:
|
|
raise RuntimeError(f"{model_path} has no embedded tokenizer.chat_template")
|
|
return bytes(field.parts[-1]).decode("utf-8")
|
|
|
|
|
|
def _raise_exception(msg):
|
|
# The Qwen chat template calls raise_exception(...) as a Jinja global,
|
|
# matching the helper HF's apply_chat_template injects into its env.
|
|
raise ValueError(msg)
|
|
|
|
|
|
def build_jinja_template(template_src):
|
|
env = jinja2.Environment()
|
|
env.globals["raise_exception"] = _raise_exception
|
|
env.filters["tojson"] = lambda v: json.dumps(v)
|
|
return env.from_string(template_src)
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
role: str
|
|
content: str = ""
|
|
|
|
|
|
class ChatCompletionRequest(BaseModel):
|
|
model: str | None = None
|
|
messages: list[ChatMessage]
|
|
stream: bool = False
|
|
max_tokens: int | None = None
|
|
# Accepted for OpenAI-client compatibility, intentionally ignored: this
|
|
# sampler's behavior is fully determined by the server's baked-in
|
|
# alpha/sine/top_k/confidence_threshold config, not per-request params.
|
|
temperature: float | None = None
|
|
top_p: float | None = None
|
|
|
|
|
|
def build_arg_parser():
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--host", default="0.0.0.0")
|
|
p.add_argument("--port", type=int, default=30001)
|
|
p.add_argument("--model", default=MODEL_PATH)
|
|
p.add_argument("--model-id", default=DEFAULT_MODEL_ID,
|
|
help="Model name reported via /v1/models and in responses.")
|
|
|
|
alpha_group = p.add_mutually_exclusive_group()
|
|
alpha_group.add_argument("--alpha", type=float, default=None,
|
|
help="Fixed alpha in [-1, 1]. Overrides --sine if given.")
|
|
alpha_group.add_argument("--sine", type=parse_sine, default="16,0.6,-0.2",
|
|
metavar="PERIOD,AMPLITUDE[,OFFSET[,PHASE]]",
|
|
help="Default: 16,0.6,-0.2 (peak +0.4) - the best-tested "
|
|
"config this session. Keep peak (offset+amplitude) "
|
|
"<= ~+0.5 for safety.")
|
|
|
|
p.add_argument("--top-k", type=int, default=12)
|
|
p.add_argument("--top-n-future", type=int, default=20)
|
|
p.add_argument("--confidence-threshold", type=float, default=0.8)
|
|
p.add_argument("--n-ctx", type=int, default=212992,
|
|
help="Requested total context, split n_seq_max=(top_k+1) ways since "
|
|
"this model's KV cache is not unified across fork sequences "
|
|
"(kv_unified=false). Default 212992 -> ~16384 tokens/sequence "
|
|
"at top_k=12. Since each chat request re-sends the full "
|
|
"conversation history and re-primes from scratch, this is the "
|
|
"budget for prompt+reply *per request*, and it must also cover "
|
|
"the whole growing history in a multi-turn conversation.")
|
|
p.add_argument("--n-batch", type=int, default=16384,
|
|
help="Hard ceiling on tokens submitted in one llama_decode() call. "
|
|
"prime() decodes the whole rendered prompt/history in a single "
|
|
"call, so this must cover the largest expected prompt. Default "
|
|
"16384 matches the default per-sequence context budget. Too "
|
|
"small and long prompts trigger GGML_ASSERT(n_tokens_all <= "
|
|
"n_batch), which aborts the whole process (not a catchable "
|
|
"Python exception) - this happened with the llama.cpp default "
|
|
"of 2048 on a 2059-token prompt.")
|
|
p.add_argument("--n-ubatch", type=int, default=512,
|
|
help="Internal per-step chunk size within a decode call - llama.cpp "
|
|
"processes n_batch in n_ubatch-sized pieces automatically. "
|
|
"Smaller = less peak compute-buffer memory.")
|
|
p.add_argument("--n-threads", type=int, default=8)
|
|
p.add_argument("--max-tokens-default", type=int, default=3000,
|
|
help="Used when a request doesn't specify max_tokens (Open WebUI "
|
|
"typically doesn't). Sized for ~1600-word creative-writing "
|
|
"replies (~2000-2200 tokens) with margin - 512 and 1536 both "
|
|
"proved too tight and truncated mid-sentence.")
|
|
p.add_argument("--think-ban", dest="think_ban", action="store_true", default=True)
|
|
p.add_argument("--no-think-ban", dest="think_ban", action="store_false")
|
|
p.add_argument("--enable-thinking", dest="enable_thinking", action="store_true", default=False,
|
|
help="Chat template's enable_thinking flag. Default off - this "
|
|
"sampler is tuned for direct creative-writing continuation, "
|
|
"not reasoning mode.")
|
|
return p
|
|
|
|
|
|
def make_alpha(args):
|
|
if args.alpha is not None:
|
|
if args.alpha >= 0.7:
|
|
print(f"warning: fixed alpha {args.alpha:+.2f} >= +0.7 reliably degenerated "
|
|
f"into register-breaks in prior testing", file=sys.stderr)
|
|
return lambda: args.alpha, f"fixed={args.alpha:+.2f}"
|
|
|
|
s = args.sine
|
|
peak = s["offset"] + s["amplitude"]
|
|
if peak > SAFE_PEAK_ALPHA:
|
|
print(f"warning: peak alpha {peak:+.2f} exceeds the ~+{SAFE_PEAK_ALPHA:.1f} "
|
|
f"safety margin observed for coherence - expect elevated crash risk",
|
|
file=sys.stderr)
|
|
desc = (f"sine(period={s['period_tokens']:g}, amplitude={s['amplitude']:.2f}, "
|
|
f"offset={s['offset']:.2f}, phase={s['phase']:.2f}) peak={peak:+.2f}")
|
|
return (lambda: sine_alpha_schedule(**s)), desc
|
|
|
|
|
|
def main():
|
|
args = build_arg_parser().parse_args()
|
|
alpha_factory, alpha_desc = make_alpha(args)
|
|
|
|
print("=== entropy server ===")
|
|
print(f"model: {args.model}")
|
|
print(f"model_id: {args.model_id}")
|
|
print(f"top_k: {args.top_k} (n_seq_max={args.top_k + 1})")
|
|
print(f"top_n_future: {args.top_n_future}")
|
|
print(f"alpha: {alpha_desc}")
|
|
print(f"confidence_thr: {args.confidence_threshold}")
|
|
print(f"think_ban: {args.think_ban}")
|
|
print(f"enable_thinking: {args.enable_thinking} (chat template flag)")
|
|
print(f"n_ctx: requested={args.n_ctx}")
|
|
print(f"n_batch: {args.n_batch} n_ubatch: {args.n_ubatch}")
|
|
|
|
template = build_jinja_template(load_chat_template(args.model))
|
|
|
|
sampler = EntropySampler(
|
|
model_path=args.model,
|
|
n_ctx=args.n_ctx,
|
|
top_k=args.top_k,
|
|
top_n_future=args.top_n_future,
|
|
n_threads=args.n_threads,
|
|
think_ban=args.think_ban,
|
|
n_batch=args.n_batch,
|
|
n_ubatch=args.n_ubatch,
|
|
)
|
|
n_ctx_actual = C.lib.llama_n_ctx(sampler.ctx)
|
|
n_ctx_seq = C.lib.llama_n_ctx_seq(sampler.ctx)
|
|
print(f"n_ctx: actual={n_ctx_actual} per-seq={n_ctx_seq}")
|
|
print(f"think_ban ids: {sampler.banned_token_ids}")
|
|
print("=" * 40)
|
|
|
|
# Each request re-primes seq 0 from scratch with the *entire* rendered chat
|
|
# history, so this per-sequence budget has to cover a full growing
|
|
# conversation, not just one reply. Undersizing this is exactly what
|
|
# produced the mid-generation "llama_decode failed rc=1 / failed to find
|
|
# a memory slot" crash this was added after.
|
|
if n_ctx_seq < args.max_tokens_default + 512:
|
|
print(
|
|
f"warning: per-sequence budget ({n_ctx_seq}) is only "
|
|
f"{n_ctx_seq - args.max_tokens_default} tokens above max_tokens_default "
|
|
f"({args.max_tokens_default}) - long prompts or multi-turn history will "
|
|
f"exhaust it. Raise --n-ctx.",
|
|
file=sys.stderr,
|
|
)
|
|
if args.n_batch < n_ctx_seq:
|
|
print(
|
|
f"warning: --n-batch ({args.n_batch}) is smaller than the per-sequence "
|
|
f"context budget ({n_ctx_seq}) - a prompt/history longer than --n-batch "
|
|
f"tokens will hit GGML_ASSERT(n_tokens_all <= n_batch) and abort the whole "
|
|
f"process, even though it would otherwise fit in context. Raise --n-batch "
|
|
f"to at least {n_ctx_seq}.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
gen_lock = threading.Lock()
|
|
|
|
def render_prompt(messages):
|
|
try:
|
|
return template.render(
|
|
messages=messages, tools=None, add_generation_prompt=True,
|
|
enable_thinking=args.enable_thinking,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
app = FastAPI()
|
|
|
|
@app.get("/v1/models")
|
|
def list_models():
|
|
return {
|
|
"object": "list",
|
|
"data": [{"id": args.model_id, "object": "model", "owned_by": "local"}],
|
|
}
|
|
|
|
@app.post("/v1/chat/completions")
|
|
def chat_completions(req: ChatCompletionRequest):
|
|
messages = [m.model_dump() for m in req.messages]
|
|
prompt = render_prompt(messages)
|
|
max_new_tokens = req.max_tokens or args.max_tokens_default
|
|
|
|
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
|
created = int(time.time())
|
|
|
|
n_prompt_tokens = len(C.tokenize(sampler.vocab, prompt))
|
|
print(
|
|
f"[{completion_id}] request: prompt_tokens={n_prompt_tokens} "
|
|
f"requested_max_tokens={req.max_tokens} resolved_max_new_tokens={max_new_tokens} "
|
|
f"stream={req.stream}"
|
|
)
|
|
|
|
if req.stream:
|
|
def event_stream():
|
|
first = {
|
|
"id": completion_id, "object": "chat.completion.chunk", "created": created,
|
|
"model": args.model_id,
|
|
"choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
|
|
}
|
|
yield f"data: {json.dumps(first)}\n\n"
|
|
|
|
n_yielded = 0
|
|
out_of_context = False
|
|
with gen_lock:
|
|
try:
|
|
for piece, _diag in sampler.generate_stream(
|
|
prompt, max_new_tokens=max_new_tokens,
|
|
alpha=alpha_factory(), confidence_threshold=args.confidence_threshold,
|
|
):
|
|
n_yielded += 1
|
|
chunk = {
|
|
"id": completion_id, "object": "chat.completion.chunk", "created": created,
|
|
"model": args.model_id,
|
|
"choices": [{"index": 0, "delta": {"content": piece}, "finish_reason": None}],
|
|
}
|
|
yield f"data: {json.dumps(chunk)}\n\n"
|
|
except RuntimeError as e:
|
|
out_of_context = True
|
|
print(f"warning: generation truncated after {n_yielded} tokens: {e} "
|
|
f"(per-sequence budget exhausted - raise --n-ctx)", file=sys.stderr)
|
|
|
|
finish_reason = "length" if (out_of_context or n_yielded >= max_new_tokens) else "stop"
|
|
print(f"[{completion_id}] done: tokens_generated={n_yielded} finish_reason={finish_reason} "
|
|
f"out_of_context={out_of_context}")
|
|
final = {
|
|
"id": completion_id, "object": "chat.completion.chunk", "created": created,
|
|
"model": args.model_id,
|
|
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
|
|
}
|
|
yield f"data: {json.dumps(final)}\n\n"
|
|
yield "data: [DONE]\n\n"
|
|
|
|
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
|
|
out_of_context = False
|
|
with gen_lock:
|
|
pieces = []
|
|
try:
|
|
for piece, _diag in sampler.generate_stream(
|
|
prompt, max_new_tokens=max_new_tokens,
|
|
alpha=alpha_factory(), confidence_threshold=args.confidence_threshold,
|
|
):
|
|
pieces.append(piece)
|
|
except RuntimeError as e:
|
|
out_of_context = True
|
|
print(f"warning: generation truncated after {len(pieces)} tokens: {e} "
|
|
f"(per-sequence budget exhausted - raise --n-ctx)", file=sys.stderr)
|
|
text = "".join(pieces)
|
|
finish_reason = "length" if (out_of_context or len(pieces) >= max_new_tokens) else "stop"
|
|
print(f"[{completion_id}] done: tokens_generated={len(pieces)} finish_reason={finish_reason} "
|
|
f"out_of_context={out_of_context}")
|
|
|
|
return {
|
|
"id": completion_id,
|
|
"object": "chat.completion",
|
|
"created": created,
|
|
"model": args.model_id,
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": text},
|
|
"finish_reason": finish_reason,
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 0, "completion_tokens": len(pieces),
|
|
"total_tokens": len(pieces),
|
|
},
|
|
}
|
|
|
|
try:
|
|
uvicorn.run(app, host=args.host, port=args.port)
|
|
finally:
|
|
sampler.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|