Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion common/chat-auto-parser-generator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ common_chat_params peg_generator::generate_parser(const common_chat_template &
data.generation_prompt = common_chat_template_generation_prompt(tmpl, inputs);
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
data.preserved_tokens = autoparser.preserved_tokens;
data.additional_stops.insert(data.additional_stops.end(),
autoparser.additional_stops.begin(), autoparser.additional_stops.end());

std::string parser_generation_prompt = data.generation_prompt;

Expand Down Expand Up @@ -286,7 +288,13 @@ common_peg_parser analyze_tools::build_func_parser(common_chat_peg_builder & p,
// we only emit tool_close when we can actually see the closing marker. This prevents
// premature closing during partial parsing when we've seen e.g. "</" which could be
// either "</tool_call>" (end) or "<arg_key>" prefix that failed to match.
func_parser = func_parser + p.tool_close(p.peek(p.literal(format.per_call_end)));
// Laguna (v4): the model may emit whitespace between the last </arg_value> and
// </tool_call> even though the template renders them tight. Tolerate optional
// leading space in the close lookahead so the tool call still closes.
auto close_peek = arguments.tolerate_intertag_whitespace
? p.peek(p.space() + p.literal(format.per_call_end))
: p.peek(p.literal(format.per_call_end));
func_parser = func_parser + p.tool_close(close_peek);
} else {
func_parser = func_parser + p.tool_close(p.space()); // force this to process tool closing callbacks in mapper
}
Expand Down
2 changes: 2 additions & 0 deletions common/chat-auto-parser.h
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ struct tool_arguments_analysis {
std::string value_prefix; // e.g., "", "<arg_value>", ""
std::string value_suffix; // e.g., "</param>", "</arg_value>", ""
std::string separator; // e.g., "", "\n", ","
bool tolerate_intertag_whitespace = false; // Laguna: accept optional whitespace between arg tags
};

struct tool_id_analysis {
Expand Down Expand Up @@ -388,6 +389,7 @@ struct autoparser {

// Preserved tokens for tokenizer (union of all non-empty markers)
std::vector<std::string> preserved_tokens;
std::vector<std::string> additional_stops; // literal stop strings (e.g. Laguna </assistant>) caught however tokenized

autoparser() = default;

Expand Down
20 changes: 20 additions & 0 deletions common/chat-diff-analyzer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,26 @@ static std::vector<std::function<void(const common_chat_template & tmpl, autopar
LOG_DBG(ANSI_ORANGE "[Patch: JSON name/parameters tool instruction]\n" ANSI_RESET);
}
},
// Laguna (poolside) - the v4 chat template renders reasoning and tool-arg
// delimiters with formatting whitespace ("<think>\n", "</arg_value>\n") that
// the model does not emit, so the inferred delimiters carry a spurious
// newline and never match the model output. Trim to the bare tag. (v8
// renders without the whitespace, so this is a no-op there.)
[](const common_chat_template & tmpl, autoparser & analysis) -> void {
if (tmpl.src.find("laguna_glm_thinking") != std::string::npos) {
analysis.reasoning.start = trim_whitespace(analysis.reasoning.start);
analysis.reasoning.end = trim_whitespace(analysis.reasoning.end);
analysis.tools.arguments.value_prefix = trim_whitespace(analysis.tools.arguments.value_prefix);
analysis.tools.arguments.value_suffix = trim_whitespace(analysis.tools.arguments.value_suffix);
analysis.tools.arguments.separator = trim_whitespace(analysis.tools.arguments.separator);
analysis.tools.arguments.tolerate_intertag_whitespace = true;
// The CONTROL/eot </assistant> token only halts generation when emitted as the
// single token; after tool calls the model can spell it out as text tokens.
// A literal stop string catches it either way.
analysis.additional_stops.push_back("</assistant>");
LOG_DBG(ANSI_ORANGE "[Patch: Laguna]\n" ANSI_RESET);
}
},

});

Expand Down
1 change: 1 addition & 0 deletions conversion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

TEXT_MODEL_MAP: dict[str, str] = {
"AfmoeForCausalLM": "afmoe",
"LagunaForCausalLM": "laguna",
"ApertusForCausalLM": "llama",
"ArceeForCausalLM": "llama",
"ArcticForCausalLM": "arctic",
Expand Down
3 changes: 3 additions & 0 deletions conversion/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1345,15 +1345,15 @@

from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(self.dir_model)
vocab_size = self.hparams.get("vocab_size", len(tokenizer.vocab)) # ty: ignore[unresolved-attribute]

Check warning on line 1348 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1348:76: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment
assert max(tokenizer.vocab.values()) < vocab_size # ty: ignore[unresolved-attribute]

Check warning on line 1349 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1349:60: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment

tokpre = self.get_vocab_base_pre(tokenizer)

reverse_vocab = {id_: encoded_tok for encoded_tok, id_ in tokenizer.vocab.items()} # ty: ignore[unresolved-attribute]

Check warning on line 1353 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1353:93: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment
added_vocab = tokenizer.get_added_vocab() # ty: ignore[unresolved-attribute]

Check warning on line 1354 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1354:52: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment

added_tokens_decoder = tokenizer.added_tokens_decoder # ty: ignore[unresolved-attribute]

Check warning on line 1356 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1356:64: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment

for i in range(vocab_size):
if i not in reverse_vocab:
Expand All @@ -1366,7 +1366,7 @@
# To avoid unexpected issues - we make sure to normalize non-normalized tokens
if not added_tokens_decoder[i].normalized:
previous_token = token
token = tokenizer.decode(tokenizer.encode(token, add_special_tokens=False)) # ty: ignore[unresolved-attribute, invalid-assignment]

Check warning on line 1369 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1369:102: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment
if previous_token != token:
logger.info(f"{repr(previous_token)} is encoded and decoded back to {repr(token)} using AutoTokenizer")

Expand Down Expand Up @@ -1682,6 +1682,9 @@
if chkhsh == "9dcf830ee9990cdbf78cc523a5f7bd9ad8f3f9890c2d3581d2785ad10f07049d":
# ref: https://huggingface.co/JetBrains/Mellum2-12B-A2.5B-Base
res = "mellum2"
if chkhsh == "972da7b59cec44d1f0a490a86c96df53859e486e481563e5dddac155013d87ac":
# ref: https://huggingface.co/poolside/Laguna-XS.2
res = "laguna"

if res is None:
logger.warning("\n")
Expand Down Expand Up @@ -1730,14 +1733,14 @@
def _set_vocab_hybriddna(self):
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(self.dir_model, trust_remote_code=True)
vocab_size = self.hparams.get("vocab_size", len(tokenizer.vocab)) # ty: ignore[unresolved-attribute]

Check warning on line 1736 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1736:76: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment
assert max(tokenizer.vocab.values()) < vocab_size # ty: ignore[unresolved-attribute]

Check warning on line 1737 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1737:60: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment

reverse_vocab = {id_: encoded_tok for encoded_tok, id_ in tokenizer.vocab.items()} # ty: ignore[unresolved-attribute]

Check warning on line 1739 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1739:93: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment
# k-mers can share text with a base-vocab BPE token (e.g. CCCCCC) and get
# dropped by get_vocab(); a reserved marker suffix (U+E000) keeps each
# k-mer's own id (llama.cpp strips it on detokenization)
for kmer in tokenizer.kmers: # ty: ignore[unresolved-attribute]

Check warning on line 1743 in conversion/base.py

View workflow job for this annotation

GitHub Actions / python type-check

ty (unused-ignore-comment)

conversion/base.py:1743:39: unused-ignore-comment: Unused `ty: ignore` directive help: Remove the unused suppression comment
reverse_vocab[tokenizer.dna_token_to_id[kmer]] = kmer + "\ue000" # ty: ignore[unresolved-attribute]
added_vocab = tokenizer.get_added_vocab() # ty: ignore[unresolved-attribute]
added_tokens_decoder = tokenizer.added_tokens_decoder # ty: ignore[unresolved-attribute]
Expand Down
207 changes: 207 additions & 0 deletions conversion/laguna.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
from __future__ import annotations

import re
from collections.abc import Iterable
from typing import TYPE_CHECKING

import torch

if TYPE_CHECKING:
from torch import Tensor

from .base import ModelBase, TextModel, gguf, logger


@ModelBase.register("LagunaForCausalLM")
class LagunaModel(TextModel):
model_arch = gguf.MODEL_ARCH.LAGUNA
_experts: list[dict] | None = None
_gate_types: list[str] | None = None

# --- vocab ---------------------------------------------------------------

def set_vocab(self) -> None:
self._set_vocab_gpt2()

# Some Laguna releases wrap the chat template in tokenizer_config.json as
# "{% include 'chat_template.jinja' %}", which SpecialVocab embeds verbatim
# and llama.cpp's jinja engine cannot process. Prefer the resolved template
# from the chat_template.jinja file so the GGUF is self-contained.
tmpl_file = self.dir_model / "chat_template.jinja"
if tmpl_file.is_file():
self.gguf_writer.add_chat_template(tmpl_file.read_text(encoding="utf-8"))
logger.info("gguf: embedded resolved chat_template.jinja (overriding include directive)")

# eos_token_id is a list [2, 24]: token 2 (EOS, also BOS) and token 24
# (</assistant>, the turn-end). _set_vocab_gpt2 only records the scalar
# eos, so register the extra id as eot; llama.cpp folds eot into its EOG
# set, so the model halts on </assistant> natively.
eos_ids = self.hparams.get("eos_token_id")
if isinstance(eos_ids, list):
bos_id = self.hparams.get("bos_token_id")
extra = [e for e in eos_ids if e != bos_id]
if extra:
self.gguf_writer.add_eot_token_id(extra[0])
logger.info(f"gguf: registered eot_token_id={extra[0]} from eos list {eos_ids}")

def get_vocab_base(self) -> tuple[list[str], list[int], str]:
# </assistant> is the assistant turn-end (registered as eot below). The
# HF tokenizer flags it special=false, so the base classifies it as
# USER_DEFINED and llama.cpp renders its text into generated content,
# leaking "</assistant>" and breaking response parsing. It is a control
# marker, so promote it to CONTROL: llama.cpp then treats it as
# end-of-generation and suppresses its text.
tokens, toktypes, tokpre = super().get_vocab_base()
for i, tok in enumerate(tokens):
if tok == "</assistant>":
toktypes[i] = gguf.TokenType.CONTROL
logger.info(f"gguf: marked </assistant> (id {i}) as CONTROL token")
return tokens, toktypes, tokpre

# --- hparams -------------------------------------------------------------

def set_gguf_parameters(self) -> None:
super().set_gguf_parameters()
hparams = self.hparams

# super() does not emit vocab_size for the gpt2 vocab path; head_count is
# overridden with a per-layer array (XS.2 varies heads per layer via
# num_attention_heads_per_layer; M.1 is uniform and omits it).
self.gguf_writer.add_vocab_size(hparams["vocab_size"])

per_layer_heads = hparams.get("num_attention_heads_per_layer")
if not per_layer_heads:
per_layer_heads = [hparams["num_attention_heads"]] * hparams["num_hidden_layers"]
assert len(per_layer_heads) == hparams["num_hidden_layers"], (
f"num_attention_heads_per_layer length {len(per_layer_heads)} != "
f"num_hidden_layers {hparams['num_hidden_layers']}"
)
self.gguf_writer.add_head_count(per_layer_heads)

# Resolve + validate the attention gate type now so an inconsistent
# `gating` field fails at conversion time. See _attn_gate_types.
self._attn_gate_types()

# SWA window size (M.1 has none -> key omitted, swa_type stays NONE).
sliding_window = hparams.get("sliding_window") or 0
if sliding_window > 0:
self.gguf_writer.add_sliding_window(sliding_window)

# MoE (expert_count / expert_used_count come from super().set_gguf_parameters())
self.gguf_writer.add_expert_feed_forward_length(hparams["moe_intermediate_size"])
self.gguf_writer.add_expert_shared_feed_forward_length(hparams["shared_expert_intermediate_size"])
self.gguf_writer.add_expert_weights_norm(True) # HF reference always sum-normalises after top-k
self.gguf_writer.add_expert_weights_scale(float(hparams["moe_routed_scaling_factor"]))
self.gguf_writer.add_expert_gating_func(gguf.ExpertGatingFuncType.SIGMOID)

# Leading dense layers (XS.2 has 1, M.1 has 3) before the MoE layers.
mlp_layer_types: list[str] = hparams["mlp_layer_types"]
leading_dense = 0
for t in mlp_layer_types:
if t == "dense":
leading_dense += 1
else:
break
self.gguf_writer.add_leading_dense_block_count(leading_dense)

# Per-layer-type RoPE dimension count (partial rotary). base emits
# rope_freq_base(_swa) and the YaRN params from self.rope_parameters.
head_dim = hparams["head_dim"]
full_rope = self.rope_parameters["full_attention"]
self.gguf_writer.add_rope_dimension_count(
int(head_dim * float(full_rope.get("partial_rotary_factor", 1.0))))
swa_rope = self.rope_parameters.get("sliding_attention")
if swa_rope is not None:
self.gguf_writer.add_rope_dimension_count_swa(
int(head_dim * float(swa_rope.get("partial_rotary_factor", 1.0))))

def _attn_gate_types(self) -> list[str]:
"""Per-layer attention output gate type: "per_head" or "per_element".

`gating_types` (per layer) is authoritative when present; otherwise the
scalar `gating` field is used (the "per-element"/"per-head" string, or
the legacy boolean True == per-head, as in Laguna-XS.2).

Fails loudly when the model is per-element but the `gating` field does
not declare that as a string: runtimes that key off `gating` (vLLM,
transformers) ignore gating_types and read a bare boolean True as
per-head, silently corrupting the model. Surfacing it here keeps a
broken checkpoint from being packaged as if it were fine.
"""
if self._gate_types is not None:
return self._gate_types
hparams = self.hparams
n_layer = hparams["num_hidden_layers"]
gating = hparams.get("gating")
gating_types = hparams.get("gating_types")

def _norm(t: object) -> str:
sval = str(t).replace("-", "_")
if sval in ("per_element", "per_head"):
return sval
raise ValueError(f"Laguna: unrecognised attention gate type {t!r}")

if gating_types:
assert len(gating_types) == n_layer, (
f"gating_types length {len(gating_types)} != num_hidden_layers {n_layer}")
types = [_norm(t) for t in gating_types]
elif isinstance(gating, str):
types = [_norm(gating)] * n_layer
elif gating is True:
types = ["per_head"] * n_layer
else:
raise ValueError(
f"Laguna: cannot determine attention gate type "
f"(gating={gating!r}, gating_types={gating_types!r})")

if any(t == "per_element" for t in types) and not (
isinstance(gating, str) and _norm(gating) == "per_element"):
raise ValueError(
f"Laguna config declares a per-element attention gate but "
f"`gating`={gating!r} is not the string \"per-element\". Runtimes that "
f"read `gating` (vLLM, transformers) will mis-handle this checkpoint as "
f"per-head. Set gating=\"per-element\" in the source config.")

self._gate_types = types
return types

# --- tensor handling -----------------------------------------------------

def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
# Per-expert MoE weights: model.layers.{bid}.mlp.experts.{xid}.{w}.weight.
# Only the NUMBERED per-expert weights are stacked; the router bias
# (mlp.experts.e_score_correction_bias) takes the normal mapping path.
if re.search(r"mlp\.experts\.\d+\.", name):
n_experts = self.find_hparam(["num_local_experts", "num_experts"])
assert bid is not None
if self._experts is None:
self._experts = [{} for _ in range(self.block_count)]
self._experts[bid][name] = data_torch
needed = [f"model.layers.{bid}.mlp.experts.{x}.{w}.weight"
for x in range(n_experts) for w in ("gate_proj", "up_proj", "down_proj")]
if all(e in self._experts[bid] for e in needed):
for w_name in ["gate_proj", "up_proj", "down_proj"]:
datas = [self._experts[bid][f"model.layers.{bid}.mlp.experts.{x}.{w_name}.weight"]
for x in range(n_experts)]
stacked = torch.stack(datas, dim=0)
merged = f"model.layers.{bid}.mlp.experts.{w_name}.weight"
yield from TextModel.modify_tensors(self, stacked, merged, bid)
self._experts[bid].clear()
return
return
# Cross-check the gate projection width against the declared gate type;
# a mismatch means the weights and config disagree -> fail, do not guess.
if bid is not None and name.endswith("self_attn.g_proj.weight"):
heads = (self.hparams.get("num_attention_heads_per_layer")
or [self.hparams["num_attention_heads"]] * self.hparams["num_hidden_layers"])
n_head = heads[bid]
head_dim = self.hparams["head_dim"]
gate_type = self._attn_gate_types()[bid]
expected = n_head * head_dim if gate_type == "per_element" else n_head
out_features = int(data_torch.shape[0])
if out_features != expected:
raise ValueError(
f"Laguna layer {bid}: g_proj output width {out_features} contradicts the "
f"declared {gate_type} gate (expected {expected}); weights and config disagree.")

yield from TextModel.modify_tensors(self, data_torch, name, bid)
1 change: 1 addition & 0 deletions convert_hf_to_gguf_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ class TOKENIZER_TYPE(IntEnum):
{"name": "granite-embed-multi-97m", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/ibm-granite/granite-embedding-97m-multilingual-r2", },
{"name": "granite-embed-multi-311m", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/ibm-granite/granite-embedding-311m-multilingual-r2", },
{"name": "mellum2", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/JetBrains/Mellum2-12B-A2.5B-Base"},
{"name": "laguna", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/poolside/Laguna-XS.2", },
]

# some models are known to be broken upstream, so we will skip them as exceptions
Expand Down
27 changes: 27 additions & 0 deletions gguf-py/gguf/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ class MODEL_ARCH(IntEnum):
DOTS1 = auto()
ARCEE = auto()
AFMOE = auto()
LAGUNA = auto()
ERNIE4_5 = auto()
ERNIE4_5_MOE = auto()
HUNYUAN_MOE = auto()
Expand Down Expand Up @@ -1088,6 +1089,7 @@ class MODEL_TENSOR(IntEnum):
MODEL_ARCH.DOTS1: "dots1",
MODEL_ARCH.ARCEE: "arcee",
MODEL_ARCH.AFMOE: "afmoe",
MODEL_ARCH.LAGUNA: "laguna",
MODEL_ARCH.ERNIE4_5: "ernie4_5",
MODEL_ARCH.ERNIE4_5_MOE: "ernie4_5-moe",
MODEL_ARCH.FALCON_H1: "falcon-h1",
Expand Down Expand Up @@ -3823,6 +3825,31 @@ class MODEL_TENSOR(IntEnum):
MODEL_TENSOR.FFN_POST_NORM,
MODEL_TENSOR.FFN_EXP_PROBS_B,
],
MODEL_ARCH.LAGUNA: [
MODEL_TENSOR.TOKEN_EMBD,
MODEL_TENSOR.OUTPUT_NORM,
MODEL_TENSOR.OUTPUT,
MODEL_TENSOR.ATTN_NORM,
MODEL_TENSOR.ATTN_Q,
MODEL_TENSOR.ATTN_K,
MODEL_TENSOR.ATTN_V,
MODEL_TENSOR.ATTN_OUT,
MODEL_TENSOR.ATTN_Q_NORM,
MODEL_TENSOR.ATTN_K_NORM,
MODEL_TENSOR.ATTN_GATE,
MODEL_TENSOR.FFN_NORM,
MODEL_TENSOR.FFN_GATE,
MODEL_TENSOR.FFN_DOWN,
MODEL_TENSOR.FFN_UP,
MODEL_TENSOR.FFN_GATE_INP,
MODEL_TENSOR.FFN_EXP_PROBS_B,
MODEL_TENSOR.FFN_GATE_EXP,
MODEL_TENSOR.FFN_DOWN_EXP,
MODEL_TENSOR.FFN_UP_EXP,
MODEL_TENSOR.FFN_GATE_SHEXP,
MODEL_TENSOR.FFN_UP_SHEXP,
MODEL_TENSOR.FFN_DOWN_SHEXP,
],
MODEL_ARCH.ERNIE4_5: [
MODEL_TENSOR.TOKEN_EMBD,
MODEL_TENSOR.OUTPUT_NORM,
Expand Down
1 change: 1 addition & 0 deletions gguf-py/gguf/tensor_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ class TensorNameMap:
"model.layers.{bid}.mlp.e_score_correction", # exaone-moe
"model.layers.{bid}.block_sparse_moe.gate.e_score_correction", # kimi
"model.layers.{bid}.moe.router_bias", # step3.5 expert selection bias
"model.layers.{bid}.mlp.experts.e_score_correction", # laguna
),

# Feed-forward up
Expand Down
Loading
Loading