Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
73 changes: 69 additions & 4 deletions backend/python/sglang/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -165,6 +166,51 @@ def _parse_thinking_budget(value) -> Optional[int]:
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
if not raw.strip():
print(f"effort_budgets entry {entry!r} has no token count, 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

def _configured_budget(self) -> Optional[int]:
"""Any budget configured for this model (fixed or per effort), for the load-time warning."""
return self.thinking_budget or max((self.effort_budgets or {}).values(), default=None)

@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
Expand Down Expand Up @@ -304,6 +350,15 @@ async def LoadModel(self, request, context):
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`.
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.
Expand Down Expand Up @@ -338,7 +393,7 @@ async def LoadModel(self, request, context):
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._configured_budget(), engine_kwargs)
if warning:
print(warning, file=sys.stderr)

Expand Down Expand Up @@ -457,22 +512,32 @@ def _build_sampling_params(self, request) -> dict:
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

def _thinking_default(self, request) -> Optional[bool]:
"""Whether this request should render with reasoning on, off, or unset.

Per-request ``Metadata["enable_thinking"]`` wins; the model-level
Per-request ``Metadata["enable_thinking"]`` wins; next, an effort level
listed in ``effort_budgets`` turns reasoning on; the model-level
``reasoning_default`` option fills in when the request is silent.
Returns None when neither says anything, leaving template behaviour
untouched.
"""
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. Normally the Go side already sends enable_thinking for known
# levels; this covers levels it does not set it for (custom names in
# the map, or callers that only send reasoning_effort), where
# `reasoning_default: off` would otherwise win. An explicit
# enable_thinking=false above still takes precedence.
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":
Expand Down
84 changes: 84 additions & 0 deletions backend/python/sglang/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,80 @@ def test_no_thinking_budget_means_no_custom_params_key(self):
params = servicer._build_sampling_params(request)
self.assertNotIn("custom_params", params)

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(""), {})
self.assertEqual(servicer._parse_effort_budgets(";;low=512;"), {"low": 512})

def test_effort_budgets_empty_value_warns(self):
"""`high=` is skipped like any other invalid entry, and says so."""
import contextlib
import io

servicer = self._servicer()
err = io.StringIO()
with contextlib.redirect_stderr(err):
self.assertEqual(servicer._parse_effort_budgets("high=;max=16384"), {"max": 16384})
self.assertIn("'high='", err.getvalue())

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, "xhigh": 16384, "max": 32768}
for level, budget in (("low", 512), ("medium", 2048), ("HIGH", 8192), ("xhigh", 16384), ("max", 32768)):
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):
"""A request that carries only reasoning_effort (no enable_thinking) for a
level listed in effort_budgets must think instead of falling to default off."""
servicer = self._servicer()
servicer.reasoning_default = "off"
servicer.effort_budgets = {"xhigh": 16384, "max": 16384}
self.assertTrue(servicer._thinking_default(_request(metadata={"reasoning_effort": "xhigh"})))
self.assertTrue(servicer._thinking_default(_request(metadata={"reasoning_effort": "max"})))
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()
Expand Down Expand Up @@ -328,6 +402,16 @@ def test_warns_when_budget_set_without_strict_thinking(self):
self.assertIsNone(servicer._strict_thinking_warning(512, {"enable_strict_thinking": True}))
self.assertIsNone(servicer._strict_thinking_warning(None, {}))

def test_warns_when_only_effort_budgets_are_set(self):
"""A model with effort_budgets but no thinking_budget still needs strict thinking."""
servicer = self._servicer()
servicer.thinking_budget = None
servicer.effort_budgets = {"low": 256, "max": 16384}
self.assertEqual(servicer._configured_budget(), 16384)
self.assertIn("enable_strict_thinking", servicer._strict_thinking_warning(servicer._configured_budget(), {}))
servicer.effort_budgets = {}
self.assertIsNone(servicer._configured_budget())

def test_explicit_zero_temperature_and_seed_are_preserved(self):
"""Temperature=0 is greedy decoding and 0 is a valid seed — neither is
an unset value. A dropped seed turns a reproducible request random."""
Expand Down
2 changes: 1 addition & 1 deletion core/config/model_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1142,7 +1142,7 @@ func (c *ModelConfig) ApplyReasoningEffort(requestEffort string) {
case "none":
disable := true
c.ReasoningConfig.DisableReasoning = &disable
case "minimal", "low", "medium", "high":
case "minimal", "low", "medium", "high", "xhigh", "max":
if c.ReasoningConfig.DisableReasoning == nil || !*c.ReasoningConfig.DisableReasoning {
enable := false
c.ReasoningConfig.DisableReasoning = &enable
Expand Down
22 changes: 17 additions & 5 deletions core/config/reasoning_effort_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,24 @@ var _ = Describe("ModelConfig.ApplyReasoningEffort", func() {
Expect(*c.ReasoningConfig.DisableReasoning).To(BeFalse())
})

It("enables thinking for the levels above high", func() {
for _, level := range []string{"xhigh", "max"} {
c := &config.ModelConfig{}
c.ApplyReasoningEffort(level)
Expect(c.ReasoningEffort).To(Equal(level))
Expect(c.ReasoningConfig.DisableReasoning).ToNot(BeNil())
Expect(*c.ReasoningConfig.DisableReasoning).To(BeFalse())
}
})

It("does not let a level override an operator's config-level disable", func() {
disabled := true
c := &config.ModelConfig{}
c.ReasoningConfig.DisableReasoning = &disabled
c.ApplyReasoningEffort("high")
Expect(*c.ReasoningConfig.DisableReasoning).To(BeTrue())
for _, level := range []string{"high", "xhigh", "max"} {
disabled := true
c := &config.ModelConfig{}
c.ReasoningConfig.DisableReasoning = &disabled
c.ApplyReasoningEffort(level)
Expect(*c.ReasoningConfig.DisableReasoning).To(BeTrue(), level)
}
})

It("is a no-op on the toggle when no effort is set anywhere", func() {
Expand Down
4 changes: 2 additions & 2 deletions docs/content/advanced/model-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ These load-time options control how the backend parses `<think>` reasoning block
This is the load-time reasoning configuration. The orthogonal per-request `enable_thinking` chat-template kwarg toggles thinking on/off per call without restarting the model. It can be driven either by the YAML `reasoning.disable` field (model default) or per request via the OpenAI `reasoning_effort` field on `/v1/chat/completions`:

- `reasoning_effort: "none"` disables thinking for that request (`enable_thinking=false`) - useful to run a single reasoning model like Qwen3 for low-latency tasks while still enabling reasoning on other requests.
- `reasoning_effort: "minimal" | "low" | "medium" | "high"` enables thinking, unless the model config explicitly set `reasoning.disable: true` (an operator's explicit disable wins and is never re-enabled by a request).
- `reasoning_effort: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"` enables thinking, unless the model config explicitly set `reasoning.disable: true` (an operator's explicit disable wins and is never re-enabled by a request).
{{% /notice %}}

#### `reasoning_effort` as a chat-template kwarg
Expand All @@ -657,7 +657,7 @@ Set a per-model default in the config so every request inherits it (a per-reques

```yaml
name: my-model
reasoning_effort: none # none | minimal | low | medium | high
reasoning_effort: none # none | minimal | low | medium | high | xhigh | max
```

For [realtime pipelines]({{%relref "features/openai-realtime" %}}), set it on the pipeline so it applies to the pipeline's LLM without editing that model's own config:
Expand Down
13 changes: 13 additions & 0 deletions docs/content/features/text-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,19 @@ LocalAI warns if you configure a budget without that engine option.
Keep the budget well below the `max_tokens` of your requests: if `max_tokens` is reached first,
the budget never triggers and the whole reply can be spent on reasoning, leaving the answer empty.

`effort_budgets` maps the request's `reasoning_effort` to its own budget, so the levels differ in how long the model may think:

```yaml
options:
- thinking_budget:256 # fallback: no effort, or a level not listed below
- effort_budgets:low=256;medium=1024;high=4096;xhigh=16384;max=16384
```

Entries are separated by `;` and use the same parsing rules as `thinking_budget` (invalid entries are skipped with a warning).
A request whose effort is not listed falls back to `thinking_budget`; without either, no budget is sent.
A level listed here also turns reasoning on for that request, even when `reasoning_default:off` is set; an explicit `enable_thinking: "false"` in the request metadata (also sent when the model sets `reasoning.disable: true`) still wins.
The budget is an upper bound, not a target: the model may stop thinking earlier.

`reasoning_default:on` or `reasoning_default:off` sets the default for LocalAI's tokenizer chat template.
Request metadata `enable_thinking` set to `"true"` or `"false"` overrides this default.
An explicit prompt bypasses tokenizer template rendering.
Expand Down