Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
283c765
Add backwards-compatible support for multiple EOS tokens
hudson-ai Mar 6, 2026
b261ae9
cargo fmt
hudson-ai Mar 6, 2026
985e344
Apply suggestions from code review
hudson-ai Mar 6, 2026
abed676
Add zero-initialization requirement comment to LlgTokenizerInit
hudson-ai Mar 6, 2026
963d764
Introduce LlgTokenizerInitV2 and llg_new_tokenizer_v2 for ABI stability
hudson-ai Mar 6, 2026
ca775a5
Add struct_size field to LlgTokenizerInitV2 for forward compatibility
hudson-ai Mar 6, 2026
db177e9
Flatten LlgTokenizerInitV2 fields instead of embedding LlgTokenizerInit
hudson-ai Mar 6, 2026
6e43159
Test both v1 and v2 C ABI in c_sample
hudson-ai Mar 6, 2026
bc534b3
cargo fmt
hudson-ai Mar 6, 2026
4387f51
Validate EOS token IDs and fix struct_size forward compatibility
hudson-ai Mar 6, 2026
d65de0d
Fix struct_size check and add EOS validation in FFI path
hudson-ai Mar 6, 2026
56526da
Make struct_size forward compatibility real via raw pointer
hudson-ai Mar 6, 2026
a7292ef
cargo fmt
hudson-ai Mar 6, 2026
988365a
Fix multi-EOS in stopped/error fallbacks and TokenizerWrapper path
hudson-ai Mar 6, 2026
b0ec8ef
Add Rust tests for multi-EOS stopped-state mask and simplify Python test
hudson-ai Mar 6, 2026
821f593
cargo fmt
hudson-ai Mar 6, 2026
229cf29
Refactor from_init_v2 to avoid double factory construction
hudson-ai Mar 6, 2026
1dc5bd6
Validate EOS token IDs in Python entry points
hudson-ai Mar 6, 2026
88caf8b
Validate single EOS token in from_tiktoken path too
hudson-ai Mar 6, 2026
acbb724
cargo fmt
hudson-ai Mar 6, 2026
197b628
Address remaining review comments
hudson-ai Mar 6, 2026
96bb5fb
Fix mypy errors in test_matcher.py
hudson-ai Mar 6, 2026
ec47b5d
Guard eos_token_set() against INVALID_TOKEN and out-of-range IDs
hudson-ai Mar 6, 2026
037a7b0
Use offset_of token_lens for min_size to match doc comment
hudson-ai Mar 6, 2026
911534d
Merge branch 'main' into multi_eos
hudson-ai Mar 10, 2026
afa0241
clean up python tests a little bit
hudson-ai Mar 10, 2026
c15f0d4
Use std::vector instead of new[]/delete[] in c_sample
hudson-ai Mar 11, 2026
3026e27
Take a single eos_tokens vector in create_tokenizer_v2
hudson-ai Mar 11, 2026
724856c
Remove stale commented-out v2 snippet from create_tokenizer
hudson-ai Mar 11, 2026
cdfbf2d
Pre-allocate token vector capacity in byte tokenizer constructors
hudson-ai Mar 17, 2026
6ee6b94
Use std::copy instead of memcpy for token byte packing
hudson-ai Mar 17, 2026
e941074
Replace remaining memcpy with std::copy in tokenize_callback
hudson-ai Mar 17, 2026
434d169
doctest format fixes
hudson-ai Mar 18, 2026
91e022d
Merge branch 'main' into multi_eos
hudson-ai Mar 18, 2026
ce51ebe
cbindgen
hudson-ai Mar 18, 2026
cc619d0
simplify from_init/from_init_v2 by delegating in a more sensible dire…
hudson-ai Mar 18, 2026
5cfc057
cargo fmt
hudson-ai Mar 18, 2026
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
4 changes: 4 additions & 0 deletions c_sample/c_sample.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ LlgTokenizer *create_tokenizer(std::vector<std::vector<uint8_t>> &tokens,
LlgTokenizerInit tok_init = {};
tok_init.vocab_size = (uint32_t)tokens.size();
tok_init.tok_eos = tok_eos;
// For models with multiple EOS tokens (e.g., Qwen3), set:
// LlgToken extra_eos[] = {second_eos, third_eos};
// tok_init.tok_eos_extra = extra_eos;
// tok_init.tok_eos_extra_count = sizeof(extra_eos) / sizeof(extra_eos[0]);
tok_init.token_lens = token_lens;
tok_init.token_bytes = token_bytes;
tok_init.tokenize_assumes_string = false;
Expand Down
10 changes: 10 additions & 0 deletions parser/llguidance.h
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ typedef struct LlgTokenizerInit {
* Pass NULL to use defaults. Pass empty array to disable.
*/
const char *const *slices;
/**
* Additional EOS token IDs beyond `tok_eos`.
* Points to an array of `tok_eos_extra_count` elements.
* When NULL (the default for zero-initialized structs), only `tok_eos` is used.
*/
const LlgToken *tok_eos_extra;
/**
* Number of elements in the `tok_eos_extra` array.
*/
uint32_t tok_eos_extra_count;
Comment thread
hudson-ai marked this conversation as resolved.
} LlgTokenizerInit;


Expand Down
19 changes: 18 additions & 1 deletion parser/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,16 @@ impl LlgTokenizer {
token_bytes
};

let trie = TokTrie::from(&TokRxInfo::new(tokens.len() as u32, init.tok_eos), &tokens);
let mut trie = TokTrie::from(&TokRxInfo::new(tokens.len() as u32, init.tok_eos), &tokens);

if !init.tok_eos_extra.is_null() && init.tok_eos_extra_count > 0 {
let extra = unsafe {
std::slice::from_raw_parts(init.tok_eos_extra, init.tok_eos_extra_count as usize)
};
let mut eos_tokens = vec![init.tok_eos];
eos_tokens.extend_from_slice(extra);
trie = trie.with_eos_tokens(&eos_tokens);
Comment thread
hudson-ai marked this conversation as resolved.
}

let tok_env: TokEnv = Arc::new(CTokenizerInner {
trie,
Expand Down Expand Up @@ -249,6 +258,14 @@ pub struct LlgTokenizerInit {
/// This is array of pointers to strings, terminated with NULL (argv style).
/// Pass NULL to use defaults. Pass empty array to disable.
pub slices: *const *const c_char,

/// Additional EOS token IDs beyond `tok_eos`.
/// Points to an array of `tok_eos_extra_count` elements.
/// When NULL (the default for zero-initialized structs), only `tok_eos` is used.
pub tok_eos_extra: *const LlgToken,

/// Number of elements in the `tok_eos_extra` array.
pub tok_eos_extra_count: u32,
}

#[derive(Clone)]
Expand Down
23 changes: 15 additions & 8 deletions parser/src/tokenparser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub struct TokenParser {
pub dbg_grammar: String,
last_step_stats: ParserStats,
max_step_stats: ParserStats,
eos_token: TokenId,
eos_tokens: Vec<TokenId>,

had_rollback: bool,
had_backtrack: bool,
Expand Down Expand Up @@ -91,7 +91,7 @@ impl TokenParser {
factory.perf_counters(),
)?;
parser.metrics_mut().rand = factory.next_rng();
let eos_token = token_env.tok_trie().eos_token();
let eos_tokens = token_env.tok_trie().eos_tokens().to_vec();

Ok(TokenParser {
bias_computer: factory.slicer().clone(),
Expand All @@ -108,7 +108,7 @@ impl TokenParser {
error_message: None,
parser,
dbg_grammar: String::new(),
eos_token,
eos_tokens,
llm_tokens: Vec::new(),
llm_bytes: Vec::new(),
grm_prefix: Vec::new(),
Expand Down Expand Up @@ -389,7 +389,7 @@ impl TokenParser {
let new_len = self.llm_tokens.len() - n_tokens;
let mut bytes_to_drop = 0;
for tok in &self.llm_tokens[new_len..] {
if *tok == self.eos_token {
if self.eos_tokens.contains(tok) {
// doesn't count; we hope it's last though...
bytes_to_drop += 0;
} else {
Expand Down Expand Up @@ -492,8 +492,12 @@ impl TokenParser {
return Err(self.stop_for_parser_error("", s));
}

if self.eos_token != INVALID_TOKEN && self.is_accepting() {
allowed_tokens.allow_token(self.eos_token);
if self.is_accepting() {
for &eos in &self.eos_tokens {
if eos != INVALID_TOKEN {
allowed_tokens.allow_token(eos);
}
}
}

self.log_final(&prefix, &allowed_tokens);
Expand Down Expand Up @@ -797,7 +801,7 @@ impl TokenParser {
}
self.max_tokens_total -= 1;

if token == self.eos_token {
if self.eos_tokens.contains(&token) {
if self.parser.scan_eos() {
// it got scanned correctly, so we remove it
// this only happens for gen() terminated by EOS
Expand Down Expand Up @@ -838,7 +842,10 @@ impl TokenParser {
/// This generally should be called after consume_token().
pub fn check_stop(&mut self) -> Result<bool> {
let empty_token_prefix = !self.has_ff_bytes();
let pending_eos = self.llm_tokens.last() == Some(&self.eos_token);
let pending_eos = self
.llm_tokens
.last()
.is_some_and(|t| self.eos_tokens.contains(t));
let lexer_bytes = self.parser.has_pending_lexeme_bytes();
let is_accepting = self.is_accepting();
let can_advance = self.parser.can_advance();
Expand Down
4 changes: 3 additions & 1 deletion python/llguidance/_lib.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ from ._tokenizer import TokenizerWrapper
class LLTokenizer:
vocab_size: int
eos_token: TokenId
eos_tokens: List[TokenId]
is_canonical: bool

def __new__(
cls,
tokenizer: Union[str, TokenizerWrapper],
n_vocab: Optional[int] = None,
eos_token: Optional[TokenId] = None,
eos_token: Optional[Union[TokenId, List[TokenId]]] = None,
slices: Optional[List[str]] = None,
) -> "LLTokenizer":
"""
Expand All @@ -23,6 +24,7 @@ class LLTokenizer:
Args:
tokenizer: str or TokenizerWrapper - if str, it is the name or path to the HF tokenizers tokenizer; otherwise it is a TokenizerWrapper
n_vocab: int - override the size of the vocabulary
eos_token: int or list of ints - override the EOS token(s)
slices: List[str] - configuration for slicer optimization; pass [] to disable,
or None to use general_slices()
"""
Expand Down
6 changes: 3 additions & 3 deletions python/llguidance/hf.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from copy import copy
from typing import List, Optional
from typing import List, Optional, Union

import transformers

Expand All @@ -9,7 +9,7 @@
def from_tokenizer(
hf_tokenizer: transformers.PreTrainedTokenizerFast,
n_vocab: Optional[int] = None,
eos_token: Optional[int] = None,
eos_token: Optional[Union[int, List[int]]] = None,
slices: Optional[List[str]] = None,
) -> LLTokenizer:
"""
Expand All @@ -21,7 +21,7 @@ def from_tokenizer(
Args:
hf_tokenizer: transformers.PreTrainedTokenizerFast - the tokenizer to wrap
n_vocab: int - override the size of the vocabulary
eos_token: int - override the EOS token
eos_token: int or list of ints - override the EOS token(s)
slices: List[str] - configuration for slicer optimization; pass [] to disable,
or None to use the default configuration
"""
Expand Down
5 changes: 3 additions & 2 deletions python/llguidance/llamacpp.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import List, Optional, Union

from ._lib import LLTokenizer

Expand All @@ -8,7 +8,7 @@
def lltokenizer_from_vocab(
vocab: llama_cpp.llama_vocab_p,
n_vocab: Optional[int] = None,
eos_token: Optional[int] = None,
eos_token: Optional[Union[int, List[int]]] = None,
slices: Optional[List[str]] = None,
) -> LLTokenizer:
"""
Expand All @@ -18,6 +18,7 @@ def lltokenizer_from_vocab(
Args:
vocab: llama_cpp.llama_vocab_p - the vocab object to use
n_vocab: int - override the size of the vocabulary
eos_token: int or list of ints - override the EOS token(s)
eos_token: int - override the EOS token
Comment thread
hudson-ai marked this conversation as resolved.
Outdated
slices: List[str] - configuration for slicer optimization; pass [] to disable,
or None to use the default configuration
Expand Down
6 changes: 3 additions & 3 deletions python/llguidance/tiktoken.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import List, Optional, TYPE_CHECKING
from typing import List, Optional, Union, TYPE_CHECKING

from ._lib import LLTokenizer

Expand All @@ -10,7 +10,7 @@ def lltokenizer_from_encoding(
encoding: 'tiktoken.Encoding',
*,
n_vocab: Optional[int] = None,
eos_token: Optional[int] = None,
eos_token: Optional[Union[int, List[int]]] = None,
slices: Optional[List[str]] = None,
) -> LLTokenizer:
"""
Expand All @@ -20,7 +20,7 @@ def lltokenizer_from_encoding(
Args:
encoding: tiktoken.Encoding - the encoding object to use
n_vocab: int - override the size of the vocabulary
eos_token: int - override the EOS token
eos_token: int or list of ints - override the EOS token(s)
slices: List[str] - configuration for slicer optimization; pass [] to disable,
or None to use the default configuration
"""
Expand Down
10 changes: 7 additions & 3 deletions python_ext/src/llamatokenizer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,17 @@ pub fn tokenv_from_llamacpp(
tokens: Vec<Vec<u8>>,
vocab_ptr: usize,
tokenize_fptr: usize,
eos_token: u32,
eos_tokens: &[u32],
) -> Result<TokEnv> {
ensure!(!eos_tokens.is_empty(), "eos_tokens must not be empty");
ensure!(vocab_ptr != 0, "vocab_ptr must be non-null");
ensure!(tokenize_fptr != 0, "tokenize_fptr must be non-null");

let info = TokRxInfo::new(tokens.len() as u32, eos_token);
let trie = TokTrie::from(&info, &tokens);
let info = TokRxInfo::new(tokens.len() as u32, eos_tokens[0]);
let mut trie = TokTrie::from(&info, &tokens);
if eos_tokens.len() > 1 {
trie = trie.with_eos_tokens(eos_tokens);
}
Comment thread
hudson-ai marked this conversation as resolved.
Comment thread
hudson-ai marked this conversation as resolved.

let mut llama_tok = LlamaTokenizer {
trie,
Expand Down
46 changes: 37 additions & 9 deletions python_ext/src/py.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ use toktrie_tiktoken::TikTokenBPE;

use crate::llamatokenizer::tokenv_from_llamacpp;

/// Extract eos_token from a Python value that may be int or list[int].
/// Returns None if the value is None, or Some(vec) if it's an int or list of ints.
Comment thread
hudson-ai marked this conversation as resolved.
Outdated
fn extract_eos_tokens(obj: &Bound<'_, PyAny>) -> PyResult<Vec<u32>> {
if let Ok(single) = obj.extract::<u32>() {
Ok(vec![single])
} else if let Ok(list) = obj.extract::<Vec<u32>>() {
if list.is_empty() {
return Err(PyValueError::new_err("eos_token list must not be empty"));
}
Ok(list)
} else {
Err(PyValueError::new_err(
"eos_token must be an int or a non-empty list of ints",
))
}
}

struct PyTokenizer {
tok_trie: Arc<toktrie::TokTrie>,
tokenizer_fun: Py<PyAny>,
Expand All @@ -36,9 +53,10 @@ impl LLTokenizer {
fn py_new(
tokenizer: Bound<'_, PyAny>,
n_vocab: Option<usize>,
eos_token: Option<u32>,
eos_token: Option<Bound<'_, PyAny>>,
slices: Option<Vec<String>>,
) -> PyResult<Self> {
let eos_tokens = eos_token.as_ref().map(extract_eos_tokens).transpose()?;
let tok_env: TokEnv = if let Ok(tokenizer_str) = tokenizer.extract::<String>() {
Comment thread
hudson-ai marked this conversation as resolved.
if tokenizer_str == "byte" {
ApproximateTokEnv::single_byte_env()
Expand All @@ -48,8 +66,8 @@ impl LLTokenizer {
} else {
ByteTokenizer::from_file(&tokenizer_str).map_err(val_error)?
};
if let Some(eos_token) = eos_token {
tok.set_eos_token(eos_token);
if let Some(ref eos_tokens) = eos_tokens {
Comment thread
hudson-ai marked this conversation as resolved.
tok.set_eos_tokens(eos_tokens);
}
Comment thread
hudson-ai marked this conversation as resolved.
tok.into_tok_env(n_vocab).map_err(val_error)?
}
Expand Down Expand Up @@ -77,18 +95,22 @@ impl LLTokenizer {
encoder: HashMap<Vec<u8>, u32>,
special_tokens: HashMap<String, u32>,
pattern: &str,
eos_token: u32,
eos_token: Bound<'_, PyAny>,
n_vocab: Option<usize>,
slices: Option<Vec<String>>,
) -> PyResult<Self> {
let bpe = TikTokenBPE::new(
let eos_tokens = extract_eos_tokens(&eos_token)?;
let mut bpe = TikTokenBPE::new(
encoder.into_iter().collect(),
special_tokens.into_iter().collect(),
pattern,
n_vocab,
eos_token,
eos_tokens[0],
)
.map_err(val_error)?;
Comment thread
hudson-ai marked this conversation as resolved.
if eos_tokens.len() > 1 {
bpe.set_eos_tokens(&eos_tokens);
}
Comment thread
hudson-ai marked this conversation as resolved.
let tok_env = bpe.to_env();

let factory = ParserFactory::new(
Expand All @@ -108,11 +130,12 @@ impl LLTokenizer {
tokens: Vec<Vec<u8>>,
vocab_ptr: usize,
tokenize_fptr: usize,
eos_token: u32,
eos_token: Bound<'_, PyAny>,
slices: Option<Vec<String>>,
) -> PyResult<Self> {
let tok_env =
tokenv_from_llamacpp(tokens, vocab_ptr, tokenize_fptr, eos_token).map_err(val_error)?;
let eos_tokens = extract_eos_tokens(&eos_token)?;
let tok_env = tokenv_from_llamacpp(tokens, vocab_ptr, tokenize_fptr, &eos_tokens)
.map_err(val_error)?;

let factory = ParserFactory::new(
&tok_env,
Expand Down Expand Up @@ -244,6 +267,11 @@ impl LLTokenizer {
fn eos_token(&self) -> u32 {
self.tok_trie().eos_token()
}

#[getter]
fn eos_tokens(&self) -> Vec<u32> {
self.tok_trie().eos_tokens().to_vec()
}
}

impl LLTokenizer {
Expand Down
Loading