Skip to content

Commit 0761bd0

Browse files
feat(chat): add end-to-end context compression (mudler#11556)
* feat(config): add context compression policy Define the opt-in model configuration contract before the chat middleware consumes it. Document each policy field so later request handling does not invent a second schema.\n\nRefs mudler#9534\n\nAssisted-by: Codex:gpt-5 * fix(config): register compression fields The model editor metadata gate rejects new config fields without descriptions and suitable controls. Register the compression policy so operators can edit its six fields safely. Assisted-by: Codex:gpt-5 [monitoring-prs] * feat(chat): compress long contexts Long conversations currently fail once they reach the model context window. The opt-in policy now summarizes complete older turns before primary inference and preserves the newest tool chains. Both OpenAI and MCP chat routes share the same transformation. Usage metadata and metrics expose each compression event. Refs mudler#9534 Assisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
1 parent cf93c04 commit 0761bd0

19 files changed

Lines changed: 1019 additions & 18 deletions

File tree

core/config/meta/registry.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,64 @@ func DefaultRegistry() map[string]FieldMetaOverride {
185185
Advanced: true,
186186
Order: 22,
187187
},
188+
"compression.enabled": {
189+
Section: "llm",
190+
Label: "Context Compression",
191+
Description: "Enable compression of chat history before it reaches the model context limit",
192+
Component: "checkbox",
193+
Advanced: true,
194+
Order: 24,
195+
},
196+
"compression.trigger_at_ratio": {
197+
Section: "llm",
198+
Label: "Compression Trigger Ratio",
199+
Description: "Fraction of the model context window that starts compression",
200+
Component: "slider",
201+
Min: f64(0),
202+
Max: f64(1),
203+
Step: f64(0.05),
204+
Advanced: true,
205+
Order: 25,
206+
},
207+
"compression.keep_tail_tokens": {
208+
Section: "llm",
209+
Label: "Compression Tail Tokens",
210+
Description: "Number of newest conversation tokens to keep outside the summary",
211+
Component: "number",
212+
Min: f64(0),
213+
Advanced: true,
214+
Order: 26,
215+
},
216+
"compression.max_summary_tokens": {
217+
Section: "llm",
218+
Label: "Maximum Summary Tokens",
219+
Description: "Maximum number of tokens produced by context compression",
220+
Component: "number",
221+
Min: f64(0),
222+
Advanced: true,
223+
Order: 27,
224+
},
225+
"compression.compressor_model": {
226+
Section: "llm",
227+
Label: "Compressor Model",
228+
Description: "Chat model used to summarize context; empty uses this model",
229+
Component: "model-select",
230+
AutocompleteProvider: ProviderModelsChat,
231+
Advanced: true,
232+
Order: 28,
233+
},
234+
"compression.on_post_compression_overflow": {
235+
Section: "llm",
236+
Label: "Post-compression Overflow",
237+
Description: "Action to take when compressed context still exceeds the context limit",
238+
Component: "select",
239+
Options: []FieldOption{
240+
{Value: "drop_oldest_summary", Label: "Drop Oldest Summary"},
241+
{Value: "error", Label: "Return Error"},
242+
},
243+
Advanced: true,
244+
Order: 29,
245+
},
188246
"cache_type_k": {
189247
Section: "llm",
190248
Label: "KV Cache Type (K)",

core/config/model_config.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ type ModelConfig struct {
8484

8585
FunctionsConfig functions.FunctionsConfig `yaml:"function,omitempty" json:"function,omitempty"`
8686
ReasoningConfig reasoning.Config `yaml:"reasoning,omitempty" json:"reasoning,omitempty"`
87+
Compression CompressionConfig `yaml:"compression,omitempty" json:"compression,omitempty"`
8788

8889
// ReasoningEffort is the default reasoning effort (none|minimal|low|medium|high)
8990
// for this model. A per-request reasoning_effort overrides it. It is forwarded
@@ -152,6 +153,18 @@ type ModelConfig struct {
152153
Limits LimitsConfig `yaml:"limits,omitempty" json:"limits,omitempty"`
153154
}
154155

156+
// CompressionConfig controls opt-in compression of chat history before inference.
157+
// The request middleware consumes this configuration; keeping it on ModelConfig
158+
// lets operators select a policy per context window and model workload.
159+
type CompressionConfig struct {
160+
Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
161+
TriggerAtRatio float64 `yaml:"trigger_at_ratio,omitempty" json:"trigger_at_ratio,omitempty"`
162+
KeepTailTokens int `yaml:"keep_tail_tokens,omitempty" json:"keep_tail_tokens,omitempty"`
163+
MaxSummaryTokens int `yaml:"max_summary_tokens,omitempty" json:"max_summary_tokens,omitempty"`
164+
CompressorModel string `yaml:"compressor_model,omitempty" json:"compressor_model,omitempty"`
165+
OnPostCompressionOverflow string `yaml:"on_post_compression_overflow,omitempty" json:"on_post_compression_overflow,omitempty"`
166+
}
167+
155168
// @Description Admission-control limits applied per request. The
156169
// admission middleware enforces these before invoking the handler;
157170
// requests that exceed a limit get 503 with a Retry-After hint so
@@ -1538,6 +1551,22 @@ func (cfg *ModelConfig) SetDefaults(opts ...ConfigLoaderOption) {
15381551
}
15391552

15401553
func (c *ModelConfig) Validate() (bool, error) {
1554+
if c.Compression.Enabled {
1555+
if c.IsCloudProxyBackendPassthrough() {
1556+
return false, fmt.Errorf("compression: cloud-proxy passthrough is unsupported; configure proxy mode translate")
1557+
}
1558+
if c.Compression.TriggerAtRatio < 0 || c.Compression.TriggerAtRatio > 1 {
1559+
return false, fmt.Errorf("compression: trigger_at_ratio must be between 0 and 1")
1560+
}
1561+
if c.Compression.KeepTailTokens < 0 || c.Compression.MaxSummaryTokens < 0 {
1562+
return false, fmt.Errorf("compression: token limits cannot be negative")
1563+
}
1564+
switch c.Compression.OnPostCompressionOverflow {
1565+
case "", "error", "drop_oldest_summary":
1566+
default:
1567+
return false, fmt.Errorf("compression: unknown on_post_compression_overflow %q", c.Compression.OnPostCompressionOverflow)
1568+
}
1569+
}
15411570
if c.IsAlias() && len(c.Artifacts) > 0 {
15421571
return false, fmt.Errorf("alias model %q cannot declare artifacts", c.Name)
15431572
}

core/config/model_config_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,54 @@ parameters:
5252
Expect(valid).To(BeTrue())
5353
})
5454

55+
It("round-trips context compression settings", func() {
56+
raw := []byte(`
57+
name: compressed-chat
58+
backend: llama-cpp
59+
parameters:
60+
model: chat.gguf
61+
compression:
62+
enabled: true
63+
trigger_at_ratio: 0.75
64+
keep_tail_tokens: 8000
65+
max_summary_tokens: 2048
66+
compressor_model: fast-summarizer
67+
on_post_compression_overflow: error
68+
`)
69+
var cfg ModelConfig
70+
Expect(yaml.Unmarshal(raw, &cfg)).To(Succeed())
71+
Expect(cfg.Compression.Enabled).To(BeTrue())
72+
Expect(cfg.Compression.TriggerAtRatio).To(Equal(0.75))
73+
Expect(cfg.Compression.KeepTailTokens).To(Equal(8000))
74+
Expect(cfg.Compression.MaxSummaryTokens).To(Equal(2048))
75+
Expect(cfg.Compression.CompressorModel).To(Equal("fast-summarizer"))
76+
Expect(cfg.Compression.OnPostCompressionOverflow).To(Equal("error"))
77+
})
78+
79+
It("rejects invalid context compression policies", func() {
80+
cfg := ModelConfig{Compression: CompressionConfig{Enabled: true, TriggerAtRatio: 1.1}}
81+
valid, err := cfg.Validate()
82+
Expect(valid).To(BeFalse())
83+
Expect(err).To(MatchError(ContainSubstring("trigger_at_ratio")))
84+
85+
cfg.Compression.TriggerAtRatio = 0.75
86+
cfg.Compression.OnPostCompressionOverflow = "truncate_anything"
87+
valid, err = cfg.Validate()
88+
Expect(valid).To(BeFalse())
89+
Expect(err).To(MatchError(ContainSubstring("on_post_compression_overflow")))
90+
})
91+
92+
It("rejects context compression for cloud proxy passthrough", func() {
93+
cfg := ModelConfig{
94+
Backend: "cloud-proxy",
95+
Proxy: ProxyConfig{Mode: ProxyModePassthrough},
96+
Compression: CompressionConfig{Enabled: true},
97+
}
98+
valid, err := cfg.Validate()
99+
Expect(valid).To(BeFalse())
100+
Expect(err).To(MatchError(ContainSubstring("cloud-proxy passthrough")))
101+
})
102+
55103
It("derives a managed snapshot filename without replacing the logical model", func() {
56104
const cacheKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
57105
cfg := ModelConfig{

core/http/endpoints/localai/mcp.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,15 @@ type MCPErrorEvent struct {
5757
// @Param request body schema.OpenAIRequest true "query params"
5858
// @Success 200 {object} schema.OpenAIResponse "Response"
5959
// @Router /v1/mcp/chat/completions [post]
60-
func MCPEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient) echo.HandlerFunc {
60+
func MCPEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, appConfig *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, compressor middleware.ChatCompressor) echo.HandlerFunc {
6161
// The legacy /v1/mcp/chat/completions endpoint never opts into the
6262
// in-process LocalAI Assistant tool surface — pass nil holder so the
6363
// assistant branch in chat.go is unreachable from this code path.
6464
// Stream-side PII filter is also nil: this legacy endpoint pre-dates
6565
// the per-model PII config and is kept for backward compatibility.
6666
// The request-side middleware on the main chat route handles
6767
// filtering for the standard /v1/chat/completions path.
68-
chatHandler := openai.ChatEndpoint(cl, ml, evaluator, appConfig, natsClient, nil)
68+
chatHandler := openai.ChatEndpoint(cl, ml, evaluator, appConfig, natsClient, nil, compressor)
6969

7070
return func(c echo.Context) error {
7171
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenAIRequest)

core/http/endpoints/openai/chat.go

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ func applyAutoparserOverride(
129129
// @Param request body schema.OpenAIRequest true "query params"
130130
// @Success 200 {object} schema.OpenAIResponse "Response"
131131
// @Router /v1/chat/completions [post]
132-
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, startupOptions *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, assistantHolder *mcpTools.LocalAIAssistantHolder) echo.HandlerFunc {
132+
func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator *templates.Evaluator, startupOptions *config.ApplicationConfig, natsClient mcpTools.MCPNATSClient, assistantHolder *mcpTools.LocalAIAssistantHolder, compressor middleware.ChatCompressor) echo.HandlerFunc {
133133
return func(c echo.Context) error {
134134
var textContentToReturn string
135135
id := uuid.New().String()
@@ -155,6 +155,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
155155
// redaction already ran in the middleware; the response is
156156
// forwarded unmodified.
157157
if config.IsCloudProxyBackendPassthrough() {
158+
if err := middleware.CompressChatRequest(c, compressor); err != nil {
159+
return err
160+
}
158161
return forwardCloudProxyOpenAIViaBackend(c, config, input, ml, startupOptions)
159162
}
160163

@@ -201,7 +204,8 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
201204
// system message they're responsible for keeping the assistant
202205
// safe, so we leave it alone.
203206
if !hasSystemMessage(input.Messages) {
204-
input.Messages = append([]schema.Message{{Role: "system", StringContent: assistantHolder.SystemPrompt()}}, input.Messages...)
207+
prompt := assistantHolder.SystemPrompt()
208+
input.Messages = append([]schema.Message{{Role: "system", Content: prompt, StringContent: prompt}}, input.Messages...)
205209
}
206210

207211
xlog.Debug("LocalAI Assistant tools injected", "count", len(mcpFuncs))
@@ -244,6 +248,9 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
244248
xlog.Error("Failed to parse MCP config", "error", mcpErr)
245249
}
246250
}
251+
if err := middleware.CompressChatRequest(c, compressor); err != nil {
252+
return err
253+
}
247254

248255
xlog.Debug("Tool call routing decision",
249256
"shouldUseFn", shouldUseFn,
@@ -401,9 +408,17 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
401408

402409
for mcpStreamIter := 0; mcpStreamIter <= mcpStreamMaxIterations; mcpStreamIter++ {
403410
// Re-template on MCP iterations
404-
if mcpStreamIter > 0 && !config.TemplateConfig.UseTokenizerTemplate {
405-
predInput = evaluator.TemplateMessages(*input, input.Messages, config, funcs, shouldUseFn)
406-
xlog.Debug("MCP stream re-templating", "iteration", mcpStreamIter)
411+
if mcpStreamIter > 0 {
412+
if err := middleware.CompressChatRequest(c, compressor); err != nil {
413+
fmt.Fprintf(c.Response().Writer, "data: {\"error\":{\"message\":%q,\"type\":\"context_compression_error\"}}\n\n", err.Error())
414+
fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
415+
c.Response().Flush()
416+
return nil
417+
}
418+
if !config.TemplateConfig.UseTokenizerTemplate {
419+
predInput = evaluator.TemplateMessages(*input, input.Messages, config, funcs, shouldUseFn)
420+
xlog.Debug("MCP stream re-templating", "iteration", mcpStreamIter)
421+
}
407422
}
408423

409424
responses := make(chan schema.OpenAIResponse)
@@ -658,6 +673,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
658673
// tools-path worker not surfacing this value at all.
659674
if input.StreamOptions != nil && input.StreamOptions.IncludeUsage {
660675
trailerUsage := streamUsageFromTokenUsage(finalUsage, extraUsage)
676+
trailerUsage.CompressionMeta = middleware.CompressionMetadata(c)
661677
trailer := streamUsageTrailerJSON(id, input.Model, created, trailerUsage)
662678
_, _ = fmt.Fprintf(c.Response().Writer, "data: %s\n\n", trailer)
663679
}
@@ -683,9 +699,14 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
683699

684700
for mcpIteration := 0; mcpIteration <= mcpMaxIterations; mcpIteration++ {
685701
// Re-template on each MCP iteration since messages may have changed
686-
if mcpIteration > 0 && !config.TemplateConfig.UseTokenizerTemplate {
687-
predInput = evaluator.TemplateMessages(*input, input.Messages, config, funcs, shouldUseFn)
688-
xlog.Debug("MCP re-templating", "iteration", mcpIteration, "prompt_len", len(predInput))
702+
if mcpIteration > 0 {
703+
if err := middleware.CompressChatRequest(c, compressor); err != nil {
704+
return err
705+
}
706+
if !config.TemplateConfig.UseTokenizerTemplate {
707+
predInput = evaluator.TemplateMessages(*input, input.Messages, config, funcs, shouldUseFn)
708+
xlog.Debug("MCP re-templating", "iteration", mcpIteration, "prompt_len", len(predInput))
709+
}
689710
}
690711

691712
// Detect if thinking token is already in prompt or template
@@ -1010,6 +1031,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
10101031
usage.TimingTokenGeneration = tokenUsage.TimingTokenGeneration
10111032
usage.TimingPromptProcessing = tokenUsage.TimingPromptProcessing
10121033
}
1034+
usage.CompressionMeta = middleware.CompressionMetadata(c)
10131035

10141036
resp := &schema.OpenAIResponse{
10151037
ID: id,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package middleware
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
"github.com/labstack/echo/v4"
8+
"github.com/mudler/LocalAI/core/config"
9+
"github.com/mudler/LocalAI/core/schema"
10+
compressionservice "github.com/mudler/LocalAI/core/services/compression"
11+
"github.com/mudler/LocalAI/pkg/tokens"
12+
)
13+
14+
const contextKeyCompressionMetadata = "COMPRESSION_METADATA"
15+
16+
type ChatCompressor interface {
17+
Transform(context.Context, config.CompressionConfig, int, string, []schema.Message) ([]schema.Message, *compressionservice.Metadata, error)
18+
}
19+
20+
func ContextCompression(compressor ChatCompressor) echo.MiddlewareFunc {
21+
return func(next echo.HandlerFunc) echo.HandlerFunc {
22+
return func(c echo.Context) error {
23+
if err := CompressChatRequest(c, compressor); err != nil {
24+
return err
25+
}
26+
return next(c)
27+
}
28+
}
29+
}
30+
31+
func CompressChatRequest(c echo.Context, compressor ChatCompressor) error {
32+
cfg, ok := c.Get(CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
33+
if !ok || cfg == nil || !cfg.Compression.Enabled {
34+
return nil
35+
}
36+
if cfg.IsCloudProxyBackendPassthrough() {
37+
return echo.NewHTTPError(http.StatusBadRequest, "context compression is not supported by cloud-proxy passthrough models; configure translate mode or a local compressor model")
38+
}
39+
input, ok := c.Get(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenAIRequest)
40+
if !ok {
41+
return echo.NewHTTPError(http.StatusBadRequest, "context compression requires a chat request")
42+
}
43+
contextSize := config.DefaultContextSize
44+
if cfg.ContextSize != nil && *cfg.ContextSize > 0 {
45+
contextSize = *cfg.ContextSize
46+
}
47+
extraPayload := make(map[string]any)
48+
if len(input.Functions) > 0 {
49+
extraPayload["functions"] = input.Functions
50+
}
51+
if len(input.Tools) > 0 {
52+
extraPayload["tools"] = input.Tools
53+
}
54+
if input.FunctionCall != nil {
55+
extraPayload["function_call"] = input.FunctionCall
56+
}
57+
if input.ToolsChoice != nil {
58+
extraPayload["tool_choice"] = input.ToolsChoice
59+
}
60+
if input.ResponseFormat != nil {
61+
extraPayload["response_format"] = input.ResponseFormat
62+
}
63+
requestOverhead, err := tokens.CountPayload(extraPayload)
64+
if err != nil {
65+
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
66+
}
67+
if cfg.Maxtokens != nil && *cfg.Maxtokens > 0 {
68+
requestOverhead += *cfg.Maxtokens
69+
}
70+
messages, meta, err := compressor.Transform(c.Request().Context(), cfg.Compression, contextSize-requestOverhead, cfg.ModelID(), input.Messages)
71+
if err != nil {
72+
if compressionservice.IsOverflow(err) {
73+
return echo.NewHTTPError(http.StatusRequestEntityTooLarge, err.Error())
74+
}
75+
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
76+
}
77+
input.Messages = messages
78+
if meta != nil {
79+
meta.OriginalTokens += requestOverhead
80+
meta.CompressedTokens += requestOverhead
81+
if previous, ok := c.Get(contextKeyCompressionMetadata).(*compressionservice.Metadata); ok && previous != nil {
82+
meta.OriginalTokens = previous.OriginalTokens
83+
meta.DroppedTurns += previous.DroppedTurns
84+
meta.SummaryTokens += previous.SummaryTokens
85+
meta.OverflowRecoveries += previous.OverflowRecoveries
86+
}
87+
c.Set(contextKeyCompressionMetadata, meta)
88+
}
89+
return nil
90+
}
91+
92+
func CompressionMetadata(c echo.Context) *schema.CompressionMetadata {
93+
meta, ok := c.Get(contextKeyCompressionMetadata).(*compressionservice.Metadata)
94+
if !ok || meta == nil {
95+
return nil
96+
}
97+
return &schema.CompressionMetadata{
98+
OriginalTokens: meta.OriginalTokens, CompressedTokens: meta.CompressedTokens,
99+
DroppedTurns: meta.DroppedTurns, Compressor: meta.Compressor,
100+
SummaryTokens: meta.SummaryTokens, OverflowRecoveries: meta.OverflowRecoveries,
101+
}
102+
}

0 commit comments

Comments
 (0)