fix(ollama): cap num_ctx so it cannot wrap negative when cast to int32 (#11032)

* fix(ollama): cap num_ctx so it cannot wrap negative when cast to int32

applyOllamaOptions copied a client-supplied options.num_ctx straight into
cfg.ContextSize with only a > 0 check. That value is later cast to int32
before it reaches the backend (core/backend/options.go), so a num_ctx
above math.MaxInt32 silently wrapped into a negative context size that
was then sent to the LoadModel gRPC call. Both /api/chat and /api/generate
share applyOllamaOptions, so both endpoints were affected.

Cap num_ctx at math.MaxInt32 so the later cast stays positive, and add
internal regression coverage for the overflow, in-range, and unset cases.

num_ctx remains an intentional user override, so this does not re-impose
the hardware-aware auto context clamp; that policy choice is left to
maintainers.

Fixes #11022

Signed-off-by: Tai An <antai12232931@outlook.com>

* fix(ollama): clamp num_ctx to model context ceiling, not just int32

Per review on #11032: capping only at math.MaxInt32 still let an
unauthenticated request replace the hardware/model-derived context
limit with ~2.1B tokens, so a real backend could attempt a catastrophic
KV-cache allocation. Treat any existing positive cfg.ContextSize as the
server ceiling and clamp num_ctx down to it (smaller values still
honored), while retaining the int32-safe bound when no smaller ceiling
exists. Shared by /api/chat and /api/generate via applyOllamaOptions.

Add regression coverage proving num_ctx=2,000,000,000 cannot replace an
existing 4096/8192 ceiling.

Signed-off-by: Tai An <antai12232931@outlook.com>

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
This commit is contained in:
Tai An
2026-07-22 00:28:02 -07:00
committed by GitHub
parent 48b7d6d8fd
commit bf19758e05
2 changed files with 86 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ package ollama
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/config"
@@ -57,7 +58,25 @@ func applyOllamaOptions(opts *schema.OllamaOptions, cfg *config.ModelConfig) {
cfg.StopWords = append(cfg.StopWords, opts.Stop...) cfg.StopWords = append(cfg.StopWords, opts.Stop...)
} }
if opts.NumCtx > 0 { if opts.NumCtx > 0 {
cfg.ContextSize = &opts.NumCtx numCtx := opts.NumCtx
// The model configuration / hardware-aware defaults have already
// populated cfg.ContextSize by the time we get here. Treat any
// existing positive value as the server-side ceiling: an
// unauthenticated client must never be able to *raise* the context
// window, since that drives KV-cache allocation and an oversized
// value (e.g. 2,000,000,000) can trigger a catastrophic OOM. A
// smaller num_ctx is still honored. See issue #11022.
if cfg.ContextSize != nil && *cfg.ContextSize > 0 && numCtx > *cfg.ContextSize {
numCtx = *cfg.ContextSize
}
// Regardless of the ceiling, keep the value int32-safe: ContextSize is
// cast to int32 before it reaches the backend (core/backend/options.go),
// so a value above math.MaxInt32 would silently wrap into a negative
// context size when no smaller ceiling exists.
if numCtx > math.MaxInt32 {
numCtx = math.MaxInt32
}
cfg.ContextSize = &numCtx
} }
} }
@@ -80,4 +99,4 @@ func ollamaMessagesToOpenAI(messages []schema.OllamaMessage) []schema.Message {
result = append(result, openAIMsg) result = append(result, openAIMsg)
} }
return result return result
} }

View File

@@ -0,0 +1,65 @@
package ollama
import (
"math"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// These specs pin the num_ctx handling for issue #11022. /api/chat and
// /api/generate share applyOllamaOptions, so exercising the helper covers both
// endpoints. A client-supplied options.num_ctx must not be able to *raise* the
// model/hardware-derived context ceiling already in cfg.ContextSize (that value
// drives KV-cache allocation, so an oversized request is a DoS), and it must
// stay int32-safe because ContextSize is cast to int32 before it reaches the
// backend, where an out-of-range value would silently wrap negative.
var _ = Describe("applyOllamaOptions num_ctx clamping (issue #11022)", func() {
It("caps num_ctx at math.MaxInt32 so the later int32 cast cannot wrap negative", func() {
cfg := &config.ModelConfig{}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: math.MaxInt32 + 1}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(math.MaxInt32))
Expect(int32(*cfg.ContextSize)).To(BeNumerically(">", 0),
"capped context size must stay positive after the int32 cast")
})
It("does not let an oversized num_ctx raise an existing context ceiling", func() {
for _, ceiling := range []int{4096, 8192} {
existing := ceiling
cfg := &config.ModelConfig{ContextSize: &existing}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: 2000000000}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(ceiling),
"an in-range but oversized num_ctx must be clamped to the server ceiling, not replace it")
}
})
It("still honors a num_ctx smaller than the existing ceiling", func() {
existing := 8192
cfg := &config.ModelConfig{ContextSize: &existing}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: 2048}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(2048))
})
It("passes an in-range num_ctx through unchanged", func() {
cfg := &config.ModelConfig{}
applyOllamaOptions(&schema.OllamaOptions{NumCtx: 4096}, cfg)
Expect(cfg.ContextSize).ToNot(BeNil())
Expect(*cfg.ContextSize).To(Equal(4096))
})
It("leaves ContextSize untouched when num_ctx is not set", func() {
cfg := &config.ModelConfig{}
applyOllamaOptions(&schema.OllamaOptions{}, cfg)
Expect(cfg.ContextSize).To(BeNil())
})
})