Text Generation
LiteRT-LM
English
custom
hermes-edge
mobile-ai
on-device
ios
iphone-16
apple-neural-engine
deepseek
dspark
speculative-decoding
hermes-agent
tool-calling
raven-ecosystem
Instructions to use bclermo/hermes-edge with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LiteRT-LM
How to use bclermo/hermes-edge with LiteRT-LM:
# LiteRT-LM runs on various platforms (Android, iOS, Windows, Linux, macOS, IoT, Web/WASM) # and supports many APIs (C++, Python, Kotlin, Swift, JavaScript, Flutter). # For platform-specific integration guides, please refer to the official developer website: # https://ai.google.dev/edge/litert-lm # To try LiteRT-LM, the easiest way is to use our CLI tool. # 1. Install the LiteRT-LM CLI tool: pip install litert-lm # 2. Download and run this model locally: # See: https://ai.google.dev/edge/litert-lm/cli litert-lm run \ --from-huggingface-repo=bclermo/hermes-edge \ model.litertlm \ --prompt="Write me a poem"
- Notebooks
- Google Colab
- Kaggle
| """Streaming inference engine for the Hermes mobile transformer. | |
| :class:`HermesInference` wraps a :class:`~hermes.model.HermesForCausalLM` and a | |
| SentencePiece tokenizer into a single object with three entry points: | |
| * :meth:`generate` — low-level text completion with nucleus (top-p), top-k, and | |
| repetition-penalty sampling, optionally streaming token strings as they decode. | |
| * :meth:`chat` — renders a message list through the Hermes ChatML template, then | |
| generates an assistant turn. | |
| * :meth:`tool_call_loop` — the agentic loop: generate, parse any ``<tool_call>``, | |
| dispatch it to a Python callable, feed the ``<tool_response>`` back, and repeat | |
| until the model produces a plain answer (or ``max_rounds`` is hit). | |
| Decoding reuses the existing :class:`~hermes.model.Attention` KV-cache: the | |
| prompt is run once to prime per-layer caches, then each new token is decoded with | |
| a single-position forward pass, so cost is linear in generated length rather than | |
| quadratic. | |
| The tokenizer is duck-typed: anything exposing ``encode(str) -> list[int]`` and | |
| ``decode(list[int]) -> str`` works, which covers ``sentencepiece`` and the tiny | |
| byte-level stub used in tests. | |
| """ | |
| from __future__ import annotations | |
| from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union | |
| import torch | |
| import torch.nn.functional as F | |
| from hermes.chat_template import ( | |
| Message, | |
| build_prompt, | |
| parse_tool_call, | |
| ) | |
| from hermes.config import HermesConfig | |
| from hermes.model import HermesForCausalLM, build_model | |
| KVList = List[Optional[Tuple[torch.Tensor, torch.Tensor]]] | |
| ToolFn = Callable[..., Any] | |
| class HermesInference: | |
| """Load a Hermes checkpoint + tokenizer and run streaming generation.""" | |
| def __init__( | |
| self, | |
| model: HermesForCausalLM, | |
| tokenizer: Any, | |
| device: Union[str, torch.device] = "cpu", | |
| preset_name: str = "custom", | |
| ) -> None: | |
| self.device = torch.device(device) | |
| self.model = model.to(self.device).eval() | |
| self.tokenizer = tokenizer | |
| self.config: HermesConfig = model.config | |
| self.preset_name = preset_name | |
| # ------------------------------------------------------------------ # | |
| # Construction helpers | |
| # ------------------------------------------------------------------ # | |
| def from_checkpoint( | |
| cls, | |
| config: HermesConfig, | |
| checkpoint_path: Optional[str], | |
| tokenizer: Any, | |
| device: Union[str, torch.device] = "cpu", | |
| preset_name: str = "custom", | |
| ) -> "HermesInference": | |
| """Build a model from ``config``, optionally load weights, and wrap it. | |
| If ``checkpoint_path`` is None the model keeps its random init — handy for | |
| CI and shape tests that don't need a trained checkpoint. | |
| """ | |
| model = build_model(config) | |
| if checkpoint_path is not None: | |
| ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) | |
| state_dict = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt | |
| model.load_state_dict(state_dict, strict=False) | |
| return cls(model, tokenizer, device=device, preset_name=preset_name) | |
| def __repr__(self) -> str: | |
| n_params = sum(p.numel() for p in self.model.parameters()) | |
| return ( | |
| f"HermesInference(preset={self.preset_name!r}, " | |
| f"params={n_params / 1e6:.1f}M, " | |
| f"layers={self.config.num_layers}, ctx={self.config.max_seq_len}, " | |
| f"device={self.device.type})" | |
| ) | |
| # ------------------------------------------------------------------ # | |
| # Sampling | |
| # ------------------------------------------------------------------ # | |
| def _apply_repetition_penalty( | |
| logits: torch.Tensor, generated: List[int], penalty: float | |
| ) -> torch.Tensor: | |
| """Divide logits of already-seen tokens by ``penalty`` (CTRL-style).""" | |
| if penalty == 1.0 or not generated: | |
| return logits | |
| idx = torch.tensor(sorted(set(generated)), device=logits.device) | |
| selected = logits.index_select(-1, idx) | |
| # Positive logits are divided, negative are multiplied (push both down). | |
| selected = torch.where(selected > 0, selected / penalty, selected * penalty) | |
| logits = logits.index_copy(-1, idx, selected) | |
| return logits | |
| def _sample( | |
| logits: torch.Tensor, | |
| temperature: float, | |
| top_p: float, | |
| top_k: int, | |
| ) -> int: | |
| """Sample a single token id from ``logits`` with top-k + nucleus filtering.""" | |
| if temperature <= 0.0: | |
| return int(logits.argmax(dim=-1)) | |
| logits = logits / temperature | |
| if top_k and top_k > 0: | |
| k = min(top_k, logits.size(-1)) | |
| kth = torch.topk(logits, k).values[..., -1, None] | |
| logits = torch.where( | |
| logits < kth, torch.full_like(logits, float("-inf")), logits | |
| ) | |
| if top_p and 0.0 < top_p < 1.0: | |
| sorted_logits, sorted_idx = torch.sort(logits, descending=True) | |
| probs = F.softmax(sorted_logits, dim=-1) | |
| cumulative = torch.cumsum(probs, dim=-1) | |
| # Keep tokens up to and including the one that crosses top_p. | |
| remove = cumulative - probs > top_p | |
| sorted_logits = sorted_logits.masked_fill(remove, float("-inf")) | |
| logits = torch.full_like(logits, float("-inf")).scatter( | |
| -1, sorted_idx, sorted_logits | |
| ) | |
| probs = F.softmax(logits, dim=-1) | |
| return int(torch.multinomial(probs, num_samples=1)) | |
| # ------------------------------------------------------------------ # | |
| # KV-cache primed decode | |
| # ------------------------------------------------------------------ # | |
| def _forward_with_cache( | |
| self, | |
| input_ids: torch.Tensor, | |
| caches: KVList, | |
| start_pos: int, | |
| ) -> Tuple[torch.Tensor, KVList]: | |
| """Run the model for ``input_ids`` reusing/extending per-layer KV caches. | |
| Returns the last-position logits and the updated cache list. This bypasses | |
| ``HermesForCausalLM.forward`` so it can thread the per-layer cache tuples | |
| through the existing :class:`Attention` ``kv_cache`` argument. | |
| """ | |
| model = self.model | |
| b, t = input_ids.shape | |
| x = model.embed_tokens(input_ids) | |
| cos = model.rope_cos[start_pos : start_pos + t].to(x.device) | |
| sin = model.rope_sin[start_pos : start_pos + t].to(x.device) | |
| # Causal mask over the *full* attended length (past + current). | |
| total = start_pos + t | |
| full_mask = torch.full((t, total), float("-inf"), device=x.device) | |
| full_mask = torch.triu(full_mask, diagonal=1 + start_pos) | |
| new_caches: KVList = [None] * len(model.layers) | |
| for i, layer in enumerate(model.layers): | |
| h, new_cache = layer.self_attn( | |
| layer.input_layernorm(x), cos, sin, full_mask, caches[i] | |
| ) | |
| x = x + h | |
| x = x + layer.mlp(layer.post_attention_layernorm(x)) | |
| new_caches[i] = new_cache | |
| x = model.norm(x) | |
| logits = model.lm_head(x[:, -1, :]) | |
| return logits, new_caches | |
| def _generate_ids( | |
| self, | |
| prompt_ids: List[int], | |
| max_new_tokens: int, | |
| temperature: float, | |
| top_p: float, | |
| top_k: int, | |
| repetition_penalty: float, | |
| ) -> Iterator[int]: | |
| """Yield newly generated token ids one at a time, using a KV cache.""" | |
| self.model.eval() | |
| eos = self.config.eos_token_id | |
| caches: KVList = [None] * len(self.model.layers) | |
| # Keep room for at least one generated token: truncate the prompt to the | |
| # most recent (max_seq_len - 1) tokens if it would otherwise fill context. | |
| max_prompt = max(1, self.config.max_seq_len - 1) | |
| if len(prompt_ids) > max_prompt: | |
| prompt_ids = prompt_ids[-max_prompt:] | |
| # Prime the cache on the full prompt in one prefill pass. | |
| ids = torch.tensor([prompt_ids], dtype=torch.long, device=self.device) | |
| logits, caches = self._forward_with_cache(ids, caches, start_pos=0) | |
| pos = len(prompt_ids) | |
| generated: List[int] = [] | |
| for _ in range(max_new_tokens): | |
| step_logits = self._apply_repetition_penalty( | |
| logits.clone(), prompt_ids + generated, repetition_penalty | |
| ) | |
| next_id = self._sample(step_logits.squeeze(0), temperature, top_p, top_k) | |
| if next_id == eos: | |
| break | |
| generated.append(next_id) | |
| yield next_id | |
| if pos >= self.config.max_seq_len: | |
| break | |
| step = torch.tensor([[next_id]], dtype=torch.long, device=self.device) | |
| logits, caches = self._forward_with_cache(step, caches, start_pos=pos) | |
| pos += 1 | |
| # ------------------------------------------------------------------ # | |
| # Public generation API | |
| # ------------------------------------------------------------------ # | |
| def generate( | |
| self, | |
| prompt: str, | |
| max_new_tokens: int = 128, | |
| temperature: float = 0.8, | |
| top_p: float = 0.95, | |
| top_k: int = 50, | |
| repetition_penalty: float = 1.1, | |
| stream: bool = False, | |
| ) -> Union[str, Iterator[str]]: | |
| """Generate text from ``prompt``. | |
| Returns the full completion string, or — if ``stream=True`` — a generator | |
| that yields incremental token strings as they are produced. | |
| """ | |
| prompt_ids = self.tokenizer.encode(prompt) | |
| def _token_strings() -> Iterator[str]: | |
| prev_text = "" | |
| buffer: List[int] = [] | |
| for tok in self._generate_ids( | |
| prompt_ids, max_new_tokens, temperature, top_p, top_k, repetition_penalty | |
| ): | |
| buffer.append(tok) | |
| # Decode incrementally so multi-token characters render correctly. | |
| text = self.tokenizer.decode(buffer) | |
| delta = text[len(prev_text) :] | |
| if delta: | |
| prev_text = text | |
| yield delta | |
| if stream: | |
| return _token_strings() | |
| return "".join(_token_strings()) | |
| def chat( | |
| self, | |
| messages: List[Message], | |
| tools: Optional[List[Dict[str, Any]]] = None, | |
| **kwargs: Any, | |
| ) -> str: | |
| """Render ``messages`` (+ optional ``tools``) and generate a reply string.""" | |
| prompt = build_prompt(messages, tools=tools) | |
| result = self.generate(prompt, stream=False, **kwargs) | |
| assert isinstance(result, str) | |
| return result | |
| def tool_call_loop( | |
| self, | |
| messages: List[Message], | |
| tools: List[Dict[str, Any]], | |
| tool_functions: Dict[str, ToolFn], | |
| max_rounds: int = 5, | |
| **kwargs: Any, | |
| ) -> List[Message]: | |
| """Agentic loop: generate → parse tool call → dispatch → feed back. | |
| Each round generates an assistant turn. If it contains a parseable | |
| ``<tool_call>``, the named callable in ``tool_functions`` is invoked with | |
| the parsed arguments and its result is appended as a ``tool`` message; | |
| otherwise the loop ends. Returns the full conversation including all | |
| assistant and tool turns appended. | |
| Args: | |
| messages: Seed conversation (mutated copy is returned). | |
| tools: Tool schemas advertised to the model in the system prompt. | |
| tool_functions: Maps tool ``name`` → Python callable. | |
| max_rounds: Hard cap on generate/dispatch cycles. | |
| """ | |
| convo = list(messages) | |
| for _ in range(max_rounds): | |
| reply = self.chat(convo, tools=tools, **kwargs) | |
| convo.append(Message("assistant", reply)) | |
| call = parse_tool_call(reply) | |
| if call is None: | |
| break | |
| fn = tool_functions.get(call["name"]) | |
| if fn is None: | |
| convo.append( | |
| Message("tool", f'{{"error": "unknown tool: {call["name"]}"}}') | |
| ) | |
| continue | |
| try: | |
| result = fn(**call.get("arguments", {})) | |
| except Exception as exc: # surface tool errors back to the model | |
| result = {"error": str(exc)} | |
| convo.append(Message("tool", str(result))) | |
| return convo | |
| if __name__ == "__main__": # pragma: no cover - manual smoke check | |
| from hermes.config import hermes_270m_config | |
| class _ByteTokenizer: | |
| def encode(self, text: str) -> List[int]: | |
| return [b % 32000 for b in text.encode("utf-8")] or [1] | |
| def decode(self, ids: List[int]) -> str: | |
| return bytes(i % 256 for i in ids).decode("utf-8", errors="replace") | |
| engine = HermesInference.from_checkpoint( | |
| hermes_270m_config(), None, _ByteTokenizer(), preset_name="hermes-270m" | |
| ) | |
| print(engine) | |
| print(engine.generate("Hello", max_new_tokens=8, temperature=0.0)) | |