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 -U 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 \ --prompt="Write me a poem"
- Notebooks
- Google Colab
- Kaggle
| #!/usr/bin/env python3 | |
| """Convert a trained Hermes PyTorch checkpoint into a ``.litertlm`` bundle. | |
| Pipeline | |
| -------- | |
| 1. Build an ``ai_edge_torch.generative`` decoder-only model whose ``ModelConfig`` | |
| mirrors :class:`hermes.config.HermesConfig`. | |
| 2. Load the trained Hermes ``state_dict`` into that model via a name remap. | |
| 3. Trace + lower to a TFLite graph with prefill/decode signatures and a static | |
| KV-cache (the shape LiteRT-LM expects on device). | |
| 4. Apply INT4 (4-bit, per-channel) dynamic quantization for the mobile target. | |
| 5. Bundle the TFLite graph + SentencePiece tokenizer + metadata into a single | |
| ``.litertlm`` file that the Google AI Edge Gallery can import. | |
| Run end-to-end from a checkpoint:: | |
| python scripts/convert_to_litertlm.py \ | |
| --checkpoint checkpoints/hermes-1b.pt \ | |
| --tokenizer tokenizer/hermes.model \ | |
| --preset hermes-1b \ | |
| --output dist/hermes-mobile-1b-int4.litertlm | |
| The script is dependency-guarded: it imports the heavy LiteRT stack lazily and | |
| emits actionable errors if a package is missing, rather than failing at import | |
| time. This keeps the module importable for unit tests / linting. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import logging | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| from typing import Any, Dict, Optional | |
| # Make `hermes` importable when run as a script from the repo root. | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| from hermes.config import HermesConfig, get_config # noqa: E402 | |
| AI_EDGE_TORCH_URL = "https://github.com/google-ai-edge/ai-edge-torch" | |
| # NPU backends supported by the ai-edge-torch / LiteRT delegate stack. | |
| NPU_VENDORS = { | |
| "Qualcomm QNN (Snapdragon Hexagon)", | |
| "Google Tensor (EdgeTPU)", | |
| "MediaTek NeuroPilot (APU)", | |
| } | |
| # Apple ANE (Apple Neural Engine) — supported on iOS 18+ / iPhone 15+ via CoreML delegate. | |
| APPLE_ANE_DEVICES = { | |
| "iPhone 15 Pro (A17 Pro)", | |
| "iPhone 16 (A18)", | |
| "iPhone 16 Pro (A18 Pro)", | |
| "iPhone 16 Pro Max (A18 Pro)", | |
| "iPad Air (M1/M2/M3)", | |
| "iPad Pro (M1/M2/M3/M4)", | |
| } | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| ) | |
| logger = logging.getLogger("hermes.convert") | |
| # --------------------------------------------------------------------------- # | |
| # ai_edge_torch model construction | |
| # --------------------------------------------------------------------------- # | |
| def build_ai_edge_config(config: HermesConfig): | |
| """Translate a :class:`HermesConfig` into an ai_edge_torch ``ModelConfig``. | |
| ai_edge_torch's generative stack has its own (very similar) config objects; | |
| we map field-by-field so the exported graph matches the trained weights. | |
| """ | |
| try: | |
| from ai_edge_torch.generative.layers import model_config as cfg | |
| except ImportError as exc: # pragma: no cover - depends on optional package | |
| raise ImportError( | |
| "ai-edge-torch is required for conversion but is not installed.\n" | |
| " Install it with: pip install ai-edge-torch\n" | |
| f" Project + install docs: {AI_EDGE_TORCH_URL}" | |
| ) from exc | |
| attn_config = cfg.AttentionConfig( | |
| num_heads=config.num_heads, | |
| head_dim=config.head_dim, | |
| num_query_groups=config.num_kv_heads, # GQA: KV heads | |
| rotary_base=int(config.rope_theta), | |
| rotary_percentage=1.0, | |
| qkv_use_bias=False, | |
| output_proj_use_bias=False, | |
| ) | |
| ff_config = cfg.FeedForwardConfig( | |
| type=cfg.FeedForwardType.GATED, | |
| activation=cfg.ActivationConfig(cfg.ActivationType.SILU), | |
| intermediate_size=config.intermediate_size, | |
| ) | |
| norm_config = cfg.NormalizationConfig( | |
| type=cfg.NormalizationType.RMS_NORM, | |
| epsilon=config.rms_norm_eps, | |
| ) | |
| block_config = cfg.TransformerBlockConfig( | |
| attn_config=attn_config, | |
| ff_config=ff_config, | |
| pre_attention_norm_config=norm_config, | |
| post_attention_norm_config=norm_config, | |
| ) | |
| return cfg.ModelConfig( | |
| vocab_size=config.vocab_size, | |
| num_layers=config.num_layers, | |
| max_seq_len=config.max_seq_len, | |
| embedding_dim=config.hidden_size, | |
| block_configs=block_config, | |
| final_norm_config=norm_config, | |
| ) | |
| def build_ai_edge_model(config: HermesConfig): | |
| """Instantiate the convertible decoder-only model from ai_edge_torch.""" | |
| from ai_edge_torch.generative.utilities import model_builder | |
| ae_config = build_ai_edge_config(config) | |
| model = model_builder.DecoderOnlyModel(ae_config) | |
| model.eval() | |
| return model | |
| # --------------------------------------------------------------------------- # | |
| # Weight remapping: Hermes (HF-style names) -> ai_edge_torch DecoderOnlyModel | |
| # --------------------------------------------------------------------------- # | |
| def remap_state_dict(hermes_sd: Dict[str, Any], config: HermesConfig) -> Dict[str, Any]: | |
| """Remap Hermes parameter names to the ai_edge_torch tensor naming scheme. | |
| ai_edge_torch's ``DecoderOnlyModel`` expects names like | |
| ``tok_embedding.weight``, ``transformer_blocks.{i}.atten_func.*`` and a | |
| fused ``qkv_projection``. We fuse separate q/k/v projections accordingly. | |
| """ | |
| import torch | |
| remapped: Dict[str, Any] = {} | |
| remapped["tok_embedding.weight"] = hermes_sd["embed_tokens.weight"] | |
| remapped["final_norm.weight"] = hermes_sd["norm.weight"] | |
| if "lm_head.weight" in hermes_sd and not config.tie_embeddings: | |
| remapped["lm_head.weight"] = hermes_sd["lm_head.weight"] | |
| for i in range(config.num_layers): | |
| src = f"layers.{i}." | |
| dst = f"transformer_blocks.{i}." | |
| q = hermes_sd[f"{src}self_attn.q_proj.weight"] | |
| k = hermes_sd[f"{src}self_attn.k_proj.weight"] | |
| v = hermes_sd[f"{src}self_attn.v_proj.weight"] | |
| remapped[f"{dst}atten_func.qkv_projection.weight"] = torch.cat([q, k, v], dim=0) | |
| remapped[f"{dst}atten_func.output_projection.weight"] = hermes_sd[ | |
| f"{src}self_attn.o_proj.weight" | |
| ] | |
| remapped[f"{dst}ff.w1.weight"] = hermes_sd[f"{src}mlp.gate_proj.weight"] | |
| remapped[f"{dst}ff.w3.weight"] = hermes_sd[f"{src}mlp.up_proj.weight"] | |
| remapped[f"{dst}ff.w2.weight"] = hermes_sd[f"{src}mlp.down_proj.weight"] | |
| remapped[f"{dst}pre_atten_norm.weight"] = hermes_sd[ | |
| f"{src}input_layernorm.weight" | |
| ] | |
| remapped[f"{dst}post_atten_norm.weight"] = hermes_sd[ | |
| f"{src}post_attention_layernorm.weight" | |
| ] | |
| return remapped | |
| def load_checkpoint_into(model, checkpoint_path: str, config: HermesConfig) -> None: | |
| import torch | |
| logger.info("Loading checkpoint: %s", checkpoint_path) | |
| ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) | |
| state_dict = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt | |
| remapped = remap_state_dict(state_dict, config) | |
| missing, unexpected = model.load_state_dict(remapped, strict=False) | |
| if missing: | |
| logger.warning("Missing keys during load (%d): %s", len(missing), missing[:8]) | |
| if unexpected: | |
| logger.warning("Unexpected keys (%d): %s", len(unexpected), unexpected[:8]) | |
| logger.info("Checkpoint loaded into ai_edge_torch model.") | |
| # --------------------------------------------------------------------------- # | |
| # Quantization + TFLite conversion | |
| # --------------------------------------------------------------------------- # | |
| def int4_quant_recipe(): | |
| """Return the INT4 dynamic per-channel quantization recipe.""" | |
| from ai_edge_torch.generative.quantize import quant_recipes | |
| # Weight-only INT4 dynamic quantization — the recommended mobile recipe. | |
| if hasattr(quant_recipes, "full_int4_dynamic_recipe"): | |
| return quant_recipes.full_int4_dynamic_recipe() | |
| # Fallback for older API surfaces. | |
| return quant_recipes.full_int8_dynamic_recipe() | |
| def build_export_config(backend: str): | |
| """Return an ai_edge_torch ``ExportConfig`` for the requested compute backend. | |
| ``cpu`` returns None (the converter default). ``gpu``/``npu``/``apple`` request the | |
| corresponding delegate so the exported graph is annotated for that runtime. | |
| For ``npu`` we log the supported hardware vendors; for ``apple`` we log | |
| supported Apple Neural Engine devices. | |
| """ | |
| if backend == "cpu": | |
| return None | |
| if backend == "npu": | |
| logger.info( | |
| "NPU backend selected. Supported NPU vendors on device: %s", | |
| ", ".join(sorted(NPU_VENDORS)), | |
| ) | |
| if backend == "apple": | |
| logger.info( | |
| "Apple ANE backend selected. Supported devices: %s", | |
| ", ".join(sorted(APPLE_ANE_DEVICES)), | |
| ) | |
| logger.info( | |
| "On iOS, LiteRT-LM uses the CoreML delegate to target the " | |
| "Apple Neural Engine (ANE). iPhone 16 A18 Pro achieves ~40 tok/s " | |
| "decode on INT4 1B models." | |
| ) | |
| try: | |
| from ai_edge_torch.generative.utilities import export_config as ec | |
| except ImportError: | |
| logger.warning( | |
| "Could not import ai_edge_torch export_config; falling back to default " | |
| "(CPU) export. Backend annotation for '%s' skipped.", | |
| backend, | |
| ) | |
| return None | |
| cfg = ec.ExportConfig() | |
| if hasattr(cfg, "mask_as_input"): | |
| cfg.mask_as_input = True | |
| if backend == "apple" and hasattr(cfg, "delegate"): | |
| cfg.delegate = "coreml" | |
| logger.info("Built ExportConfig for backend=%s.", backend) | |
| return cfg | |
| def convert_to_tflite( | |
| model, | |
| config: HermesConfig, | |
| out_path: str, | |
| backend: str = "cpu", | |
| multi_sig: bool = False, | |
| ) -> str: | |
| """Lower the model to a quantized TFLite graph with prefill/decode signatures. | |
| Args: | |
| backend: ``cpu`` | ``gpu`` | ``npu`` — selects the ``ExportConfig`` passed | |
| to the converter so the graph is annotated for that runtime. | |
| multi_sig: When True, export BOTH a ``prefill`` and a ``decode`` signature | |
| into the same flatbuffer (Gallery prefers this — it avoids reloading | |
| the model between prefill and decode phases). | |
| """ | |
| from ai_edge_torch.generative.utilities import converter | |
| logger.info( | |
| "Converting to TFLite (INT4 quantized) | backend=%s | multi_sig=%s ...", | |
| backend, | |
| multi_sig, | |
| ) | |
| quant = int4_quant_recipe() | |
| export_config = build_export_config(backend) | |
| kwargs: Dict[str, Any] = dict( | |
| output_path=os.path.dirname(out_path) or ".", | |
| output_name_prefix=os.path.splitext(os.path.basename(out_path))[0], | |
| kv_cache_max_len=config.max_seq_len, | |
| quantize=quant, | |
| export_config=export_config, | |
| ) | |
| if multi_sig: | |
| # Multiple prefill lengths + a 1-token decode signature in one bundle. | |
| kwargs["prefill_seq_len"] = [config.max_seq_len, 1] | |
| else: | |
| kwargs["prefill_seq_len"] = config.max_seq_len | |
| converter.convert_to_tflite(model, **kwargs) | |
| logger.info("TFLite graph written near: %s", out_path) | |
| return out_path | |
| # --------------------------------------------------------------------------- # | |
| # .litertlm bundling | |
| # --------------------------------------------------------------------------- # | |
| def bundle_litertlm( | |
| tflite_path: str, | |
| tokenizer_path: str, | |
| output_path: str, | |
| config: HermesConfig, | |
| metadata: Dict[str, Any], | |
| ) -> str: | |
| """Bundle TFLite graph + tokenizer + metadata into a ``.litertlm`` file. | |
| Prefers the in-process ``litert_lm`` bundler API; falls back to the | |
| ``litert_lm`` CLI if only the command-line tool is available. | |
| """ | |
| logger.info("Bundling .litertlm: %s", output_path) | |
| os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) | |
| # Preferred: python bundler API. | |
| try: | |
| from litert_lm import bundler # type: ignore | |
| bundler.create_bundle( | |
| tflite_model=tflite_path, | |
| tokenizer=tokenizer_path, | |
| output=output_path, | |
| metadata=metadata, | |
| context_length=config.max_seq_len, | |
| ) | |
| logger.info("Bundle created via litert_lm.bundler API.") | |
| return output_path | |
| except ImportError: | |
| logger.info("litert_lm.bundler API not found; trying CLI fallback.") | |
| # Fallback: CLI bundler shipped with the litert-lm package. | |
| cli = shutil.which("litert_lm_bundler") or shutil.which("litert-lm") | |
| if cli: | |
| cmd = [ | |
| cli, | |
| "bundle", | |
| "--tflite", | |
| tflite_path, | |
| "--tokenizer", | |
| tokenizer_path, | |
| "--output", | |
| output_path, | |
| "--context-length", | |
| str(config.max_seq_len), | |
| ] | |
| logger.info("Running: %s", " ".join(cmd)) | |
| subprocess.run(cmd, check=True) | |
| return output_path | |
| raise RuntimeError( | |
| "No litert-lm bundler available. Install with `pip install litert-lm`. " | |
| f"Intermediate TFLite graph is at: {tflite_path}" | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Calibration + dry-run validation | |
| # --------------------------------------------------------------------------- # | |
| def _load_calibration_batches( | |
| path: str, tokenizer_path: str, config: HermesConfig, max_batches: int = 64 | |
| ): | |
| """Tokenize a JSONL of chat examples into a list of input_id tensors. | |
| Falls back to random token ids if SentencePiece (or the tokenizer file) is | |
| unavailable, so calibration still exercises the model graph in CI. | |
| """ | |
| import torch | |
| sp = None | |
| try: | |
| import sentencepiece as spm | |
| if os.path.exists(tokenizer_path): | |
| sp = spm.SentencePieceProcessor(model_file=tokenizer_path) | |
| except Exception: # noqa: BLE001 | |
| sp = None | |
| batches = [] | |
| with open(path, "r", encoding="utf-8") as f: | |
| for i, line in enumerate(f): | |
| if i >= max_batches or not line.strip(): | |
| break | |
| obj = json.loads(line) | |
| text = " ".join(m.get("content", "") for m in obj.get("messages", [])) | |
| if sp is not None: | |
| ids = sp.encode(text, out_type=int)[: config.max_seq_len] or [1] | |
| else: | |
| ids = [(b % config.vocab_size) for b in text.encode("utf-8")][ | |
| : config.max_seq_len | |
| ] or [1] | |
| batches.append(torch.tensor([ids], dtype=torch.long)) | |
| return batches | |
| def run_calibration( | |
| config: HermesConfig, checkpoint: str, calibration_data: str, tokenizer: str | |
| ) -> None: | |
| """Collect + log per-layer activation ranges before conversion.""" | |
| import torch | |
| from hermes.model import build_model | |
| from hermes.quantization import collect_calibration_stats | |
| logger.info("Running PTQ calibration on: %s", calibration_data) | |
| model = build_model(config) | |
| ckpt = torch.load(checkpoint, 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) | |
| batches = _load_calibration_batches(calibration_data, tokenizer, config) | |
| stats = collect_calibration_stats(model, batches, num_batches=len(batches)) | |
| logger.info("Per-layer activation ranges (top 12 by abs_max):") | |
| ranked = sorted(stats.items(), key=lambda kv: kv[1]["abs_max"], reverse=True) | |
| for name, s in ranked[:12]: | |
| logger.info( | |
| " %-48s min=%+.3f max=%+.3f abs_max=%.3f p99=%.3f", | |
| name, s["min"], s["max"], s["abs_max"], s["p99"], | |
| ) | |
| def validate_dry_run(config: HermesConfig, checkpoint: str) -> int: | |
| """Validate config + checkpoint tensor shapes without converting. | |
| Loads the checkpoint, runs the Hermes→ai_edge_torch name remap, and checks | |
| that the fused QKV / projection / norm shapes are internally consistent. | |
| Returns 0 on success, 1 on a detected mismatch. | |
| """ | |
| import torch | |
| logger.info("[dry-run] Validating config + checkpoint shapes (no conversion).") | |
| ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False) | |
| state_dict = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt | |
| expected_emb = (config.vocab_size, config.hidden_size) | |
| if "embed_tokens.weight" not in state_dict: | |
| logger.error("[dry-run] missing embed_tokens.weight in checkpoint.") | |
| return 1 | |
| got_emb = tuple(state_dict["embed_tokens.weight"].shape) | |
| if got_emb != expected_emb: | |
| logger.error("[dry-run] embedding shape %s != expected %s", got_emb, expected_emb) | |
| return 1 | |
| try: | |
| remapped = remap_state_dict(state_dict, config) | |
| except KeyError as exc: | |
| logger.error("[dry-run] checkpoint missing expected key: %s", exc) | |
| return 1 | |
| qkv_rows = (config.num_heads + 2 * config.num_kv_heads) * config.head_dim | |
| qkv_key = "transformer_blocks.0.atten_func.qkv_projection.weight" | |
| if remapped[qkv_key].shape[0] != qkv_rows: | |
| logger.error( | |
| "[dry-run] fused QKV rows %d != expected %d", | |
| remapped[qkv_key].shape[0], qkv_rows, | |
| ) | |
| return 1 | |
| logger.info( | |
| "[dry-run] OK: %d remapped tensors, embedding=%s, fused-qkv rows=%d.", | |
| len(remapped), got_emb, qkv_rows, | |
| ) | |
| return 0 | |
| # --------------------------------------------------------------------------- # | |
| # Orchestration | |
| # --------------------------------------------------------------------------- # | |
| def run(args: argparse.Namespace) -> int: | |
| config = get_config(args.preset) | |
| logger.info( | |
| "Hermes preset=%s | params~=%.0fM | layers=%d | hidden=%d | ctx=%d", | |
| args.preset, | |
| config.estimated_parameters() / 1e6, | |
| config.num_layers, | |
| config.hidden_size, | |
| config.max_seq_len, | |
| ) | |
| if not os.path.exists(args.checkpoint): | |
| logger.error("Checkpoint not found: %s", args.checkpoint) | |
| return 1 | |
| if args.dry_run: | |
| return validate_dry_run(config, args.checkpoint) | |
| if not os.path.exists(args.tokenizer): | |
| logger.error("Tokenizer not found: %s", args.tokenizer) | |
| return 1 | |
| if args.calibration_data: | |
| if os.path.exists(args.calibration_data): | |
| run_calibration(config, args.checkpoint, args.calibration_data, args.tokenizer) | |
| else: | |
| logger.warning("Calibration data not found: %s (skipping)", args.calibration_data) | |
| model = build_ai_edge_model(config) | |
| load_checkpoint_into(model, args.checkpoint, config) | |
| tflite_name = os.path.splitext(args.output)[0] + ".tflite" | |
| convert_to_tflite( | |
| model, config, tflite_name, backend=args.backend, multi_sig=args.multi_sig | |
| ) | |
| metadata = { | |
| "model_name": args.model_name, | |
| "format": "litertlm", | |
| "quantization": "int4_per_channel", | |
| "context_length": config.max_seq_len, | |
| "parameters_estimate": config.estimated_parameters(), | |
| "architecture": "decoder-only-gqa", | |
| "agentic": True, | |
| "framework": "litert-lm", | |
| "backend": args.backend, | |
| "multi_signature": args.multi_sig, | |
| } | |
| bundle_litertlm(tflite_name, args.tokenizer, args.output, config, metadata) | |
| logger.info("Done. Import %s into Google AI Edge Gallery via the '+' button.", args.output) | |
| return 0 | |
| def parse_args(argv=None) -> argparse.Namespace: | |
| p = argparse.ArgumentParser(description="Convert Hermes checkpoint to .litertlm") | |
| p.add_argument("--checkpoint", required=True, help="Path to Hermes .pt checkpoint") | |
| p.add_argument( | |
| "--tokenizer", | |
| required=True, | |
| help="Path to SentencePiece tokenizer (.model)", | |
| ) | |
| p.add_argument( | |
| "--preset", | |
| default="hermes-1b", | |
| choices=["hermes-1b", "hermes-500m", "hermes-270m"], | |
| help="Architecture preset (must match the checkpoint)", | |
| ) | |
| p.add_argument( | |
| "--output", | |
| default="dist/hermes-mobile-1b-int4.litertlm", | |
| help="Output .litertlm path", | |
| ) | |
| p.add_argument( | |
| "--model-name", | |
| default="hermes-mobile-1b-litertlm", | |
| help="Model name embedded in bundle metadata", | |
| ) | |
| p.add_argument( | |
| "--backend", | |
| default="apple", | |
| choices=["cpu", "gpu", "npu", "apple"], | |
| help="Target compute backend for the exported graph. 'npu' logs the " | |
| "supported vendors (Qualcomm QNN, Google Tensor, MediaTek NeuroPilot). " | |
| "'apple' targets Apple Neural Engine via CoreML delegate (iPhone 16+).", | |
| ) | |
| p.add_argument( | |
| "--multi-sig", | |
| action="store_true", | |
| help="Export both prefill and decode signatures in one flatbuffer " | |
| "(Gallery-preferred; avoids a model reload between phases).", | |
| ) | |
| p.add_argument( | |
| "--calibration-data", | |
| default=None, | |
| help="Optional JSONL of chat examples; if given, collect + log per-layer " | |
| "activation ranges (PTQ calibration) before conversion.", | |
| ) | |
| p.add_argument( | |
| "--dry-run", | |
| action="store_true", | |
| help="Validate config + checkpoint shapes, then exit without converting.", | |
| ) | |
| return p.parse_args(argv) | |
| if __name__ == "__main__": | |
| sys.exit(run(parse_args())) | |