Skip to content

Commit 205a037

Browse files
codelionclaude
andauthored
Honor model eos_token_id and bound local generation, bump to 0.3.20 (#317)
Two fixes for runaway local generation with models whose ChatML end token differs from their tokenizer EOS (e.g. dhara-250m: chat ends at <|im_end|>=49154 but tokenizer eos is <|end_of_text|>=1). optillm forced eos to the tokenizer's id and defaulted max_new_tokens to 4096, so such a model never stopped and generated 4096 tokens (~800s at ~5 tok/s) on every call that omitted max_tokens. 1. Resolve EOS from the model's generation_config.eos_token_id (merging the tokenizer eos as a fallback) instead of hardcoding tokenizer.eos_token_id. Applied to both PyTorch generate paths. 2. Make the default max_new_tokens env-configurable via OPTILLM_MAX_TOKENS (default 4096), covering the config builders and the InferenceClient.create() request paths. An explicit request max_tokens still wins. Set OPTILLM_MAX_TOKENS=128 in the CI jobs that run the small test model. Adds unit tests (no model load) for both helpers. Verified end to end: an unbounded request with OPTILLM_MAX_TOKENS=64 stops at 64 tokens instead of 4096. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a0200c commit 205a037

5 files changed

Lines changed: 133 additions & 12 deletions

File tree

‎.github/workflows/test.yml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,14 @@ jobs:
106106
# Wait for server to be ready
107107
echo "Waiting for server to start..."
108108
sleep 15
109-
109+
110110
# Test server health
111111
curl -s http://localhost:8000/health || echo "Server health check failed"
112112
env:
113113
OPTILLM_API_KEY: optillm
114+
# Bound generation for the small test model, which does not reliably emit
115+
# an EOS token and would otherwise ramble up to the 4096 default per call.
116+
OPTILLM_MAX_TOKENS: "128"
114117
HF_TOKEN: ${{ secrets.HF_TOKEN }}
115118

116119
- name: Run integration tests (server required)
@@ -217,6 +220,9 @@ jobs:
217220
curl -s http://localhost:8000/health || echo "Server health check failed"
218221
env:
219222
OPTILLM_API_KEY: optillm
223+
# Bound generation for the small test model, which does not reliably emit
224+
# an EOS token and would otherwise ramble up to the 4096 default per call.
225+
OPTILLM_MAX_TOKENS: "128"
220226
HF_TOKEN: ${{ secrets.HF_TOKEN }}
221227

222228
- name: Run conversation logging tests

‎optillm/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Version information
2-
__version__ = "0.3.19"
2+
__version__ = "0.3.20"
33

44
import os as _os
55

‎optillm/inference.py‎

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import numpy as np
55
from typing import Dict, List, Optional, Tuple, Any, Union
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, field
77
from collections import OrderedDict, defaultdict
88
import torch.nn.functional as F
99
import torch.nn as nn
@@ -89,6 +89,27 @@ def count_reasoning_tokens(text: str, tokenizer=None) -> int:
8989
MLX_AVAILABLE = False
9090
logger.debug("MLX framework not available - falling back to PyTorch")
9191

92+
93+
# Hard ceiling of 4096 by default. Can be lowered via OPTILLM_MAX_TOKENS so a
94+
# single local generation is bounded even when the request (or an approach's
95+
# internal calls) sends no max_tokens -- important for small local models that
96+
# do not reliably emit an EOS token (e.g. the dhara test model), which would
97+
# otherwise ramble up to the full default on every call.
98+
DEFAULT_MAX_NEW_TOKENS = 4096
99+
100+
101+
def _default_max_new_tokens() -> int:
102+
"""Default ``max_new_tokens`` for local generation (env-overridable)."""
103+
raw = os.environ.get("OPTILLM_MAX_TOKENS")
104+
if raw is None:
105+
return DEFAULT_MAX_NEW_TOKENS
106+
try:
107+
return max(1, int(raw))
108+
except (TypeError, ValueError):
109+
logger.warning("Ignoring invalid OPTILLM_MAX_TOKENS=%r; using %d", raw, DEFAULT_MAX_NEW_TOKENS)
110+
return DEFAULT_MAX_NEW_TOKENS
111+
112+
92113
@dataclass
93114
class ModelConfig:
94115
base_model_id: str
@@ -98,7 +119,7 @@ class ModelConfig:
98119
quantization_bits: int = 4
99120
device_preference: Optional[str] = None
100121
# Default generation parameters
101-
max_new_tokens: int = 4096
122+
max_new_tokens: int = field(default_factory=_default_max_new_tokens)
102123
do_sample: bool = True
103124
top_p: float = 0.9
104125
top_k: int = 50
@@ -292,7 +313,7 @@ def suggest_mlx_alternative(model_id: str) -> str:
292313
class MLXModelConfig:
293314
"""Configuration for MLX models"""
294315
model_id: str
295-
max_new_tokens: int = 4096
316+
max_new_tokens: int = field(default_factory=_default_max_new_tokens)
296317
temperature: float = 0.7
297318
top_p: float = 0.9
298319
repetition_penalty: float = 1.0
@@ -1268,16 +1289,46 @@ def setup_tokenizer(self, tokenizer: AutoTokenizer) -> AutoTokenizer:
12681289

12691290
return tokenizer
12701291

1292+
def _resolve_eos_token_ids(self):
1293+
"""Resolve the effective end-of-sequence token id(s) for generation.
1294+
1295+
Prefer the model's own ``generation_config.eos_token_id``. Chat models
1296+
commonly set it to the chat-turn end token (e.g. ``<|im_end|>``), which
1297+
can differ from the tokenizer's ``eos_token_id`` (often the base-model
1298+
``<|end_of_text|>``). Passing only the tokenizer eos to ``generate`` there
1299+
means the model never stops on its real turn-end token and rambles up to
1300+
``max_new_tokens`` -- e.g. dhara-250m's ChatML ends at ``<|im_end|>`` but
1301+
its tokenizer eos is ``<|end_of_text|>``.
1302+
1303+
The tokenizer eos is merged in as a fallback so a model that only emits
1304+
the base eos still terminates. Returns an int, a list of ints, or None.
1305+
"""
1306+
ids: List[int] = []
1307+
gen_cfg = getattr(self.current_model, "generation_config", None)
1308+
gc_eos = getattr(gen_cfg, "eos_token_id", None) if gen_cfg is not None else None
1309+
if isinstance(gc_eos, int):
1310+
ids.append(gc_eos)
1311+
elif isinstance(gc_eos, (list, tuple)):
1312+
ids.extend(int(x) for x in gc_eos if isinstance(x, int))
1313+
tok_eos = self.tokenizer.eos_token_id
1314+
if isinstance(tok_eos, int):
1315+
ids.append(tok_eos)
1316+
seen = set()
1317+
resolved = [x for x in ids if not (x in seen or seen.add(x))]
1318+
if not resolved:
1319+
return None
1320+
return resolved[0] if len(resolved) == 1 else resolved
1321+
12711322
def get_optimized_generation_config(self, generation_params: Optional[Dict[str, Any]] = None) -> Dict:
12721323
"""Get optimized generation config"""
12731324
config = {
1274-
"max_new_tokens": generation_params.get("max_new_tokens", 4096),
1325+
"max_new_tokens": generation_params.get("max_new_tokens", _default_max_new_tokens()),
12751326
"do_sample": generation_params.get("temperature", 1.0) > 0,
12761327
"temperature": generation_params.get("temperature", 1.0),
12771328
"top_p": generation_params.get("top_p", 0.95),
12781329
"num_return_sequences": generation_params.get("num_return_sequences", 1),
12791330
"pad_token_id": self.tokenizer.pad_token_id,
1280-
"eos_token_id": self.tokenizer.eos_token_id,
1331+
"eos_token_id": self._resolve_eos_token_ids(),
12811332
"return_dict_in_generate": True,
12821333
"output_scores": generation_params.get("logprobs", False),
12831334
"use_cache": True
@@ -1571,13 +1622,13 @@ def process_batch(
15711622
if batch_prompts: # If there are any uncached prompts
15721623
# Configure generation parameters
15731624
base_params = {
1574-
"max_new_tokens": generation_params.get("max_new_tokens", 4096) if generation_params else self.model_config.max_new_tokens,
1625+
"max_new_tokens": generation_params.get("max_new_tokens", _default_max_new_tokens()) if generation_params else self.model_config.max_new_tokens,
15751626
"do_sample": generation_params.get("temperature", 1.0) > 0 if generation_params else self.model_config.do_sample,
15761627
"temperature": generation_params.get("temperature", 1.0) if generation_params else self.model_config.temperature,
15771628
"top_p": generation_params.get("top_p", 1.0) if generation_params else self.model_config.top_p,
15781629
"num_return_sequences": n,
15791630
"pad_token_id": self.tokenizer.pad_token_id,
1580-
"eos_token_id": self.tokenizer.eos_token_id,
1631+
"eos_token_id": self._resolve_eos_token_ids(),
15811632
}
15821633

15831634
# Add optional parameters if specified
@@ -1900,7 +1951,7 @@ def create(
19001951

19011952
# Use directly available parameters for entropy decoding
19021953
entropy_params = {
1903-
"max_new_tokens": max_tokens if max_tokens is not None else 4096,
1954+
"max_new_tokens": max_tokens if max_tokens is not None else _default_max_new_tokens(),
19041955
"temperature": temperature,
19051956
"top_p": top_p,
19061957
"top_k": top_k,
@@ -2046,7 +2097,7 @@ def create(
20462097
"temperature": temperature,
20472098
"top_p": top_p,
20482099
"num_return_sequences": n,
2049-
"max_new_tokens": max_tokens if max_tokens is not None else 4096,
2100+
"max_new_tokens": max_tokens if max_tokens is not None else _default_max_new_tokens(),
20502101
"presence_penalty": presence_penalty,
20512102
"frequency_penalty": frequency_penalty,
20522103
"stop_sequences": [stop] if isinstance(stop, str) else stop,

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "optillm"
7-
version = "0.3.19"
7+
version = "0.3.20"
88
description = "An optimizing inference proxy for LLMs."
99
readme = "README.md"
1010
license = "Apache-2.0"

‎tests/test_batching.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,70 @@ def run_performance_comparison():
421421
}
422422

423423

424+
class TestGenerationConfigDefaults(unittest.TestCase):
425+
"""Unit tests for env-configurable max_new_tokens and EOS resolution.
426+
427+
These exercise the guards that keep a small local model from rambling up to
428+
the 4096-token default when it does not reliably emit an EOS token (e.g. a
429+
ChatML model whose tokenizer EOS differs from the chat-turn end token). No
430+
model is loaded.
431+
"""
432+
433+
def tearDown(self):
434+
os.environ.pop("OPTILLM_MAX_TOKENS", None)
435+
436+
def test_default_max_new_tokens_env_override(self):
437+
from optillm.inference import _default_max_new_tokens, DEFAULT_MAX_NEW_TOKENS
438+
439+
os.environ.pop("OPTILLM_MAX_TOKENS", None)
440+
self.assertEqual(_default_max_new_tokens(), DEFAULT_MAX_NEW_TOKENS)
441+
442+
os.environ["OPTILLM_MAX_TOKENS"] = "128"
443+
self.assertEqual(_default_max_new_tokens(), 128)
444+
445+
# Invalid value falls back to the default rather than raising.
446+
os.environ["OPTILLM_MAX_TOKENS"] = "not-a-number"
447+
self.assertEqual(_default_max_new_tokens(), DEFAULT_MAX_NEW_TOKENS)
448+
449+
# Non-positive is clamped to a usable minimum.
450+
os.environ["OPTILLM_MAX_TOKENS"] = "0"
451+
self.assertEqual(_default_max_new_tokens(), 1)
452+
453+
def test_resolve_eos_prefers_generation_config(self):
454+
from types import SimpleNamespace
455+
from optillm.inference import InferencePipeline
456+
457+
# tokenizer EOS (<|end_of_text|>=1) differs from the chat end token
458+
# (<|im_end|>=49154); both must be honoured, generation_config first.
459+
fake = SimpleNamespace(
460+
current_model=SimpleNamespace(generation_config=SimpleNamespace(eos_token_id=49154)),
461+
tokenizer=SimpleNamespace(eos_token_id=1),
462+
)
463+
eos = InferencePipeline._resolve_eos_token_ids(fake)
464+
self.assertEqual(eos, [49154, 1])
465+
466+
def test_resolve_eos_dedupes_list(self):
467+
from types import SimpleNamespace
468+
from optillm.inference import InferencePipeline
469+
470+
fake = SimpleNamespace(
471+
current_model=SimpleNamespace(generation_config=SimpleNamespace(eos_token_id=[100, 200])),
472+
tokenizer=SimpleNamespace(eos_token_id=200),
473+
)
474+
self.assertEqual(InferencePipeline._resolve_eos_token_ids(fake), [100, 200])
475+
476+
def test_resolve_eos_falls_back_to_tokenizer(self):
477+
from types import SimpleNamespace
478+
from optillm.inference import InferencePipeline
479+
480+
fake = SimpleNamespace(
481+
current_model=SimpleNamespace(generation_config=SimpleNamespace(eos_token_id=None)),
482+
tokenizer=SimpleNamespace(eos_token_id=7),
483+
)
484+
# A single id is returned as a plain int, not a list.
485+
self.assertEqual(InferencePipeline._resolve_eos_token_ids(fake), 7)
486+
487+
424488
if __name__ == "__main__":
425489
# Run tests
426490
unittest.main(verbosity=2)

0 commit comments

Comments
 (0)