Download inference.py from KerzgrAI/PromptTercet-0.5B: direct link, hf CLI and curl.
- Browser
- Download file 40.6 kB
-
https://huggingface.co/KerzgrAI/PromptTercet-0.5B/resolve/main/inference.py
- Command line
-
hf download hf://KerzgrAI/PromptTercet-0.5B/inference.py
-
curl -L -o inference.py https://huggingface.co/KerzgrAI/PromptTercet-0.5B/resolve/main/inference.py
40.6 kB
| #!/usr/bin/env python3 | |
| """Standalone inference for KerzgrAI/PromptTercet-0.5B. | |
| PromptTercet writes the user's side of a chat: give it a conversation and it | |
| asks the next question, give it nothing and it opens one. You, or any | |
| OpenAI-compatible model, write the assistant's side. | |
| Examples: | |
| python inference.py # it asks, you answer | |
| python inference.py --system "You are a cooking assistant." | |
| python inference.py --conversation chat.json # print the next question | |
| python inference.py --count 500 --output prompts.jsonl | |
| python inference.py --count 100 --turns 3 --output chats.jsonl \\ | |
| --answer-url https://openrouter.ai/api/v1 --answer-model openai/gpt-4o-mini | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import importlib | |
| import json | |
| import os | |
| import platform | |
| import secrets | |
| import shutil | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| import unicodedata | |
| import urllib.error | |
| import urllib.request | |
| import warnings | |
| from collections.abc import Callable | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from types import SimpleNamespace | |
| from typing import Any, TextIO | |
| import torch | |
| from huggingface_hub import hf_hub_download | |
| from safetensors.torch import load_file | |
| from tokenizers import Tokenizer | |
| def _silence_runtime_warnings() -> None: | |
| patterns = ( | |
| r"tl\.make_block_ptr is deprecated", | |
| r"Memory efficient kernel not used because", | |
| r"Memory Efficient attention has been runtime disabled", | |
| r"Flash attention kernel not used because", | |
| r"Torch was not compiled with flash attention", | |
| r"cuDNN attention kernel not used because", | |
| r"cuDNN attention has been runtime disabled", | |
| ) | |
| for pattern in patterns: | |
| warnings.filterwarnings("ignore", message=pattern) | |
| REPO_ID = "KerzgrAI/PromptTercet-0.5B" | |
| MODEL_NAME = "PromptTercet-0.5B" | |
| FLA_COMMIT = "cbb0a72efb55c18ca0ef4f298298317573ad2cb3" | |
| FLA_REPO = "https://github.com/fla-org/flash-linear-attention.git" | |
| FLA_RUNTIME_REQUIREMENT = "transformers>=4.45.0" | |
| # Parallax attention is not in a flash-linear-attention release yet, so fla | |
| # installs from git at the commit the model was trained with. | |
| FLA_MODULES = ( | |
| "fla.layers.gdn2", | |
| "fla.models.utils", | |
| "fla.modules.rotary", | |
| "fla.ops.parallax.parallel", | |
| "fla.ops.parallax.decode", | |
| ) | |
| PATCH_FILES = ( | |
| "fla/__init__.py", | |
| "fla/ops/__init__.py", | |
| "fla/layers/__init__.py", | |
| "fla/ops/simple_gla/__init__.py", | |
| ) | |
| TINY_GDN_FILES = ( | |
| "tiny_gdn/__init__.py", | |
| "tiny_gdn/config.py", | |
| "tiny_gdn/model.py", | |
| "tiny_gdn/haiku_layers.py", | |
| "tiny_gdn/nn_common.py", | |
| "tiny_gdn/fused_ops.py", | |
| "tiny_gdn/senryu2.py", | |
| "tiny_gdn/parallax_ops.py", | |
| "tiny_gdn/gated_residual_ops.py", | |
| "tiny_gdn/ngram.py", | |
| "tiny_gdn/tools.py", | |
| "tiny_gdn/smoltalk_chat.py", | |
| "tiny_gdn/chatml.py", | |
| "tiny_gdn/detokenize.py", | |
| "tiny_gdn/repetition.py", | |
| ) | |
| NGRAM_TABLES_FILE = "ngram_tables.safetensors" | |
| ANSWER_REQUEST_RETRIES = 6 | |
| # A conversation's own turns, as PromptTercet's chat template expects them: | |
| # its questions are the template's assistant turns and the replies its user turns. | |
| PROMPTER_ROLES = {"system": "system", "user": "assistant", "assistant": "user"} | |
| class Source: | |
| repo_id: str | |
| local_dir: Path | None | |
| # A full copy of this repo next to inference.py, used instead of the Hub. | |
| local_copy: Path | None | |
| def _local_copy() -> Path | None: | |
| here = Path(__file__).resolve().parent | |
| config = here / "config.json" | |
| if not all((here / name).is_file() for name in ("config.json", "model.safetensors", NGRAM_TABLES_FILE)): | |
| return None | |
| try: | |
| name = json.loads(config.read_text(encoding="utf-8")).get("model_name") | |
| except (OSError, ValueError): | |
| return None | |
| return here if name == MODEL_NAME else None | |
| def _download(source: Source, filename: str) -> Path: | |
| if source.local_copy is not None and (source.local_copy / filename).is_file(): | |
| return source.local_copy / filename | |
| return Path( | |
| hf_hub_download( | |
| repo_id=source.repo_id, | |
| filename=filename, | |
| local_dir=str(source.local_dir) if source.local_dir else None, | |
| ) | |
| ) | |
| def _run(cmd: list[str], *, cwd: Path | None = None) -> None: | |
| print("+", " ".join(cmd), flush=True) | |
| merged = os.environ.copy() | |
| merged.setdefault("PYTHONUTF8", "1") | |
| merged.setdefault("PYTHONIOENCODING", "utf-8") | |
| subprocess.check_call(cmd, cwd=str(cwd) if cwd else None, env=merged) | |
| def _pip_install(*args: str) -> None: | |
| _run([sys.executable, "-m", "pip", "install", *args]) | |
| def _fla_importable() -> tuple[bool, str]: | |
| try: | |
| for name in FLA_MODULES: | |
| importlib.import_module(name) | |
| except Exception as error: # noqa: BLE001 | |
| return False, str(error) | |
| return True, "" | |
| def _cache_root() -> Path: | |
| override = os.environ.get("PROMPTTERCET_CACHE") | |
| path = Path(override).expanduser().resolve() if override else Path.home() / ".cache" / "prompttercet" | |
| path.mkdir(parents=True, exist_ok=True) | |
| return path | |
| def _install_fla(source: Source) -> None: | |
| print("flash-linear-attention missing/broken — installing automatically …", flush=True) | |
| _pip_install("einops", "numpy", FLA_RUNTIME_REQUIREMENT) | |
| if platform.system() != "Windows": | |
| _pip_install("--no-deps", "--force-reinstall", f"git+{FLA_REPO}@{FLA_COMMIT}") | |
| return | |
| if shutil.which("git") is None: | |
| raise RuntimeError("git is required to auto-install flash-linear-attention. Install Git and put it on PATH.") | |
| fla_root = _cache_root() / "flash-linear-attention" | |
| if not (fla_root / ".git").is_dir(): | |
| if fla_root.exists(): | |
| shutil.rmtree(fla_root) | |
| _run(["git", "clone", "--filter=blob:none", FLA_REPO, str(fla_root)]) | |
| _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root) | |
| _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root) | |
| print("Applying Windows FLA import patches …", flush=True) | |
| for relative in PATCH_FILES: | |
| target = fla_root / relative | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(_download(source, f"windows_fla_patches/{relative}"), target) | |
| _pip_install("--no-build-isolation", "--no-deps", "-e", str(fla_root)) | |
| def _ensure_fla(source: Source) -> None: | |
| ok, error = _fla_importable() | |
| if ok: | |
| return | |
| print(f"FLA not ready ({error})", flush=True) | |
| try: | |
| _install_fla(source) | |
| except Exception as install_error: | |
| raise RuntimeError( | |
| "Automatic flash-linear-attention install failed.\n" | |
| f"Original import error: {error}\n" | |
| f"Install error: {install_error}" | |
| ) from install_error | |
| for name in list(sys.modules): | |
| if name == "fla" or name.startswith("fla."): | |
| del sys.modules[name] | |
| importlib.invalidate_caches() | |
| ok, error = _fla_importable() | |
| if not ok: | |
| raise RuntimeError(f"flash-linear-attention installed but still failed to import: {error}") | |
| print("flash-linear-attention ready.", flush=True) | |
| def _ensure_tiny_gdn(source: Source) -> Path: | |
| for root in (Path(__file__).resolve().parent, source.local_copy, source.local_dir): | |
| if root is not None and all((root / name).is_file() for name in TINY_GDN_FILES): | |
| return root | |
| for name in TINY_GDN_FILES: | |
| _download(source, name) | |
| return _download(source, "tiny_gdn/__init__.py").parent.parent | |
| def _import_stack() -> SimpleNamespace: | |
| # tiny_gdn is fetched from the Hub and FLA may be installed at runtime, so | |
| # these modules only become importable after _ensure_tiny_gdn/_ensure_fla. | |
| config = importlib.import_module("tiny_gdn.config") | |
| senryu2 = importlib.import_module("tiny_gdn.senryu2") | |
| chatml = importlib.import_module("tiny_gdn.chatml") | |
| detokenize = importlib.import_module("tiny_gdn.detokenize") | |
| repetition = importlib.import_module("tiny_gdn.repetition") | |
| return SimpleNamespace( | |
| TinyGDNConfig=config.TinyGDNConfig, | |
| Senryu2ForCausalLM=senryu2.Senryu2ForCausalLM, | |
| encode_prompter_messages=chatml.encode_prompter_messages, | |
| IncrementalUtf8Decoder=detokenize.IncrementalUtf8Decoder, | |
| apply_repetition_penalty=repetition.apply_repetition_penalty, | |
| structural_token_flags=repetition.structural_token_flags, | |
| ) | |
| class Sampling: | |
| temperature: float | |
| top_p: float | |
| top_k: int | |
| repetition_penalty: float | |
| repetition_window: int | |
| max_new_tokens: int | |
| context_length: int | |
| class Question: | |
| text: str | |
| tokens: int | |
| # "stop" ended the turn itself; "length" hit --max-new-tokens and is cut off. | |
| stop_reason: str | |
| def usable(self) -> bool: | |
| return self.stop_reason == "stop" and bool(self.text) | |
| def _sample(logits: torch.Tensor, sampling: Sampling, generator: torch.Generator) -> int: | |
| logits = logits.float() | |
| if sampling.temperature <= 1e-5: | |
| return int(torch.argmax(logits).item()) | |
| logits = logits / sampling.temperature | |
| if 0 < sampling.top_k < logits.shape[-1]: | |
| threshold = torch.topk(logits, sampling.top_k).values[-1] | |
| logits = logits.masked_fill(logits < threshold, -torch.inf) | |
| if sampling.top_p < 1.0: | |
| sorted_logits, sorted_indices = torch.sort(logits, descending=True) | |
| remove = torch.cumsum(torch.softmax(sorted_logits, dim=-1), dim=-1) > sampling.top_p | |
| remove[1:] = remove[:-1].clone() | |
| remove[0] = False | |
| sorted_logits = sorted_logits.masked_fill(remove, -torch.inf) | |
| logits = torch.full_like(logits, -torch.inf) | |
| logits.scatter_(0, sorted_indices, sorted_logits) | |
| probs = torch.softmax(logits, dim=-1).cpu() | |
| return int(torch.multinomial(probs, 1, generator=generator).item()) | |
| def prompter_messages(conversation: list[dict[str, str]]) -> list[dict[str, str]]: | |
| return [{"role": PROMPTER_ROLES[item["role"]], "content": item["content"]} for item in conversation] | |
| class Prompter: | |
| """PromptTercet on one GPU. Generations run one at a time, so threads can share it.""" | |
| def __init__(self, model: Any, tokenizer: Tokenizer, stack: SimpleNamespace, device: torch.device, sampling: Sampling, seed: int | None) -> None: | |
| self.model = model | |
| self.tokenizer = tokenizer | |
| self.stack = stack | |
| self.device = device | |
| self.sampling = sampling | |
| self.stop_ids = {int(model.config.eos_token_id)} | |
| im_end = tokenizer.token_to_id("<|im_end|>") | |
| if im_end is not None: | |
| self.stop_ids.add(im_end) | |
| # The penalty skips whitespace, punctuation and chat tokens, so turns can still end. | |
| self.structural = stack.structural_token_flags(tokenizer) if sampling.repetition_penalty != 1.0 else () | |
| self._seed = seed | |
| self._calls = 0 | |
| self._lock = threading.Lock() | |
| def prompt_length(self, conversation: list[dict[str, str]]) -> int: | |
| return len(self.stack.encode_prompter_messages(self.tokenizer, prompter_messages(conversation))) | |
| def ask( | |
| self, | |
| conversation: list[dict[str, str]], | |
| *, | |
| on_text: Callable[[str], None] | None = None, | |
| stop: threading.Event | None = None, | |
| ) -> Question: | |
| prompt_ids = self.stack.encode_prompter_messages(self.tokenizer, prompter_messages(conversation)) | |
| with self._lock: | |
| if self._seed is None: | |
| seed = secrets.randbits(63) | |
| else: | |
| seed = self._seed + self._calls | |
| self._calls += 1 | |
| return self._generate(prompt_ids, seed, on_text, stop) | |
| def _generate( | |
| self, | |
| prompt_ids: list[int], | |
| seed: int, | |
| on_text: Callable[[str], None] | None, | |
| stop: threading.Event | None, | |
| ) -> Question: | |
| sampling = self.sampling | |
| token_ids = list(prompt_ids[-sampling.context_length :]) | |
| generated = 0 | |
| decoder = self.stack.IncrementalUtf8Decoder(self.tokenizer) | |
| generator = torch.Generator(device="cpu") | |
| generator.manual_seed(seed) | |
| stop_reason = "length" | |
| try: | |
| output = self.model( | |
| torch.tensor([token_ids], dtype=torch.long, device=self.device), | |
| return_logits=True, | |
| logits_to_keep=1, | |
| use_cache=True, | |
| ) | |
| for step in range(sampling.max_new_tokens): | |
| if stop is not None and stop.is_set(): | |
| stop_reason = "interrupted" | |
| break | |
| if output.logits is None: | |
| raise RuntimeError("Model returned no logits") | |
| logits = output.logits[0, -1].float() | |
| if self.structural: | |
| self.stack.apply_repetition_penalty( | |
| logits, | |
| token_ids, | |
| penalty=sampling.repetition_penalty, | |
| window=sampling.repetition_window, | |
| structural=self.structural, | |
| ) | |
| next_id = _sample(logits, sampling, generator) | |
| if next_id in self.stop_ids: | |
| stop_reason = "stop" | |
| break | |
| token_ids.append(next_id) | |
| generated += 1 | |
| delta = decoder.push(next_id) | |
| if delta and on_text is not None: | |
| on_text(delta) | |
| if step + 1 == sampling.max_new_tokens: | |
| break | |
| if output.past_key_values is None: | |
| raise RuntimeError("Model returned no decode cache") | |
| if output.past_key_values.length + 1 > self.model.config.max_position_embeddings: | |
| stop_reason = "context_full" | |
| break | |
| output = self.model( | |
| torch.tensor([[next_id]], dtype=torch.long, device=self.device), | |
| return_logits=True, | |
| logits_to_keep=1, | |
| past_key_values=output.past_key_values, | |
| use_cache=True, | |
| ) | |
| except KeyboardInterrupt: | |
| stop_reason = "interrupted" | |
| text = decoder.finalize().strip() | |
| return Question(text=text, tokens=generated, stop_reason=stop_reason) | |
| class AnswerError(RuntimeError): | |
| pass | |
| class AnswerModel: | |
| """An OpenAI-compatible chat completions endpoint that writes the assistant turns.""" | |
| base_url: str | |
| model: str | |
| api_key: str | |
| max_tokens: int | |
| temperature: float | |
| extra: dict[str, Any] | |
| timeout: float | |
| def reply(self, messages: list[dict[str, str]], stop: threading.Event) -> tuple[str, str]: | |
| """``(content, finish_reason)`` of one completion, retrying transient failures.""" | |
| body = { | |
| **self.extra, | |
| "model": self.model, | |
| "messages": messages, | |
| "max_tokens": self.max_tokens, | |
| "temperature": self.temperature, | |
| } | |
| headers = {"Content-Type": "application/json", "User-Agent": "PromptTercet-0.5B/inference.py"} | |
| if self.api_key: | |
| headers["Authorization"] = f"Bearer {self.api_key}" | |
| url = f"{self.base_url.rstrip('/')}/chat/completions" | |
| last = "no attempt" | |
| for attempt in range(ANSWER_REQUEST_RETRIES): | |
| if stop.is_set(): | |
| raise AnswerError("stopped") | |
| delay = min(2.0**attempt, 30.0) | |
| try: | |
| request = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers=headers, method="POST") | |
| with urllib.request.urlopen(request, timeout=self.timeout) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| except urllib.error.HTTPError as error: | |
| detail = error.read().decode("utf-8", errors="replace")[:300] | |
| last = f"HTTP {error.code}: {detail}" | |
| if error.code not in {408, 409, 425, 429} and error.code < 500: | |
| raise AnswerError(last) from error | |
| retry_after = error.headers.get("Retry-After") if error.headers else None | |
| if retry_after and retry_after.isdigit(): | |
| delay = min(float(retry_after), 60.0) | |
| except (urllib.error.URLError, TimeoutError, ConnectionError, OSError, ValueError) as error: | |
| last = f"{type(error).__name__}: {error}" | |
| else: | |
| if isinstance(payload, dict) and payload.get("error"): | |
| last = f"API error: {json.dumps(payload['error'])[:300]}" | |
| else: | |
| try: | |
| choice = payload["choices"][0] | |
| content = choice["message"].get("content") or "" | |
| except (KeyError, IndexError, TypeError, AttributeError) as error: | |
| raise AnswerError(f"Unexpected response: {json.dumps(payload)[:300]}") from error | |
| return str(content).strip(), str(choice.get("finish_reason") or "") | |
| if stop.wait(delay): | |
| raise AnswerError("stopped") | |
| raise AnswerError(f"{ANSWER_REQUEST_RETRIES} requests failed; last: {last}") | |
| def opening_key(text: str) -> str: | |
| return unicodedata.normalize("NFC", text).strip() | |
| def load_conversation(path: str) -> list[dict[str, str]]: | |
| raw = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8") | |
| payload = json.loads(raw) | |
| messages = payload.get("messages") if isinstance(payload, dict) else payload | |
| if not isinstance(messages, list): | |
| raise ValueError("A conversation is a JSON list of messages or an object with a 'messages' list") | |
| conversation: list[dict[str, str]] = [] | |
| for index, item in enumerate(messages): | |
| if not isinstance(item, dict) or item.get("role") not in PROMPTER_ROLES: | |
| raise ValueError(f"Message {index} needs a role of system, user or assistant") | |
| content = item.get("content") | |
| if not isinstance(content, str) or not content.strip(): | |
| raise ValueError(f"Message {index} needs non-empty text content") | |
| if item["role"] == "system" and index != 0: | |
| raise ValueError("Only the first message can be a system prompt") | |
| conversation.append({"role": item["role"], "content": content}) | |
| if conversation and conversation[-1]["role"] == "user": | |
| raise ValueError("The conversation ends with a user turn. PromptTercet writes the user turn after an assistant reply.") | |
| return conversation | |
| class Style: | |
| def __init__(self, enabled: bool) -> None: | |
| self.enabled = enabled | |
| def wrap(self, code: str, text: str) -> str: | |
| return f"\033[{code}m{text}\033[0m" if self.enabled else text | |
| def user(self, text: str) -> str: | |
| return self.wrap("1;36", text) | |
| def assistant(self, text: str) -> str: | |
| return self.wrap("1;32", text) | |
| def note(self, text: str) -> str: | |
| return self.wrap("2", text) | |
| INTERACTIVE_HELP = ( | |
| "Commands: /retry asks again, /system TEXT sets the system prompt and starts over, " | |
| "/reset starts over, /save FILE appends the chat as a JSON line, /exit quits." | |
| ) | |
| def run_interactive(prompter: Prompter, system: str, style: Style) -> int: | |
| print( | |
| f"{MODEL_NAME} plays the user and you play the assistant: answer each question it asks.\n" | |
| f"{INTERACTIVE_HELP} Ctrl+C stops a question.", | |
| flush=True, | |
| ) | |
| def fresh(prompt: str) -> list[dict[str, str]]: | |
| return [{"role": "system", "content": prompt}] if prompt.strip() else [] | |
| conversation = fresh(system) | |
| ask_now = True | |
| while True: | |
| if ask_now: | |
| ask_now = False | |
| if prompter.prompt_length(conversation) > prompter.sampling.context_length: | |
| print(style.note(f"(the chat is over {prompter.sampling.context_length} tokens; the oldest are dropped)")) | |
| print(f"\n{style.user('user>')} ", end="", flush=True) | |
| try: | |
| question = prompter.ask(conversation, on_text=lambda delta: print(delta, end="", flush=True)) | |
| except KeyboardInterrupt: | |
| question = Question(text="", tokens=0, stop_reason="interrupted") | |
| print(flush=True) | |
| if question.stop_reason == "interrupted": | |
| print(style.note("(stopped; /retry asks again)")) | |
| elif not question.text: | |
| print(style.note("(empty question; /retry asks again)")) | |
| else: | |
| if question.stop_reason == "length": | |
| print(style.note(f"(cut off at --max-new-tokens {prompter.sampling.max_new_tokens}; /retry asks again)")) | |
| conversation.append({"role": "user", "content": question.text}) | |
| try: | |
| line = input(f"{style.assistant('assistant>')} ") | |
| except (EOFError, KeyboardInterrupt): | |
| print() | |
| return 0 | |
| text = line.strip() | |
| if not text: | |
| continue | |
| if text.startswith("/"): | |
| command, _, rest = text.partition(" ") | |
| if command in {"/exit", "/quit"}: | |
| return 0 | |
| if command == "/help": | |
| print(INTERACTIVE_HELP) | |
| elif command == "/reset": | |
| conversation = fresh(system) | |
| ask_now = True | |
| print(style.note("(new chat)")) | |
| elif command == "/system": | |
| system = rest.strip() | |
| conversation = fresh(system) | |
| ask_now = True | |
| print(style.note("(system prompt set; new chat)" if system else "(system prompt cleared; new chat)")) | |
| elif command == "/retry": | |
| if conversation and conversation[-1]["role"] == "user": | |
| conversation.pop() | |
| ask_now = True | |
| elif command == "/save": | |
| path = Path(rest.strip() or "conversations.jsonl") | |
| if not any(item["role"] == "user" for item in conversation): | |
| print(style.note("(nothing to save yet)")) | |
| continue | |
| with path.open("a", encoding="utf-8") as handle: | |
| handle.write(json.dumps({"messages": conversation}, ensure_ascii=False) + "\n") | |
| print(style.note(f"(appended to {path})")) | |
| else: | |
| print(style.note(f"(unknown command {command}; /help lists them)")) | |
| continue | |
| if not conversation or conversation[-1]["role"] != "user": | |
| print(style.note("(no question to answer; /retry asks again)")) | |
| continue | |
| conversation.append({"role": "assistant", "content": text}) | |
| ask_now = True | |
| def run_next_question(prompter: Prompter, conversation: list[dict[str, str]]) -> int: | |
| question = prompter.ask(conversation) | |
| if question.stop_reason == "interrupted": | |
| return 130 | |
| print(question.text, flush=True) | |
| if not question.usable: | |
| print(f"[warn] question ended with {question.stop_reason}", file=sys.stderr, flush=True) | |
| return 1 | |
| return 0 | |
| class BatchState: | |
| count: int | |
| written: int = 0 | |
| in_flight: int = 0 | |
| failed: int = 0 | |
| redone_questions: int = 0 | |
| repeated_openings: int = 0 | |
| redone_answers: int = 0 | |
| last_error: str = "" | |
| seen: set[str] = field(default_factory=set) | |
| lock: threading.Lock = field(default_factory=threading.Lock) | |
| def claim(self) -> bool: | |
| with self.lock: | |
| if self.written + self.in_flight >= self.count: | |
| return False | |
| self.in_flight += 1 | |
| return True | |
| def bump(self, name: str) -> None: | |
| with self.lock: | |
| setattr(self, name, getattr(self, name) + 1) | |
| def note(self, error: str) -> None: | |
| with self.lock: | |
| self.last_error = error | |
| def finish(self, ok: bool, stopping: bool) -> bool: | |
| """Settle one claimed chat; True when failures say the batch should stop.""" | |
| with self.lock: | |
| self.in_flight -= 1 | |
| if ok: | |
| self.written += 1 | |
| elif not stopping: | |
| self.failed += 1 | |
| return self.failed >= 20 and self.failed > self.written | |
| def summary(self) -> str: | |
| parts = [f"{self.written}/{self.count} written"] | |
| if self.failed: | |
| parts.append(f"{self.failed} failed") | |
| if self.repeated_openings: | |
| parts.append(f"{self.repeated_openings} repeated openings resampled") | |
| if self.redone_questions: | |
| parts.append(f"{self.redone_questions} cut-off questions resampled") | |
| if self.redone_answers: | |
| parts.append(f"{self.redone_answers} cut-off answers redone") | |
| return ", ".join(parts) | |
| def seen_openings(path: Path) -> set[str]: | |
| seen: set[str] = set() | |
| if not path.is_file(): | |
| return seen | |
| with path.open(encoding="utf-8") as handle: | |
| for line in handle: | |
| try: | |
| messages = json.loads(line).get("messages", []) | |
| except (ValueError, AttributeError): | |
| continue | |
| first = next((item for item in messages if isinstance(item, dict) and item.get("role") == "user"), None) | |
| if first and isinstance(first.get("content"), str): | |
| seen.add(opening_key(first["content"])) | |
| return seen | |
| def run_batch(prompter: Prompter, args: argparse.Namespace, answer: AnswerModel | None) -> int: | |
| output_path = Path(args.output) if args.output else None | |
| state = BatchState(count=args.count) | |
| if output_path is not None: | |
| state.seen = seen_openings(output_path) | |
| if state.seen: | |
| print(f"{output_path} already has {len(state.seen)} openings; new ones will differ.", file=sys.stderr) | |
| sink: TextIO = output_path.open("a", encoding="utf-8") if output_path else sys.stdout | |
| write_lock = threading.Lock() | |
| stop = threading.Event() | |
| system = args.system.strip() | |
| answer_system = system if args.answer_system is None else args.answer_system.strip() | |
| def ask(conversation: list[dict[str, str]], opening: bool) -> str | None: | |
| for _ in range(args.attempts): | |
| question = prompter.ask(conversation, stop=stop) | |
| if question.stop_reason == "interrupted" or stop.is_set(): | |
| return None | |
| if not question.usable: | |
| state.bump("redone_questions") | |
| continue | |
| if opening: | |
| key = opening_key(question.text) | |
| with state.lock: | |
| if key in state.seen: | |
| state.repeated_openings += 1 | |
| continue | |
| state.seen.add(key) | |
| return question.text | |
| state.note(f"no usable question in {args.attempts} tries") | |
| return None | |
| def reply(conversation: list[dict[str, str]]) -> str | None: | |
| assert answer is not None | |
| messages = ([{"role": "system", "content": answer_system}] if answer_system else []) + [ | |
| item for item in conversation if item["role"] != "system" | |
| ] | |
| for _ in range(args.attempts): | |
| try: | |
| content, finish = answer.reply(messages, stop) | |
| except AnswerError as error: | |
| if not stop.is_set(): | |
| state.note(f"answer model: {error}") | |
| return None | |
| if content and finish != "length": | |
| return content | |
| state.bump("redone_answers") | |
| state.note(f"{args.attempts} answers in a row hit --answer-max-tokens {args.answer_max_tokens} or came back empty") | |
| return None | |
| def conversation_once() -> bool: | |
| """One chat of exactly --turns questions, written only when complete.""" | |
| conversation = [{"role": "system", "content": system}] if system else [] | |
| for turn in range(args.turns): | |
| question = ask(conversation, opening=turn == 0) | |
| if question is None: | |
| return False | |
| conversation.append({"role": "user", "content": question}) | |
| if answer is not None: | |
| text = reply(conversation) | |
| if text is None: | |
| return False | |
| conversation.append({"role": "assistant", "content": text}) | |
| if stop.is_set(): | |
| return False | |
| with write_lock: | |
| sink.write(json.dumps({"messages": conversation}, ensure_ascii=False) + "\n") | |
| sink.flush() | |
| return True | |
| def worker() -> None: | |
| while not stop.is_set() and state.claim(): | |
| ok = False | |
| try: | |
| ok = conversation_once() | |
| except Exception as error: # noqa: BLE001 | |
| state.note(f"{type(error).__name__}: {error}") | |
| if state.finish(ok, stop.is_set()): | |
| state.note(f"stopped after {state.failed} failed chats; last: {state.last_error}") | |
| stop.set() | |
| workers = [threading.Thread(target=worker, daemon=True) for _ in range(max(1, args.workers))] | |
| for thread in workers: | |
| thread.start() | |
| tty = sys.stderr.isatty() | |
| shown = "" | |
| shown_at = 0.0 | |
| interrupted = False | |
| try: | |
| while any(thread.is_alive() for thread in workers): | |
| for thread in workers: | |
| thread.join(timeout=0.5) | |
| line = state.summary() | |
| if line != shown and (tty or time.monotonic() - shown_at >= 15): | |
| shown, shown_at = line, time.monotonic() | |
| print(f"\r[{MODEL_NAME}] {line}\033[K" if tty else f"[{MODEL_NAME}] {line}", end="" if tty else "\n", file=sys.stderr, flush=True) | |
| if tty: | |
| print(file=sys.stderr) | |
| except KeyboardInterrupt: | |
| interrupted = True | |
| stop.set() | |
| print("\nStopping after the requests in flight (Ctrl+C again quits now) …", file=sys.stderr, flush=True) | |
| try: | |
| for thread in workers: | |
| thread.join() | |
| except KeyboardInterrupt: | |
| pass | |
| finally: | |
| if output_path is not None: | |
| sink.close() | |
| print(f"[{MODEL_NAME}] done: {state.summary()}", file=sys.stderr, flush=True) | |
| if state.last_error and state.written < state.count: | |
| print(f"[{MODEL_NAME}] last failure: {state.last_error}", file=sys.stderr, flush=True) | |
| if interrupted: | |
| return 130 | |
| return 0 if state.written >= state.count else 1 | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description=f"{MODEL_NAME}: writes the user's side of a chat", | |
| formatter_class=argparse.ArgumentDefaultsHelpFormatter, | |
| ) | |
| mode = parser.add_argument_group("what to do (default: an interactive chat where you answer)") | |
| mode.add_argument("--system", default="", help="System prompt of the chat; it steers what PromptTercet asks about") | |
| mode.add_argument("--conversation", default=None, help="JSON chat (list of messages, or {messages: [...]}); print the next question. '-' reads stdin") | |
| mode.add_argument("--count", type=int, default=0, help="Batch mode: write this many chats as JSON lines") | |
| mode.add_argument("--turns", type=int, default=1, help="Questions per chat in batch mode; above 1 needs --answer-url") | |
| mode.add_argument("--output", default=None, help="JSONL file for batch mode (default: stdout). Appends, and new openings differ from those already in it") | |
| mode.add_argument("--attempts", type=int, default=8, help="Samples per turn before a chat is given up: cut-off or repeated questions, cut-off answers") | |
| mode.add_argument("--workers", type=int, default=None, help="Chats in flight in batch mode (default: 8 with an answer model, else 1)") | |
| answers = parser.add_argument_group("answer model for batch mode (any OpenAI-compatible API)") | |
| answers.add_argument("--answer-url", default=None, help="Base URL, e.g. https://openrouter.ai/api/v1 or http://localhost:8000/v1") | |
| answers.add_argument("--answer-model", default=None, help="Model name sent to the API") | |
| answers.add_argument("--answer-key-env", default="OPENAI_API_KEY", help="Environment variable holding the API key (unset sends none)") | |
| answers.add_argument("--answer-system", default=None, help="System prompt only the answer model sees, not saved (default: --system)") | |
| answers.add_argument("--answer-max-tokens", type=int, default=4096, help="Answers that hit this are discarded and redone") | |
| answers.add_argument("--answer-temperature", type=float, default=0.7) | |
| answers.add_argument("--answer-extra", default="{}", help='Extra request fields as JSON, e.g. \'{"reasoning": {"effort": "low"}}\'') | |
| answers.add_argument("--answer-timeout", type=float, default=600.0, help="Seconds per request") | |
| sampling = parser.add_argument_group("PromptTercet sampling") | |
| sampling.add_argument("--temperature", type=float, default=0.6) | |
| sampling.add_argument("--top-p", type=float, default=0.95) | |
| sampling.add_argument("--top-k", type=int, default=0, help="0 turns top-k off") | |
| sampling.add_argument( | |
| "--repetition-penalty", | |
| type=float, | |
| default=1.36, | |
| help="Penalty on words already used twice in the chat, which keeps follow-ups from repeating it (1 turns it off)", | |
| ) | |
| sampling.add_argument("--repetition-window", type=int, default=0, help="Tokens the penalty looks back over (0: the whole chat)") | |
| sampling.add_argument("--max-new-tokens", type=int, default=1024, help="Questions that hit this are cut off") | |
| sampling.add_argument("--context-length", type=int, default=8192, help="Chat tokens kept, oldest dropped first (up to 32768)") | |
| sampling.add_argument("--seed", type=int, default=None, help="Fix the sampling seed (default: a fresh seed per question)") | |
| runtime = parser.add_argument_group("runtime") | |
| runtime.add_argument("--device", default="cuda", help=f"CUDA device (cuda, cuda:1, …); {MODEL_NAME} needs a GPU") | |
| runtime.add_argument("--ngram-device", default=None, help="Where the 2.2 GB n-gram tables live: the GPU (default, --device) or cpu") | |
| runtime.add_argument("--no-color", action="store_true") | |
| runtime.add_argument("--local-dir", default=None, help="Download the model files into this folder") | |
| runtime.add_argument("--repo-id", default=REPO_ID) | |
| return parser.parse_args() | |
| def validate(args: argparse.Namespace) -> str | None: | |
| if args.count < 0 or args.turns < 1 or args.attempts < 1: | |
| return "--count must be 0 or more, --turns and --attempts at least 1" | |
| if args.workers is not None and args.workers < 1: | |
| return "--workers must be at least 1" | |
| if args.count and args.conversation: | |
| return "Use --count or --conversation, not both" | |
| if bool(args.answer_url) != bool(args.answer_model): | |
| return "--answer-url and --answer-model go together" | |
| if args.answer_url and not args.count: | |
| return "--answer-url is for batch mode; add --count" | |
| if args.answer_url and not args.answer_url.startswith(("http://", "https://")): | |
| return "--answer-url must start with http:// or https://" | |
| if args.answer_max_tokens < 1 or args.answer_timeout <= 0: | |
| return "--answer-max-tokens and --answer-timeout must be positive" | |
| if args.count and args.turns > 1 and not args.answer_url: | |
| return "--turns above 1 needs an answer model: add --answer-url and --answer-model" | |
| if not 1 <= args.max_new_tokens <= 32768 or not 256 <= args.context_length <= 32768: | |
| return "--max-new-tokens must be 1-32768 and --context-length 256-32768" | |
| if args.temperature < 0 or not 0 < args.top_p <= 1 or args.top_k < 0 or args.repetition_penalty <= 0: | |
| return "Sampling needs --temperature >= 0, 0 < --top-p <= 1, --top-k >= 0 and --repetition-penalty > 0" | |
| if args.repetition_window < 0: | |
| return "--repetition-window must be 0 (the whole chat) or more" | |
| try: | |
| extra = json.loads(args.answer_extra) | |
| except json.JSONDecodeError as error: | |
| return f"--answer-extra is not JSON: {error}" | |
| if not isinstance(extra, dict): | |
| return "--answer-extra must be a JSON object" | |
| return None | |
| def main() -> int: | |
| _silence_runtime_warnings() | |
| args = parse_args() | |
| problem = validate(args) | |
| if problem: | |
| print(problem, file=sys.stderr) | |
| return 2 | |
| conversation: list[dict[str, str]] | None = None | |
| if args.conversation is not None: | |
| try: | |
| conversation = load_conversation(args.conversation) | |
| except (OSError, ValueError) as error: | |
| print(f"Cannot read --conversation: {error}", file=sys.stderr) | |
| return 2 | |
| if args.system.strip() and not (conversation and conversation[0]["role"] == "system"): | |
| conversation.insert(0, {"role": "system", "content": args.system.strip()}) | |
| try: | |
| device = torch.device(args.device) | |
| ngram_device = torch.device(args.ngram_device) if args.ngram_device else device | |
| except RuntimeError as error: | |
| print(f"Invalid device: {error}", file=sys.stderr) | |
| return 2 | |
| if device.type != "cuda" or not torch.cuda.is_available(): | |
| print( | |
| f"{MODEL_NAME} needs a CUDA GPU: its Gated DeltaNet-2 and Parallax layers run on " | |
| f"flash-linear-attention Triton kernels. torch {torch.__version__} reports " | |
| f"CUDA available={torch.cuda.is_available()}.", | |
| file=sys.stderr, | |
| ) | |
| return 1 | |
| if ngram_device.type not in {"cuda", "cpu"}: | |
| print("--ngram-device must be a CUDA device or cpu", file=sys.stderr) | |
| return 2 | |
| source = Source( | |
| repo_id=args.repo_id, | |
| local_dir=Path(args.local_dir).resolve() if args.local_dir else None, | |
| local_copy=_local_copy() if args.repo_id == REPO_ID else None, | |
| ) | |
| where = source.local_copy or f"huggingface.co/{source.repo_id}" | |
| print(f"Loading {MODEL_NAME} from {where} …", file=sys.stderr, flush=True) | |
| try: | |
| package_root = _ensure_tiny_gdn(source) | |
| if str(package_root) not in sys.path: | |
| sys.path.insert(0, str(package_root)) | |
| _ensure_fla(source) | |
| stack = _import_stack() | |
| except Exception as error: # noqa: BLE001 | |
| print(f"Setup failed: {error}", file=sys.stderr) | |
| return 1 | |
| config = stack.TinyGDNConfig.from_json(_download(source, "config.json")) | |
| with torch.device(device): | |
| model = stack.Senryu2ForCausalLM(config) | |
| model = model.to(dtype=torch.bfloat16) | |
| model.load_state_dict(load_file(str(_download(source, "model.safetensors")), device=str(device)), strict=True) | |
| if model.ngram is not None: | |
| model.ngram.load_tables(_download(source, NGRAM_TABLES_FILE), device=ngram_device, dtype=torch.bfloat16) | |
| model.eval() | |
| model.requires_grad_(False) | |
| tokenizer = Tokenizer.from_file(str(_download(source, "tokenizer.json"))) | |
| sampling = Sampling( | |
| temperature=args.temperature, | |
| top_p=args.top_p, | |
| top_k=args.top_k, | |
| repetition_penalty=args.repetition_penalty, | |
| repetition_window=args.repetition_window, | |
| max_new_tokens=args.max_new_tokens, | |
| context_length=min(args.context_length, config.max_position_embeddings), | |
| ) | |
| prompter = Prompter(model, tokenizer, stack, device, sampling, args.seed) | |
| print( | |
| f"Ready: {config.num_hidden_layers} layers on {device}, n-gram tables on {ngram_device}.", | |
| file=sys.stderr, | |
| flush=True, | |
| ) | |
| if conversation is not None: | |
| return run_next_question(prompter, conversation) | |
| if args.count: | |
| answer = None | |
| if args.answer_url: | |
| answer = AnswerModel( | |
| base_url=args.answer_url, | |
| model=args.answer_model, | |
| api_key=os.environ.get(args.answer_key_env, "").strip(), | |
| max_tokens=args.answer_max_tokens, | |
| temperature=args.answer_temperature, | |
| extra=json.loads(args.answer_extra), | |
| timeout=args.answer_timeout, | |
| ) | |
| if args.workers is None: | |
| args.workers = 8 if answer else 1 | |
| return run_batch(prompter, args, answer) | |
| style = Style(not args.no_color and sys.stdout.isatty()) | |
| return run_interactive(prompter, args.system, style) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |