feat(sglang): map reasoning_effort to per-level thinking budgets

The Go side already forwards the request's effort as
Metadata["reasoning_effort"], but the sglang backend only read
enable_thinking: every level got the same model-level thinking_budget,
so low/medium/high produced identical reasoning.

- New model option `effort_budgets:low=…;medium=…;high=…` picks the
  budget of the request's level; unlisted levels and requests without an
  effort keep `thinking_budget`. Parsing reuses _parse_thinking_budget.
- A level listed in effort_budgets turns reasoning on for that request,
  so levels the Go side does not know cannot silently disable it.
- ApplyReasoningEffort now treats `xhigh` and `max` as thinking levels.
  Before, they left DisableReasoning unset, so with a model default of
  "off" asking for more reasoning produced none.

Signed-off-by: pos-ei-don <1822533+pos-ei-don@users.noreply.github.com>
(cherry picked from commit 879c686ab1)
This commit is contained in:
pos-ei-don authored and Ettore Di Giacinto committed 2026-10-25 08:50:08 +00:00
1 parent 5e63cc1ec1
commit 073adc50c9
6 files changed
+149 -6

No files matched your search

+62 -3
View File
@@ -122,6 +122,7 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
# tests that construct it directly) doesn't AttributeError in
# _build_sampling_params.
thinking_budget: Optional[int] = None
effort_budgets: Optional[dict] = None # {effort level: tokens}, from `effort_budgets`
reasoning_default: Optional[str] = None
def _parse_options(self, options_list) -> Dict[str, str]:
@@ -165,6 +166,44 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
return None
return int(number)
@classmethod
def _parse_effort_budgets(cls, value) -> dict:
"""Parse `effort_budgets` ("low=512;medium=2048;high=8192") into {level: tokens}.
`;` separates entries because the YAML `options:` list already uses `:`
between option name and value. Invalid entries are skipped with a warning
(same policy as `thinking_budget`: never fail LoadModel over an option).
"""
budgets: dict = {}
if value is None or str(value).strip() == "":
return budgets
for entry in str(value).split(";"):
entry = entry.strip()
if not entry:
continue
level, sep, raw = entry.partition("=")
level = level.strip().lower()
if not sep or not level:
print(f"effort_budgets entry {entry!r} is not level=tokens, ignoring it", file=sys.stderr)
continue
tokens = cls._parse_thinking_budget(raw)
if tokens is not None:
budgets[level] = tokens
return budgets
@staticmethod
def _effort(request) -> str:
"""The request's reasoning_effort as forwarded by the Go side, lower-cased."""
metadata = getattr(request, "Metadata", None) or {}
return (metadata.get("reasoning_effort") or "").strip().lower()
def _thinking_budget_for(self, request) -> Optional[int]:
"""Budget for this request: its effort level's entry, else the model default."""
effort = self._effort(request)
if effort and self.effort_budgets and effort in self.effort_budgets:
return self.effort_budgets[effort]
return self.thinking_budget
@staticmethod
def _strict_thinking_warning(thinking_budget: Optional[int], engine_kwargs: dict) -> Optional[str]:
"""sglang only enforces the budget with enable_strict_thinking on; without
@@ -304,6 +343,16 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
opts.get("thinking_budget")
)
# Per-effort budgets, e.g. `effort_budgets:low=512;medium=2048;high=8192`.
# The Go side already forwards the request's `reasoning_effort` as
# Metadata["reasoning_effort"], but until now nothing in this backend
# read it: every effort level got the same fixed `thinking_budget`, so
# low/medium/high produced identical reasoning. With this map a request
# gets the budget of its effort level; levels absent from the map, and
# requests without an effort, keep `thinking_budget`. Same semantics as
# vLLM's proposed `--reasoning-effort-budgets` (vllm-project/vllm#55432).
self.effort_budgets: dict = self._parse_effort_budgets(opts.get("effort_budgets"))
# Model-level default for whether the chat template opens a reasoning
# block, as "off" or "on". Rides the same `options:` mechanism as
# thinking_budget above.
@@ -338,7 +387,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
print(f"engine_args error: {err}", file=sys.stderr)
return backend_pb2.Result(success=False, message=str(err))
warning = self._strict_thinking_warning(self.thinking_budget, engine_kwargs)
warning = self._strict_thinking_warning(
self.thinking_budget or max((self.effort_budgets or {}).values(), default=None), engine_kwargs
)
if warning:
print(warning, file=sys.stderr)
@@ -465,8 +516,9 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
except json.JSONDecodeError:
sampling_params["ebnf"] = grammar
if self.thinking_budget is not None:
sampling_params["custom_params"] = {"thinking_budget": self.thinking_budget}
budget = self._thinking_budget_for(request)
if budget is not None:
sampling_params["custom_params"] = {"thinking_budget": budget}
return sampling_params
@@ -481,6 +533,13 @@ class BackendServicer(backend_pb2_grpc.BackendServicer):
wanted = request.Metadata.get("enable_thinking", "").lower()
if wanted in ("true", "false"):
return wanted == "true"
# An effort level listed in `effort_budgets` is an explicit request to
# think. The Go side only sets enable_thinking for the levels it knows
# (minimal/low/medium/high); for others such as `xhigh` or `max` it sets
# nothing, so `reasoning_default: off` would win and asking for MORE
# reasoning would yield NONE.
if self.effort_budgets and self._effort(request) in self.effort_budgets:
return True
if self.reasoning_default == "off":
return False
if self.reasoning_default == "on":
+61
View File
@@ -340,6 +340,67 @@ class TestSglangHelpers(unittest.TestCase):
params = servicer._build_sampling_params(request)
self.assertEqual(params["max_new_tokens"], 4000)
def _sampling_request(self, metadata=None):
from types import SimpleNamespace
return SimpleNamespace(
Temperature=0.7, N=0, PresencePenalty=0, FrequencyPenalty=0,
RepetitionPenalty=0, TopP=0, TopK=0, MinP=0, Seed=0,
StopPrompts=[], StopTokenIds=[], IgnoreEOS=False, Tokens=0,
MinTokens=0, SkipSpecialTokens=False, Grammar="",
Metadata=metadata or {},
)
def test_effort_budgets_parse(self):
"""`low=512;medium=2048;high=8192` -> per-level dict; bad entries are skipped."""
servicer = self._servicer()
self.assertEqual(
servicer._parse_effort_budgets("low=512; Medium=2048.0;high=8192"),
{"low": 512, "medium": 2048, "high": 8192},
)
self.assertEqual(servicer._parse_effort_budgets("low=abc;medium;=5;high=0;xhigh=100"), {"xhigh": 100})
self.assertEqual(servicer._parse_effort_budgets(None), {})
self.assertEqual(servicer._parse_effort_budgets(""), {})
def test_effort_level_selects_its_budget(self):
"""The request's reasoning_effort (Metadata, forwarded by the Go side)
picks its own budget, so low/medium/high stop being identical."""
servicer = self._servicer()
servicer.thinking_budget = 256
servicer.effort_budgets = {"low": 512, "medium": 2048, "high": 8192}
for level, budget in (("low", 512), ("medium", 2048), ("HIGH", 8192)):
params = servicer._build_sampling_params(self._sampling_request({"reasoning_effort": level}))
self.assertEqual(params["custom_params"], {"thinking_budget": budget}, level)
def test_listed_effort_turns_thinking_on_despite_default_off(self):
"""`xhigh`/`max` get no enable_thinking from the Go side; if the level is
in effort_budgets it must still think instead of falling to default off."""
servicer = self._servicer()
servicer.reasoning_default = "off"
servicer.effort_budgets = {"xhigh": 16384}
self.assertTrue(servicer._thinking_default(_request(metadata={"reasoning_effort": "xhigh"})))
self.assertFalse(servicer._thinking_default(_request(metadata={"reasoning_effort": "ultra"})))
self.assertFalse(servicer._thinking_default(
_request(metadata={"reasoning_effort": "xhigh", "enable_thinking": "false"})))
def test_effort_without_entry_falls_back_to_thinking_budget(self):
servicer = self._servicer()
servicer.thinking_budget = 256
servicer.effort_budgets = {"high": 8192}
for metadata in ({}, {"reasoning_effort": "low"}, {"reasoning_effort": ""}):
params = servicer._build_sampling_params(self._sampling_request(metadata))
self.assertEqual(params["custom_params"], {"thinking_budget": 256}, metadata)
def test_effort_budgets_alone_without_default(self):
"""Only levels in the map get a budget; others stay unconfigured (no key)."""
servicer = self._servicer()
servicer.effort_budgets = {"low": 512}
self.assertEqual(
servicer._build_sampling_params(self._sampling_request({"reasoning_effort": "low"}))["custom_params"],
{"thinking_budget": 512},
)
self.assertNotIn("custom_params", servicer._build_sampling_params(self._sampling_request({"reasoning_effort": "high"})))
def test_thinking_budget_accepts_integral_spellings(self):
"""YAML options arrive as strings; "512" and "512.0" both mean 512."""
servicer = self._servicer()