File size: 10,529 Bytes
ae36cfb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
#!/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)