VibeGame-8B / code /scripts /generate.py
khursheed's picture
Release pipeline source, tests, notebook, and licenses
ae36cfb verified
Raw History Blame Contribute Delete
10.5 kB
#!/usr/bin/env python3
"""Generate once using local Ollama or a local Unsloth adapter. No paid API."""
from __future__ import annotations
import argparse
import hashlib
import importlib.metadata
import json
from pathlib import Path
import sys
import time
import urllib.parse
import urllib.request
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from vibegame.project import SYSTEM_PROMPT, parse_project
def file_hash(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(8 * 1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def directory_fingerprint(path: Path) -> dict:
files = {str(item.relative_to(path)): file_hash(item) for item in sorted(path.rglob("*"))
if item.is_file() and item.suffix in {".json", ".safetensors", ".bin", ".model", ".gguf"}}
return {"path": str(path.resolve()), "sha256": hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest(), "files": files}
class OllamaGenerator:
def __init__(self, model: str, url: str = "http://127.0.0.1:11434", **kwargs):
parsed = urllib.parse.urlparse(url)
if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost", "::1"} or parsed.username:
raise ValueError("Only local, unauthenticated Ollama HTTP is allowed")
self.url, self.model = url.rstrip("/"), model
tags = self._post("/api/tags", None)
matching = [item for item in tags.get("models", []) if item.get("name") in {model, model + ":latest"} or item.get("model") in {model, model + ":latest"}]
if not matching:
raise RuntimeError("Model is not installed in local Ollama; automatic pull is disabled")
self.metadata = {"backend": "ollama-local", "model": model, "model_digest": matching[0]["digest"],
"runtime": self._post("/api/version", None), "system_prompt_sha256": hashlib.sha256(SYSTEM_PROMPT.encode()).hexdigest()}
def _post(self, endpoint: str, payload):
body = None if payload is None else json.dumps(payload).encode()
request = urllib.request.Request(self.url + endpoint, data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=900) as response:
return json.load(response)
def generate(self, prompt: str, max_new_tokens: int = 3072) -> dict:
start = time.monotonic()
result = self._post("/api/generate", {"model": self.model, "system": SYSTEM_PROMPT, "prompt": prompt,
"stream": False, "options": {"temperature": 0, "seed": 42, "num_predict": max_new_tokens, "num_ctx": 4096}})
elapsed = time.monotonic() - start
return {"output": result["response"], "generation_seconds": elapsed, "generation_call_seconds": elapsed,
"tokens": result.get("eval_count"), "done_reason": result.get("done_reason")}
class UnslothGenerator:
def __init__(self, adapter: str, base_model: str | None = None, **kwargs):
from unsloth import FastLanguageModel
import torch
if not torch.cuda.is_available():
raise RuntimeError("Unsloth generation requires CUDA; local Ollama is the CPU/Mac alternative")
adapter_path = Path(adapter).expanduser().resolve(strict=True)
if not (adapter_path / "adapter_config.json").exists():
raise ValueError("Expected a local saved PEFT adapter")
config = json.loads((adapter_path / "adapter_config.json").read_text())
declared_base = config["base_model_name_or_path"]
manifest = json.loads((adapter_path / "run_manifest.json").read_text())
signature = manifest["signature"]
pinned_base, pinned_revision = signature["base_model"], signature["base_revision"]
if base_model is not None and base_model != pinned_base:
raise ValueError("Requested base model differs from the pinned training manifest")
# PEFT adapter configs do not necessarily preserve the training revision.
# Load the exact pinned quantized base first, then attach local LoRA weights.
self.model, self.tokenizer = FastLanguageModel.from_pretrained(model_name=pinned_base, revision=pinned_revision,
max_seq_length=4096, dtype=torch.float16, load_in_4bit=True, trust_remote_code=False, use_exact_model_name=True)
from bitsandbytes.nn import Linear4bit
for module in self.model.modules():
if isinstance(module, Linear4bit):
module.compute_dtype = torch.float16
module.compute_type_is_set = True
from peft import PeftModel
from transformers import AutoTokenizer
self.model = PeftModel.from_pretrained(self.model, str(adapter_path), is_trainable=False)
self.tokenizer = AutoTokenizer.from_pretrained(str(adapter_path), trust_remote_code=False)
FastLanguageModel.for_inference(self.model)
self.torch = torch
self.metadata = {"backend": "unsloth", "adapter": directory_fingerprint(adapter_path), "base_model": pinned_base, "adapter_declared_base": declared_base,
"requested_base_model": base_model, "base_revision": pinned_revision,
"torch_version": torch.__version__, "cuda_version": torch.version.cuda,
"packages": {name: importlib.metadata.version(name) for name in ["unsloth", "transformers", "peft", "bitsandbytes"]},
"gpu": torch.cuda.get_device_name(), "system_prompt_sha256": hashlib.sha256(SYSTEM_PROMPT.encode()).hexdigest()}
try:
from huggingface_hub import snapshot_download
self.metadata["base_artifacts"] = directory_fingerprint(Path(snapshot_download(repo_id=pinned_base, revision=pinned_revision, local_files_only=True)))
except Exception as exc:
self.metadata["base_artifacts_error"] = type(exc).__name__ + ": " + str(exc)
def generate(self, prompt: str, max_new_tokens: int = 3072) -> dict:
return self.generate_many([prompt], max_new_tokens)[0]
def generate_many(self, prompts: list[str], max_new_tokens: int = 3072) -> list[dict]:
call_started = time.monotonic()
if not 1 <= len(prompts) <= 32:
raise ValueError("Free T4 inference batches must contain one to thirty-two prompts")
texts = [self.tokenizer.apply_chat_template(
[{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}],
tokenize=False, add_generation_prompt=True) for prompt in prompts]
previous_padding = self.tokenizer.padding_side
self.tokenizer.padding_side = "left"
try:
inputs = self.tokenizer(texts, return_tensors="pt", padding=True, add_special_tokens=False).to("cuda")
finally:
self.tokenizer.padding_side = previous_padding
length = inputs.input_ids.shape[1]
if length + max_new_tokens > 4096:
raise ValueError("Prompt plus generation exceeds the 4096-token bound; do not truncate evaluation prompts")
self.torch.cuda.synchronize()
start = time.monotonic()
with self.torch.inference_mode():
output = self.model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False,
use_cache=True, pad_token_id=self.tokenizer.eos_token_id)
self.torch.cuda.synchronize()
elapsed = time.monotonic() - start
eos = self.model.generation_config.eos_token_id
eos_ids = set(eos if isinstance(eos, list) else [eos, self.tokenizer.eos_token_id])
results = []
for index, sequence in enumerate(output[:, length:].tolist()):
stop = next((i for i, token in enumerate(sequence) if token in eos_ids), None)
generated = sequence if stop is None else sequence[:stop + 1]
results.append({"output": self.tokenizer.decode(generated, skip_special_tokens=True),
"generation_seconds": elapsed, "generation_batch_size": len(prompts),
"latency_note": "generation_seconds times model.generate; generation_call_seconds includes chat formatting, tokenization, device transfers and decoding for the whole batch. Neither is divided by batch size.",
"tokens": len(generated), "prompt_tokens": int(inputs.attention_mask[index].sum().item()),
"done_reason": "stop" if stop is not None else "length"})
call_elapsed = time.monotonic() - call_started
for result in results:
result["generation_call_seconds"] = call_elapsed
return results
def add_backend_args(parser):
parser.add_argument("--backend", choices=["ollama", "unsloth"], required=True)
parser.add_argument("--model", help="Installed Ollama model tag")
parser.add_argument("--ollama-url", default="http://127.0.0.1:11434")
parser.add_argument("--adapter", help="Local Unsloth adapter directory")
parser.add_argument("--base-model", default=None)
parser.add_argument("--max-new-tokens", type=int, default=3072)
def make_generator(args):
if not 1 <= args.max_new_tokens <= 3072:
raise ValueError("Generation must use 1–3072 tokens, reserving context for instructions")
if args.backend == "ollama":
if not args.model:
raise ValueError("--model is required for Ollama")
return OllamaGenerator(args.model, args.ollama_url)
if not args.adapter:
raise ValueError("--adapter is required for Unsloth")
return UnslothGenerator(args.adapter, args.base_model)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
add_backend_args(parser)
parser.add_argument("--prompt", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.output.exists():
parser.error("Output already exists; refusing to overwrite a generation")
generator = make_generator(args)
result = generator.generate(args.prompt, args.max_new_tokens)
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("x") as handle:
handle.write(result["output"])
result["metadata"] = generator.metadata
args.output.with_suffix(args.output.suffix + ".generation.json").write_text(json.dumps(result, indent=2) + "\n")
parse_project(result["output"])
print(args.output)