Skip to content

Commit 93a43d1

Browse files
codelionclaude
andcommitted
Honor model eos_token_id and bound local generation, bump to 0.3.20
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 and documents OPTILLM_MAX_TOKENS. Verified end to end: an unbounded request with OPTILLM_MAX_TOKENS=64 now stops at 64 tokens instead of running to 4096. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6a0200c commit 93a43d1

6 files changed

Lines changed: 135 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

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,8 @@ We support loading any HuggingFace model or LoRA directly in optillm. To use the
324324
and then use the same in your OpenAI client. You can pass any HuggingFace model in model field. If it is a private model make sure you set the `HF_TOKEN` environment variable
325325
with your HuggingFace key. We also support adding any number of LoRAs on top of the model by using the `+` separator.
326326

327+
By default a single local generation is capped at 4096 new tokens. Set `OPTILLM_MAX_TOKENS` to lower this default (e.g. `export OPTILLM_MAX_TOKENS=512`), which bounds any request that does not send `max_tokens` — useful for small models that do not reliably emit an EOS token. An explicit `max_tokens` in the request still takes precedence.
328+
327329
E.g. The following code loads the base model `meta-llama/Llama-3.2-1B-Instruct` and then adds two LoRAs on top - `patched-codes/Llama-3.2-1B-FixVulns` and `patched-codes/Llama-3.2-1B-FastApply`.
328330
You can specify which LoRA to use using the `active_adapter` param in `extra_body` field of OpenAI SDK client. By default we will load the last specified adapter.
329331

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)