Skip to content

Commit e837921

Browse files
localai-botmudler
andauthored
feat: forward reasoning_effort to the backend so jinja models honor it (#10184)
* feat: forward reasoning_effort to the backend so jinja models honor it reasoning_effort was only mapped to the binary enable_thinking toggle and otherwise reached Go-side templates — it was never sent to the backend. So jinja-templated models whose chat template keys on reasoning_effort (gpt-oss Harmony, LFM2.5) could not be driven by it: LFM2.5 ignores enable_thinking and kept emitting <think>. Forward the effective reasoning_effort to the backend as a chat_template_kwarg (mirroring enable_thinking) in grpc-server.cpp, and put it in PredictOptions metadata (gRPCPredictOpts). Add a config-level default: ModelConfig.reasoning_effort and Pipeline.reasoning_effort, resolved by ModelConfig.ApplyReasoningEffort (request value overrides config default, none->disable / level->enable, an operator's reasoning.disable wins). request.go now uses that helper. Assisted-by: Claude:claude-opus-4-8 go test, golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(realtime): set the pipeline LLM's reasoning_effort Apply Pipeline.ReasoningEffort to the pipeline's LLM config when the realtime model is built (per-session copy, overrides the LLM's own reasoning_effort), and surface the resolved effort on the template input so Go-templated models get it too. jinja models receive it via the backend metadata. This lets a realtime pipeline disable thinking on models that only honor reasoning_effort (e.g. LFM2.5), which enable_thinking can't. Assisted-by: Claude:claude-opus-4-8 go test, golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 7338571 commit e837921

11 files changed

Lines changed: 269 additions & 30 deletions

File tree

backend/cpp/llama-cpp/grpc-server.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,6 +1944,17 @@ class BackendServiceImpl final : public backend::Backend::Service {
19441944
body_json["chat_template_kwargs"]["enable_thinking"] = (et_it->second == "true");
19451945
}
19461946

1947+
// Pass reasoning_effort via chat_template_kwargs too: the lever
1948+
// jinja templates like gpt-oss (Harmony) / LFM2.5 read, distinct
1949+
// from enable_thinking which those templates ignore.
1950+
auto re_it = metadata.find("reasoning_effort");
1951+
if (re_it != metadata.end() && !re_it->second.empty()) {
1952+
if (!body_json.contains("chat_template_kwargs")) {
1953+
body_json["chat_template_kwargs"] = json::object();
1954+
}
1955+
body_json["chat_template_kwargs"]["reasoning_effort"] = re_it->second;
1956+
}
1957+
19471958
// Debug: Print full body_json before template processing (includes messages, tools, tool_choice, etc.)
19481959
SRV_DBG("[CONVERSATION DEBUG] PredictStream: Full body_json before oaicompat_chat_params_parse:\n%s\n", body_json.dump(2).c_str());
19491960

@@ -2737,6 +2748,17 @@ class BackendServiceImpl final : public backend::Backend::Service {
27372748
body_json["chat_template_kwargs"]["enable_thinking"] = (predict_et_it->second == "true");
27382749
}
27392750

2751+
// Pass reasoning_effort via chat_template_kwargs too: the lever
2752+
// jinja templates like gpt-oss (Harmony) / LFM2.5 read, distinct
2753+
// from enable_thinking which those templates ignore.
2754+
auto predict_re_it = predict_metadata.find("reasoning_effort");
2755+
if (predict_re_it != predict_metadata.end() && !predict_re_it->second.empty()) {
2756+
if (!body_json.contains("chat_template_kwargs")) {
2757+
body_json["chat_template_kwargs"] = json::object();
2758+
}
2759+
body_json["chat_template_kwargs"]["reasoning_effort"] = predict_re_it->second;
2760+
}
2761+
27402762
// Debug: Print full body_json before template processing (includes messages, tools, tool_choice, etc.)
27412763
SRV_DBG("[CONVERSATION DEBUG] Predict: Full body_json before oaicompat_chat_params_parse:\n%s\n", body_json.dump(2).c_str());
27422764

core/backend/options.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,13 +239,13 @@ func grpcModelOpts(c config.ModelConfig, modelPath string) *pb.ModelOptions {
239239

240240
if c.Backend == "cloud-proxy" {
241241
opts.Proxy = &pb.ProxyOptions{
242-
UpstreamUrl: c.Proxy.UpstreamURL,
243-
Mode: c.Proxy.Mode,
244-
Provider: c.Proxy.Provider,
245-
ApiKeyEnv: c.Proxy.APIKeyEnv,
246-
ApiKeyFile: c.Proxy.APIKeyFile,
247-
UpstreamModel: c.Proxy.UpstreamModel,
248-
RequestTimeoutSeconds: int32(c.Proxy.RequestTimeoutSeconds),
242+
UpstreamUrl: c.Proxy.UpstreamURL,
243+
Mode: c.Proxy.Mode,
244+
Provider: c.Proxy.Provider,
245+
ApiKeyEnv: c.Proxy.APIKeyEnv,
246+
ApiKeyFile: c.Proxy.APIKeyFile,
247+
UpstreamModel: c.Proxy.UpstreamModel,
248+
RequestTimeoutSeconds: int32(c.Proxy.RequestTimeoutSeconds),
249249
}
250250
}
251251

@@ -323,6 +323,12 @@ func gRPCPredictOpts(c config.ModelConfig, modelPath string) *pb.PredictOptions
323323
metadata["enable_thinking"] = "true"
324324
}
325325
}
326+
// Forward the effective reasoning effort so the backend can pass it to the
327+
// jinja chat template (chat_template_kwargs.reasoning_effort) — the lever
328+
// models like gpt-oss / LFM2.5 actually read, distinct from enable_thinking.
329+
if c.ReasoningEffort != "" {
330+
metadata["reasoning_effort"] = c.ReasoningEffort
331+
}
326332
pbOpts.Metadata = metadata
327333

328334
// Logprobs and TopLogprobs are set by the caller if provided

core/backend/options_internal_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,25 @@ var _ = Describe("gRPCPredictOpts enable_thinking metadata", func() {
7575
Expect(opts.Metadata).ToNot(HaveKey("enable_thinking"))
7676
})
7777
})
78+
79+
// Guards forwarding the effective reasoning_effort into PredictOptions.Metadata,
80+
// where the backend passes it to the jinja chat template (chat_template_kwargs)
81+
// so models like gpt-oss / LFM2.5 honor it.
82+
var _ = Describe("gRPCPredictOpts reasoning_effort metadata", func() {
83+
withEffort := func(effort string) config.ModelConfig {
84+
cfg := config.ModelConfig{}
85+
cfg.SetDefaults()
86+
cfg.ReasoningEffort = effort
87+
return cfg
88+
}
89+
90+
It("forwards reasoning_effort when set", func() {
91+
opts := gRPCPredictOpts(withEffort("none"), "/tmp/models")
92+
Expect(opts.Metadata).To(HaveKeyWithValue("reasoning_effort", "none"))
93+
})
94+
95+
It("omits reasoning_effort when empty", func() {
96+
opts := gRPCPredictOpts(withEffort(""), "/tmp/models")
97+
Expect(opts.Metadata).ToNot(HaveKey("reasoning_effort"))
98+
})
99+
})

core/config/meta/registry.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ func DefaultRegistry() map[string]FieldMetaOverride {
128128
Advanced: true,
129129
Order: 21,
130130
},
131+
"reasoning_effort": {
132+
Section: "llm",
133+
Label: "Reasoning Effort",
134+
Description: "Default reasoning effort, forwarded to the backend as the reasoning_effort chat_template_kwarg (jinja models like gpt-oss / LFM2.5 honor it). A per-request reasoning_effort overrides it. 'none' also turns thinking off.",
135+
Component: "select",
136+
Options: []FieldOption{
137+
{Value: "", Label: "Unset (model default)"},
138+
{Value: "none", Label: "none (disable thinking)"},
139+
{Value: "minimal", Label: "minimal"},
140+
{Value: "low", Label: "low"},
141+
{Value: "medium", Label: "medium"},
142+
{Value: "high", Label: "high"},
143+
},
144+
Advanced: true,
145+
Order: 22,
146+
},
131147
"cache_type_k": {
132148
Section: "llm",
133149
Label: "KV Cache Type (K)",
@@ -277,6 +293,21 @@ func DefaultRegistry() map[string]FieldMetaOverride {
277293
AutocompleteProvider: ProviderModelsVAD,
278294
Order: 63,
279295
},
296+
"pipeline.reasoning_effort": {
297+
Section: "pipeline",
298+
Label: "Reasoning Effort",
299+
Description: "Reasoning effort for the pipeline's LLM, forwarded to the backend as the reasoning_effort chat_template_kwarg (jinja models like gpt-oss / LFM2.5 honor it). Overrides the LLM model's own reasoning_effort. 'none' also turns thinking off.",
300+
Component: "select",
301+
Options: []FieldOption{
302+
{Value: "", Label: "Default (model config)"},
303+
{Value: "none", Label: "none (disable thinking)"},
304+
{Value: "minimal", Label: "minimal"},
305+
{Value: "low", Label: "low"},
306+
{Value: "medium", Label: "medium"},
307+
{Value: "high", Label: "high"},
308+
},
309+
Order: 64,
310+
},
280311

281312
// --- Functions ---
282313
"function.grammar.parallel_calls": {

core/config/model_config.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ type ModelConfig struct {
6363
FunctionsConfig functions.FunctionsConfig `yaml:"function,omitempty" json:"function,omitempty"`
6464
ReasoningConfig reasoning.Config `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
6565

66+
// ReasoningEffort is the default reasoning effort (none|minimal|low|medium|high)
67+
// for this model. A per-request reasoning_effort overrides it. It is forwarded
68+
// to the backend as the reasoning_effort chat_template_kwarg (see
69+
// gRPCPredictOpts), so jinja-templated models that key on it — e.g. gpt-oss
70+
// (Harmony) or LFM2.5 — honor it; "none" also toggles enable_thinking off.
71+
ReasoningEffort string `yaml:"reasoning_effort,omitempty" json:"reasoning_effort,omitempty"`
72+
6673
FeatureFlag FeatureFlag `yaml:"feature_flags,omitempty" json:"feature_flags,omitempty"` // Feature Flag registry. We move fast, and features may break on a per model/backend basis. Registry for (usually temporary) flags that indicate aborting something early.
6774
// LLM configs (GPT4ALL, Llama.cpp, ...)
6875
LLMConfig `yaml:",inline" json:",inline"`
@@ -487,6 +494,40 @@ type Pipeline struct {
487494
LLM string `yaml:"llm,omitempty" json:"llm,omitempty"`
488495
Transcription string `yaml:"transcription,omitempty" json:"transcription,omitempty"`
489496
VAD string `yaml:"vad,omitempty" json:"vad,omitempty"`
497+
498+
// ReasoningEffort sets the reasoning effort (none|minimal|low|medium|high) for
499+
// the pipeline's LLM without editing the LLM model config. Overrides the LLM's
500+
// own reasoning_effort. Unset leaves the LLM model config in charge.
501+
ReasoningEffort string `yaml:"reasoning_effort,omitempty" json:"reasoning_effort,omitempty"`
502+
}
503+
504+
// ApplyReasoningEffort resolves the effective reasoning effort — a per-request
505+
// value (requestEffort) overrides the config's own ReasoningEffort default —
506+
// stores it on the config so gRPCPredictOpts forwards it to the backend as the
507+
// reasoning_effort chat_template_kwarg, and maps it onto the enable_thinking
508+
// toggle the backend also reads:
509+
// - "none" always disables thinking.
510+
// - any explicit level enables it, UNLESS the config already disabled reasoning
511+
// (an operator's explicit disable wins over a request asking to think).
512+
//
513+
// An empty requestEffort keeps the config's own default. With no effort set
514+
// anywhere it is a no-op, leaving the model's reasoning settings untouched.
515+
func (c *ModelConfig) ApplyReasoningEffort(requestEffort string) {
516+
effort := requestEffort
517+
if effort == "" {
518+
effort = c.ReasoningEffort
519+
}
520+
c.ReasoningEffort = effort
521+
switch strings.ToLower(effort) {
522+
case "none":
523+
disable := true
524+
c.ReasoningConfig.DisableReasoning = &disable
525+
case "minimal", "low", "medium", "high":
526+
if c.ReasoningConfig.DisableReasoning == nil || !*c.ReasoningConfig.DisableReasoning {
527+
enable := false
528+
c.ReasoningConfig.DisableReasoning = &enable
529+
}
530+
}
490531
}
491532

492533
// @Description File configuration for model downloads
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package config_test
2+
3+
import (
4+
. "github.com/onsi/ginkgo/v2"
5+
. "github.com/onsi/gomega"
6+
7+
"github.com/mudler/LocalAI/core/config"
8+
)
9+
10+
// ApplyReasoningEffort resolves the effective reasoning effort (request value
11+
// overrides the model config default), stores it on the config so it reaches the
12+
// backend, and maps it onto the enable_thinking toggle.
13+
var _ = Describe("ModelConfig.ApplyReasoningEffort", func() {
14+
It("uses the request value over the config default", func() {
15+
c := &config.ModelConfig{ReasoningEffort: "high"}
16+
c.ApplyReasoningEffort("none")
17+
Expect(c.ReasoningEffort).To(Equal("none"))
18+
Expect(c.ReasoningConfig.DisableReasoning).ToNot(BeNil())
19+
Expect(*c.ReasoningConfig.DisableReasoning).To(BeTrue())
20+
})
21+
22+
It("falls back to the config default when the request omits it", func() {
23+
c := &config.ModelConfig{ReasoningEffort: "none"}
24+
c.ApplyReasoningEffort("")
25+
Expect(c.ReasoningEffort).To(Equal("none"))
26+
Expect(c.ReasoningConfig.DisableReasoning).ToNot(BeNil())
27+
Expect(*c.ReasoningConfig.DisableReasoning).To(BeTrue())
28+
})
29+
30+
It("enables thinking for an explicit effort level", func() {
31+
c := &config.ModelConfig{}
32+
c.ApplyReasoningEffort("medium")
33+
Expect(c.ReasoningEffort).To(Equal("medium"))
34+
Expect(c.ReasoningConfig.DisableReasoning).ToNot(BeNil())
35+
Expect(*c.ReasoningConfig.DisableReasoning).To(BeFalse())
36+
})
37+
38+
It("does not let a level override an operator's config-level disable", func() {
39+
disabled := true
40+
c := &config.ModelConfig{}
41+
c.ReasoningConfig.DisableReasoning = &disabled
42+
c.ApplyReasoningEffort("high")
43+
Expect(*c.ReasoningConfig.DisableReasoning).To(BeTrue())
44+
})
45+
46+
It("is a no-op on the toggle when no effort is set anywhere", func() {
47+
c := &config.ModelConfig{}
48+
c.ApplyReasoningEffort("")
49+
Expect(c.ReasoningEffort).To(Equal(""))
50+
Expect(c.ReasoningConfig.DisableReasoning).To(BeNil())
51+
})
52+
})

core/http/endpoints/openai/realtime_model.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ type wrappedModel struct {
4444
// deps in. nil-safe: with classifierRegistry == nil the per-turn
4545
// routing block in Predict is skipped, preserving today's "one LLM
4646
// for the whole session" behaviour.
47-
routerDeps *middleware.ClassifierDeps
48-
routerStore router.DecisionStore
49-
routerSessionID string
50-
routerUserID string
47+
routerDeps *middleware.ClassifierDeps
48+
routerStore router.DecisionStore
49+
routerSessionID string
50+
routerUserID string
5151
}
5252

5353
// anyToAnyModel represent a model which supports Any-to-Any operations
@@ -119,6 +119,11 @@ func (m *wrappedModel) Predict(ctx context.Context, messages schema.Messages, im
119119
}
120120
}
121121

122+
// Surface the resolved reasoning effort to the Go-side template path too
123+
// (jinja models get it via backend metadata in gRPCPredictOpts; Go-templated
124+
// models like gpt-oss read it from the template's .ReasoningEffort).
125+
input.ReasoningEffort = turnCfg.ReasoningEffort
126+
122127
var predInput string
123128
var funcs []functions.Function
124129
if !turnCfg.TemplateConfig.UseTokenizerTemplate {
@@ -449,6 +454,9 @@ func newModel(pipeline *config.Pipeline, cl *config.ModelConfigLoader, ml *model
449454
return nil, fmt.Errorf("failed to validate config: %w", err)
450455
}
451456

457+
// Let the pipeline set the LLM's reasoning effort (cfgLLM is a per-session copy).
458+
applyPipelineReasoning(cfgLLM, *pipeline)
459+
452460
cfgTTS, err := cl.LoadModelConfigFileByName(pipeline.TTS, ml.ModelPath)
453461
if err != nil {
454462

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package openai
2+
3+
import "github.com/mudler/LocalAI/core/config"
4+
5+
// applyPipelineReasoning sets the reasoning effort for a realtime pipeline's LLM
6+
// from the pipeline config, without editing the underlying LLM model config. The
7+
// pipeline value overrides the LLM's own reasoning_effort; when the pipeline does
8+
// not set it, the LLM model config's reasoning_effort (if any) is used. The LLM
9+
// config passed in is the per-session copy returned by the config loader, so this
10+
// does not affect other users of the same model.
11+
func applyPipelineReasoning(llm *config.ModelConfig, pipeline config.Pipeline) {
12+
if llm == nil {
13+
return
14+
}
15+
llm.ApplyReasoningEffort(pipeline.ReasoningEffort)
16+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package openai
2+
3+
import (
4+
. "github.com/onsi/ginkgo/v2"
5+
. "github.com/onsi/gomega"
6+
7+
"github.com/mudler/LocalAI/core/config"
8+
)
9+
10+
// applyPipelineReasoning lets a realtime pipeline set the reasoning effort for
11+
// its LLM (forwarded to the backend as reasoning_effort) without editing the LLM
12+
// model config. The pipeline value overrides the LLM's own reasoning_effort.
13+
var _ = Describe("applyPipelineReasoning", func() {
14+
It("applies the pipeline reasoning_effort to the LLM config", func() {
15+
llm := &config.ModelConfig{}
16+
applyPipelineReasoning(llm, config.Pipeline{ReasoningEffort: "none"})
17+
Expect(llm.ReasoningEffort).To(Equal("none"))
18+
Expect(llm.ReasoningConfig.DisableReasoning).ToNot(BeNil())
19+
Expect(*llm.ReasoningConfig.DisableReasoning).To(BeTrue())
20+
})
21+
22+
It("falls back to the LLM's own reasoning_effort when the pipeline is unset", func() {
23+
llm := &config.ModelConfig{ReasoningEffort: "high"}
24+
applyPipelineReasoning(llm, config.Pipeline{})
25+
Expect(llm.ReasoningEffort).To(Equal("high"))
26+
Expect(llm.ReasoningConfig.DisableReasoning).ToNot(BeNil())
27+
Expect(*llm.ReasoningConfig.DisableReasoning).To(BeFalse())
28+
})
29+
30+
It("is nil-safe", func() {
31+
applyPipelineReasoning(nil, config.Pipeline{ReasoningEffort: "low"})
32+
})
33+
})

core/http/middleware/request.go

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -310,25 +310,13 @@ func mergeOpenAIRequestAndModelConfig(config *config.ModelConfig, input *schema.
310310
config.Temperature = input.Temperature
311311
}
312312

313-
// Map the per-request reasoning_effort onto the reasoning toggle the
314-
// backend reads (enable_thinking metadata, set in gRPCPredictOpts).
315-
// "none" disables thinking for this request - the use case from #10072,
316-
// running a single Qwen3-style model and turning reasoning off per
317-
// request. Any explicit effort level enables thinking, UNLESS the model
318-
// config explicitly disabled it (DisableReasoning==true wins): an
319-
// operator who deliberately turned reasoning off should not be overridden
320-
// by a request. A value of "none" always disables, since that never
321-
// conflicts with a config that also disables.
322-
switch strings.ToLower(input.ReasoningEffort) {
323-
case "none":
324-
disable := true
325-
config.ReasoningConfig.DisableReasoning = &disable
326-
case "minimal", "low", "medium", "high":
327-
if config.ReasoningConfig.DisableReasoning == nil || !*config.ReasoningConfig.DisableReasoning {
328-
enable := false
329-
config.ReasoningConfig.DisableReasoning = &enable
330-
}
331-
}
313+
// Resolve the effective reasoning effort (request overrides the model config
314+
// default), store it so gRPCPredictOpts forwards it to the backend as the
315+
// reasoning_effort chat_template_kwarg (what gpt-oss / LFM2.5 read), and map
316+
// it onto the enable_thinking toggle. "none" disables thinking (the #10072
317+
// use case); a level enables it unless the config already disabled reasoning
318+
// (an operator's explicit disable wins over a request asking to think).
319+
config.ApplyReasoningEffort(input.ReasoningEffort)
332320

333321
// Collapse the modern max_completion_tokens alias into the
334322
// legacy Maxtokens field so downstream code reads exactly one.

0 commit comments

Comments
 (0)