serve: repeat_penalty / repeat_last_n (llama.cpp's names) for the config's sampling block and for requests (#1819)

This commit is contained in:
Niko1221 committed 2026-10-10 10:45:03 +02:00
1 parent 2ea10c2e9a
commit 70ac6dd41f
3 files changed
+48 -3

No files matched your search

+1 -1
View File
@@ -1198,7 +1198,7 @@ engine arguments. The run config's optional `sampling` block sets the defaults f
(`"sampling": {"temperature": 1.0, "top_p": 0.95, "top_k": 20}`); a request's own fields always win, and with no
block at all a request without sampling keys decodes greedy. The penalties (`presence_penalty`, `frequency_penalty`,
`repetition_penalty`, with `penalty_last_n` capping how many recent tokens they count over, default 64 when any
penalty is set) ride the same path; they count the tokens the request has consumed, so a repetition penalty
penalty is set; llama.cpp's names `repeat_penalty` and `repeat_last_n` work too, in the config's `sampling` block and in a request, #1819) ride the same path; they count the tokens the request has consumed, so a repetition penalty
suppresses what the model itself just said, not the prompt alone. Since engine 0.1.19 they apply to every token
the speculative decoding checks at once, exactly as if it decoded one token at a time (before, only the first of
each batch got them). That makes requests with penalties 1-11% slower than in 0.1.18: the draft layer guesses
+21 -2
View File
@@ -5860,15 +5860,28 @@ def lift_reasoning_effort_arg(cfg: dict) -> None:
f"{cfg['reasoning_effort']!r}", flush=True)
# #1819: llama.cpp clients call the repetition penalty and its window repeat_penalty / repeat_last_n; they are the
# same settings as repetition_penalty / penalty_last_n (the /props answer already reports them under those names)
SAMPLING_ALIASES = {"repeat_penalty": "repetition_penalty", "repeat_last_n": "penalty_last_n"}
def sampling_defaults_from_config(cfg: dict) -> dict:
"""The run config's optional `sampling` block: defaults for the sampling fields a request leaves out, so
a plain client gets configured sampling instead of greedy. Supported: temperature, top_p, top_k, min_p,
presence_penalty, repetition_penalty, frequency_penalty, penalty_last_n, seed. The request's own fields
presence_penalty, repetition_penalty (also written repeat_penalty), frequency_penalty, penalty_last_n (also
repeat_last_n), seed. The request's own fields
always win - an explicit temperature=0 still means greedy, a field set to null falls back to the default.
A bad value refuses to start the server (a typo'd config should not quietly change sampling); unknown keys
are named at startup and ignored."""
out = {}
for key, value in (cfg.get("sampling") or {}).items():
block = dict(cfg.get("sampling") or {})
for alias, canon in SAMPLING_ALIASES.items(): # #1819: llama.cpp's names for the same two fields
if block.get(alias) is not None:
if block.get(canon) is not None:
raise SystemExit(f"[strata] config sampling: both {alias!r} and {canon!r} are set; they are the same "
f"setting, keep one")
block[canon] = block.pop(alias)
for key, value in block.items():
if value is None:
continue
number = isinstance(value, (int, float)) and not isinstance(value, bool)
@@ -5968,6 +5981,12 @@ def check_request_sampling(req) -> None:
types are checked: a number (int or float, never bool) for temperature, top_p, min_p and the three penalties; an
integer (never bool or float) for top_k, penalty_last_n and seed. Ranges behave as before (a temperature <= 0 is
greedy, top_k 0 or above 64 is clamped, a seed <= 0 is unset), and an absent or null field is not checked."""
if isinstance(req, dict): # #1819: llama.cpp's names; the OpenAI-style field wins when both are sent
if req.get("repeat_penalty") is not None and req.get("repetition_penalty") is None:
req["repetition_penalty"] = req["repeat_penalty"]
rl = req.get("repeat_last_n")
if req.get("penalty_last_n") is None and isinstance(rl, int) and not isinstance(rl, bool) and rl > 0:
req["penalty_last_n"] = rl # 0 and -1 (off / the whole context) keep the engine's default window
for key, integer in (("temperature", False), ("top_p", False), ("min_p", False), ("presence_penalty", False),
("frequency_penalty", False), ("repetition_penalty", False), ("top_k", True),
("penalty_last_n", True), ("seed", True)):
+26
View File
@@ -130,6 +130,32 @@ class Hardening(unittest.TestCase):
"max_tokens": 8, "temperature": "0.7", "top_k": "40"})
self.assertEqual(code, 200, out)
def test_llama_cpp_names_for_the_repetition_penalty(self):
"""#1819: repeat_penalty / repeat_last_n are repetition_penalty / penalty_last_n; the OpenAI-style name wins."""
from serve.server import SAMPLING_ALIASES, check_request_sampling, sampling_defaults_from_config
req = {"repeat_penalty": 1.1, "repeat_last_n": 128}
check_request_sampling(req)
self.assertEqual((req["repetition_penalty"], req["penalty_last_n"]), (1.1, 128))
req = {"repeat_penalty": 1.3, "repetition_penalty": 1.05, "repeat_last_n": 9, "penalty_last_n": 32}
check_request_sampling(req)
self.assertEqual((req["repetition_penalty"], req["penalty_last_n"]), (1.05, 32))
req = {"repeat_last_n": 0} # off / -1: the engine's default window, nothing is set
check_request_sampling(req)
self.assertNotIn("penalty_last_n", req)
with self.assertRaises(ValueError):
check_request_sampling({"repeat_penalty": "abc"})
self.assertEqual(sampling_defaults_from_config({"sampling": {"repeat_penalty": 1.1, "repeat_last_n": 256}}),
{"repetition_penalty": 1.1, "penalty_last_n": 256})
self.assertEqual(sampling_defaults_from_config({"sampling": {"temperature": 0.6}}), {"temperature": 0.6})
with self.assertRaises(SystemExit):
sampling_defaults_from_config({"sampling": {"repeat_penalty": 1.1, "repetition_penalty": 1.2}})
with self.assertRaises(SystemExit):
sampling_defaults_from_config({"sampling": {"repeat_penalty": -1}})
self.assertEqual(set(SAMPLING_ALIASES.values()), {"repetition_penalty", "penalty_last_n"})
code, out = self.post("/v1/chat/completions", {"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 8, "repeat_penalty": 1.1, "repeat_last_n": 64})
self.assertEqual(code, 200, out)
def test_in_type_values_and_nulls_still_answer(self):
chat = {"messages": [{"role": "user", "content": "hi"}], "max_tokens": 8, "temperature": 0, "top_k": 0,
"seed": 7}