Skip to content

Commit f25dc5a

Browse files
committed
fix(realtime): harden classifier slot completion
Reserve context for constrained slot filling, size completions from their encoded output, and encode enum grammar literals as valid JSON. Reject empty enum values and cover the failure modes with regression tests. Assisted-by: Codex:gpt-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent f59afbd commit f25dc5a

8 files changed

Lines changed: 121 additions & 5 deletions

File tree

core/http/endpoints/openai/realtime_classifier.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,8 @@ func slotFillGrammar(slots []types.ClassifierSlot) string {
429429
if vi > 0 {
430430
rules.WriteString(" | ")
431431
}
432-
rules.WriteString(gbnfLiteral(`"` + v + `"`))
432+
encoded, _ := json.Marshal(v) // validation rejects values JSON cannot encode
433+
rules.WriteString(gbnfLiteral(string(encoded)))
433434
}
434435
default: // string
435436
rules.WriteString("str")
@@ -446,6 +447,57 @@ func slotFillGrammar(slots []types.ClassifierSlot) string {
446447
return root.String() + rules.String()
447448
}
448449

450+
const (
451+
// Free-form values need an explicit ceiling; forced enum values and field
452+
// syntax are budgeted from their actual JSON encoding below.
453+
slotFillStringTokens = 64
454+
slotFillNumberTokens = 32
455+
)
456+
457+
// slotFillMaxTokens conservatively budgets one token per output byte for the
458+
// forced JSON tail, plus explicit allowances for free-form values. This avoids
459+
// truncating long enum values or field names while keeping string generation
460+
// bounded.
461+
func slotFillMaxTokens(slots []types.ClassifierSlot) int {
462+
tokens := 1 // closing brace
463+
for i := range slots {
464+
if i > 0 {
465+
field, _ := json.Marshal(slots[i].Name)
466+
tokens += len(field) + len(`, : `)
467+
}
468+
switch slots[i].Type {
469+
case types.ClassifierSlotNumber:
470+
tokens += slotFillNumberTokens
471+
case types.ClassifierSlotString:
472+
tokens += slotFillStringTokens
473+
case types.ClassifierSlotEnum:
474+
longest := 0
475+
for _, value := range slots[i].Values {
476+
encoded, _ := json.Marshal(value)
477+
if len(encoded) > longest {
478+
longest = len(encoded)
479+
}
480+
}
481+
tokens += longest
482+
}
483+
}
484+
return tokens
485+
}
486+
487+
// slotFillContextReserve includes both the generated tail and the continuation
488+
// prefix appended after the scored prompt. It intentionally over-reserves by
489+
// counting bytes as tokens; preserving the identical scoring prompt is more
490+
// important than reclaiming a handful of context tokens.
491+
func slotFillContextReserve(option *types.ClassifierOption) int {
492+
if option == nil || option.Tool == nil || len(option.Tool.Slots) == 0 {
493+
return 0
494+
}
495+
route, _ := json.Marshal(option.ID)
496+
field, _ := json.Marshal(option.Tool.Slots[0].Name)
497+
prefixBytes := len(`{"route": , : `) + len(route) + len(field)
498+
return prefixBytes + slotFillMaxTokens(option.Tool.Slots)
499+
}
500+
449501
// parseSlotValues closes the completed route JSON and extracts each slot's
450502
// value as the string form SpliceArguments expects.
451503
func parseSlotValues(chosenID, firstSlot, generated string, slots []types.ClassifierSlot) (map[string]string, error) {

core/http/endpoints/openai/realtime_classifier_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"strings"
78

89
"github.com/mudler/LocalAI/core/config"
910
"github.com/mudler/LocalAI/core/http/endpoints/openai/types"
@@ -539,6 +540,24 @@ var _ = Describe("slotFillGrammar", func() {
539540
Expect(g).To(ContainSubstring("num ::="))
540541
})
541542

543+
It("JSON-encodes enum values before embedding them in the grammar", func() {
544+
g := slotFillGrammar([]types.ClassifierSlot{
545+
{Name: "units", Type: types.ClassifierSlotEnum, Values: []string{"quoted\"value", "line\nbreak", `back\slash`}},
546+
})
547+
Expect(g).To(ContainSubstring(gbnfLiteral(`"quoted\"value"`)))
548+
Expect(g).To(ContainSubstring(gbnfLiteral(`"line\nbreak"`)))
549+
Expect(g).To(ContainSubstring(gbnfLiteral(`"back\\slash"`)))
550+
})
551+
552+
It("budgets forced enum and field text by encoded length", func() {
553+
short := []types.ClassifierSlot{{Name: "value", Type: types.ClassifierSlotEnum, Values: []string{"m"}}}
554+
long := []types.ClassifierSlot{
555+
{Name: "value", Type: types.ClassifierSlotEnum, Values: []string{strings.Repeat("long-value-", 20)}},
556+
{Name: strings.Repeat("field", 20), Type: types.ClassifierSlotNumber},
557+
}
558+
Expect(slotFillMaxTokens(long)).To(BeNumerically(">", slotFillMaxTokens(short)+200))
559+
})
560+
542561
It("emits a string rule only when needed", func() {
543562
g := slotFillGrammar([]types.ClassifierSlot{{Name: "what", Type: types.ClassifierSlotString}})
544563
Expect(g).To(ContainSubstring("slot0 ::= str"))

core/http/endpoints/openai/realtime_model.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,18 @@ func (m *wrappedModel) classifierFor(options []types.ClassifierOption, normaliza
456456
CacheCap: 0,
457457
Normalization: normalization,
458458
}
459+
if m.routerDeps != nil && m.routerDeps.TokenCounter != nil && cfg.ContextSize != nil {
460+
opts.TokenCounter = m.routerDeps.TokenCounter(cfg.Name)
461+
opts.MaxContextTokens = *cfg.ContextSize
462+
}
463+
for i := range options {
464+
if options[i].Tool != nil && len(options[i].Tool.Slots) > 0 {
465+
reserve := slotFillContextReserve(&options[i])
466+
if reserve > opts.CompletionReserveTokens {
467+
opts.CompletionReserveTokens = reserve
468+
}
469+
}
470+
}
459471
if m.evaluator != nil {
460472
if renderer := middleware.NewTemplateRenderer(m.evaluator, cfg); renderer != nil {
461473
opts.PromptRenderer = renderer
@@ -522,7 +534,7 @@ func (m *wrappedModel) FillToolArguments(ctx context.Context, messages schema.Me
522534
return "", nil, fmt.Errorf("classifier: slot filling requires completion in the scoring model's known_usecases")
523535
}
524536
cfg.Grammar = slotFillGrammar(slots)
525-
maxTokens := 16 + 16*len(slots)
537+
maxTokens := slotFillMaxTokens(slots)
526538
temperature := 0.0
527539
cfg.Maxtokens = &maxTokens
528540
cfg.Temperature = &temperature

core/http/endpoints/openai/types/classifier.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,9 @@ func validateSlots(t *ClassifierTool) error {
390390
if len(s.Values) == 0 {
391391
return fmt.Errorf("slot %q: enum slots need values", s.Name)
392392
}
393+
if slices.Contains(s.Values, "") {
394+
return fmt.Errorf("slot %q: enum values must be non-empty", s.Name)
395+
}
393396
if s.Default != "" && !slices.Contains(s.Values, s.Default) {
394397
return fmt.Errorf("slot %q: default %q is not one of its values", s.Name, s.Default)
395398
}

core/http/endpoints/openai/types/classifier_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,12 @@ var _ = Describe("ClassifierTool slots", func() {
228228
Expect(cfgWith(tool(bad, enumSlot)).Validate()).To(MatchError(ContainSubstring("does not parse")))
229229
})
230230

231+
It("rejects empty enum values that cannot be spliced", func() {
232+
bad := enumSlot
233+
bad.Values = []string{"m", ""}
234+
Expect(cfgWith(tool(numberSlot, bad)).Validate()).To(MatchError(ContainSubstring("must be non-empty")))
235+
})
236+
231237
It("rejects slots the template never references", func() {
232238
t := tool(numberSlot, enumSlot, types.ClassifierSlot{Name: "speed", Type: types.ClassifierSlotNumber})
233239
Expect(cfgWith(t).Validate()).To(MatchError(ContainSubstring("{{speed}}")))

core/services/routing/router/score.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ type ScoreClassifierOptions struct {
9898
// sends Probe.Prompt as-is and relies on the backend's n_ctx guard.
9999
TokenCounter func(string) (int, error)
100100
MaxContextTokens int
101+
102+
// CompletionReserveTokens reserves additional context beyond the longest
103+
// scoring candidate. Classifier slot filling uses this to ensure the prompt
104+
// scored here can be continued without overflowing the model context.
105+
CompletionReserveTokens int
101106
}
102107

103108
// ScoreClassifier scores every policy label as the model's actual
@@ -202,8 +207,13 @@ func NewScoreClassifier(policies []ScorePolicy, scorer backend.Scorer, opts Scor
202207
systemPrompt: systemPrompt,
203208
labelOrder: labels,
204209
candidates: candidates,
205-
budget: &lazyBudget{tokenize: opts.TokenCounter, maxContext: opts.MaxContextTokens, extras: candidates},
206-
cache: newLabelSetCache(opts.CacheCap),
210+
budget: &lazyBudget{
211+
tokenize: opts.TokenCounter,
212+
maxContext: opts.MaxContextTokens,
213+
extras: candidates,
214+
reserve: opts.CompletionReserveTokens,
215+
},
216+
cache: newLabelSetCache(opts.CacheCap),
207217
}
208218
}
209219

core/services/routing/router/score_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,19 @@ var _ = Describe("ScoreClassifier conversation trimming", func() {
368368
Expect(len(strings.Fields(s.lastP))).To(BeNumerically("<", 20000), "must be trimmed, not the full transcript")
369369
})
370370

371+
It("reserves context for a completion after scoring", func() {
372+
without := NewScoreClassifier(testPolicies(), &stubScorer{}, ScoreClassifierOptions{
373+
TokenCounter: wordCount,
374+
MaxContextTokens: 10000,
375+
})
376+
with := NewScoreClassifier(testPolicies(), &stubScorer{}, ScoreClassifierOptions{
377+
TokenCounter: wordCount,
378+
MaxContextTokens: 10000,
379+
CompletionReserveTokens: 257,
380+
})
381+
Expect(with.probeTokenBudget()).To(Equal(without.probeTokenBudget() - 257))
382+
})
383+
371384
It("keeps the newest turn whole even when it alone exceeds the budget", func() {
372385
s := &stubScorer{results: threeScores}
373386
c := NewScoreClassifier(testPolicies(), s, ScoreClassifierOptions{

core/services/routing/router/trim.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ type lazyBudget struct {
123123
tokenize func(string) (int, error)
124124
maxContext int
125125
extras []string
126+
reserve int
126127

127128
mu sync.Mutex
128129
value atomic.Int64 // 0=unset, >0=budget, -1=disabled
@@ -156,7 +157,7 @@ func (l *lazyBudget) get() int {
156157
longest = n
157158
}
158159
}
159-
b := l.maxContext - longest - tokenBudgetMargin
160+
b := l.maxContext - longest - l.reserve - tokenBudgetMargin
160161
if b <= 0 {
161162
l.value.Store(-1)
162163
return 0

0 commit comments

Comments
 (0)