Files
LocalAI/pkg/functions/grammars/json_schema.go
Tai An 37f2087f97 fix(grammars): reject cyclic $ref in JSON-schema grammar to prevent stack-overflow crash (#11020) (#11041)
* fix(grammars): reject cyclic $ref in JSON-schema grammar to prevent stack-overflow crash

JSONSchemaConverter.visit resolved $ref entries by recursively calling
itself with no cycle detection. A client-supplied grammar_json_functions
schema whose $defs contains a self- or mutually-referential $ref (e.g.
{"A": {"$ref": "#/$defs/A"}}) made visit recurse until the goroutine
stack was exhausted, producing a fatal "stack overflow" that kills the
whole process rather than failing the single request. The schema is
converted synchronously in the /v1/chat/completions handler before any
backend call, so this is an unauthenticated remote crash. Fixes #11020.

Track the $ref targets currently on the recursion stack and error out
when one is re-entered, while popping after each descent so sibling
(non-cyclic) reuse of the same $ref is still allowed.

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

* fix(grammars): add a bounded recursion depth and cover llama31 $ref cycles

Addresses the review on #11041. The stack-set approach catches cyclic
$ref chains, but a deeply nested yet acyclic client schema (thousands of
nested arrays/objects) can still recurse through visit until the
goroutine stack is exhausted, which is the same unauthenticated remote
crash surface as #11020.

- Add a bounded depth counter to JSONSchemaConverter.visit (incremented
  with a defer-based cleanup, capped at maxSchemaDepth = 256, far above
  any realistic schema) so an over-deep schema fails the request with an
  ordinary error instead of crashing the process.
- Apply the same cyclic-$ref guard and depth bound to
  LLama31SchemaConverter.visit, the other production grammar entry point
  named in #11020, which previously had no cycle detection at all.
- Regression tests: a deeply nested acyclic schema is rejected while a
  moderately nested one still builds, plus direct/indirect $ref cycle
  and depth tests for the llama31 converter.

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

* test(grammars): make llama31 cycle fixtures valid function-call shapes

The two new llama31 $ref-cycle specs asserted on "cyclic $ref" but the
converter requires each top-level oneOf alternative to carry its
function-name property before descending, so both fixtures failed
earlier with "no function name found in the schema" and never reached
the cycle guard.

Give each fixture a valid llama31 shape: construct the converter with
NewLLama31SchemaConverter("function"), put "function": {"const": "test"}
on the top-level alternative, and hang the cyclic $ref under an
arguments property, so all 29 grammar specs pass and the assertions
genuinely observe the cyclic $ref error.

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

---------

Signed-off-by: Tai An <antai12232931@outlook.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-07-30 12:06:17 +02:00

305 lines
9.6 KiB
Go

package grammars
// a golang port of https://github.com/ggerganov/llama.cpp/pull/1887
import (
"cmp"
"encoding/json"
"fmt"
"slices"
"strings"
)
// maxSchemaDepth bounds how deeply visit may recurse into a client-supplied
// schema. A cyclic $ref is caught by refsInProgress, but a deeply nested yet
// acyclic schema (e.g. thousands of nested arrays/objects) could still recurse
// until the goroutine stack is exhausted and crash the whole process. Rejecting
// the request once this depth is exceeded turns that potential crash into an
// ordinary per-request error. The limit is far above any realistic schema.
const maxSchemaDepth = 256
type JSONSchemaConverter struct {
propOrder map[string]int
rules Rules
// refsInProgress tracks the $ref targets currently on the recursion stack
// so a self- or mutually-referential schema cannot recurse forever. It is
// pushed before descending into a referenced schema and popped afterwards,
// so sibling (non-cyclic) reuse of the same $ref is still allowed.
refsInProgress map[string]bool
// depth is the current recursion depth of visit, bounded by maxSchemaDepth
// to guard against stack exhaustion on deeply nested acyclic schemas.
depth int
}
func NewJSONSchemaConverter(propOrder string) *JSONSchemaConverter {
propOrderSlice := strings.Split(propOrder, ",")
propOrderMap := make(map[string]int)
for idx, name := range propOrderSlice {
propOrderMap[name] = idx
}
rules := make(map[string]string)
rules["space"] = SPACE_RULE
return &JSONSchemaConverter{
propOrder: propOrderMap,
rules: rules,
refsInProgress: make(map[string]bool),
}
}
func (sc *JSONSchemaConverter) formatLiteral(literal any) (string, error) {
jLiteral, err := jsonString(literal)
if err != nil {
return "", err
}
escaped := GRAMMAR_LITERAL_ESCAPE_RE.ReplaceAllStringFunc(jLiteral, func(match string) string {
return GRAMMAR_LITERAL_ESCAPES[match]
})
return fmt.Sprintf(`"%s"`, escaped), nil
}
func (sc *JSONSchemaConverter) addRule(name, rule string) string {
escName := INVALID_RULE_CHARS_RE.ReplaceAllString(name, "-")
key := escName
if existingRule, ok := sc.rules[escName]; ok && existingRule != rule {
i := 0
for {
key = fmt.Sprintf("%s%d", escName, i)
if _, ok := sc.rules[key]; !ok {
break
}
i++
}
}
sc.rules[key] = rule
return key
}
func (sc *JSONSchemaConverter) visit(schema map[string]any, name string, rootSchema map[string]any) (string, error) {
sc.depth++
defer func() { sc.depth-- }()
if sc.depth > maxSchemaDepth {
return "", fmt.Errorf("schema nesting exceeds maximum depth %d while building grammar", maxSchemaDepth)
}
st, existType := schema["type"]
var schemaType string
var schemaTypes []string
if existType {
// Handle both single type strings and arrays of types (e.g., ["string", "null"])
switch v := st.(type) {
case string:
// Single type: "type": "string"
schemaType = v
schemaTypes = []string{v}
case []any:
// Multiple types: "type": ["string", "null"]
for _, item := range v {
if typeStr, ok := item.(string); ok {
schemaTypes = append(schemaTypes, typeStr)
}
}
// Use the first type as the primary schema type for compatibility
if len(schemaTypes) > 0 {
schemaType = schemaTypes[0]
}
}
}
ruleName := name
if name == "" {
ruleName = "root"
}
_, oneOfExists := schema["oneOf"]
_, anyOfExists := schema["anyOf"]
if oneOfExists || anyOfExists {
var alternatives []string
oneOfSchemas, oneOfExists := schema["oneOf"].([]any)
anyOfSchemas, anyOfExists := schema["anyOf"].([]any)
if oneOfExists {
for i, altSchema := range oneOfSchemas {
alternative, err := sc.visit(altSchema.(map[string]any), fmt.Sprintf("%s-%d", ruleName, i), rootSchema)
if err != nil {
return "", err
}
alternatives = append(alternatives, alternative)
}
} else if anyOfExists {
for i, altSchema := range anyOfSchemas {
alternative, err := sc.visit(altSchema.(map[string]any), fmt.Sprintf("%s-%d", ruleName, i), rootSchema)
if err != nil {
return "", err
}
alternatives = append(alternatives, alternative)
}
}
rule := strings.Join(alternatives, " | ")
return sc.addRule(ruleName, rule), nil
} else if ref, exists := schema["$ref"].(string); exists {
// A client-supplied schema may contain a cyclic $ref (e.g. a $def that
// references itself directly or through a chain). Without this guard the
// recursion below never terminates and exhausts the goroutine stack,
// crashing the whole process rather than just failing the request.
if sc.refsInProgress[ref] {
return "", fmt.Errorf("cyclic $ref detected while building grammar: %s", ref)
}
referencedSchema, err := sc.resolveReference(ref, rootSchema)
if err != nil {
return "", err
}
sc.refsInProgress[ref] = true
result, err := sc.visit(referencedSchema, name, rootSchema)
delete(sc.refsInProgress, ref)
return result, err
} else if constVal, exists := schema["const"]; exists {
literal, err := sc.formatLiteral((constVal))
if err != nil {
return "", err
}
return sc.addRule(ruleName, literal), nil
} else if enumVals, exists := schema["enum"].([]any); exists {
var enumRules []string
for _, enumVal := range enumVals {
enumRule, err := sc.formatLiteral(enumVal)
if err != nil {
return "", err
}
enumRules = append(enumRules, enumRule)
}
rule := strings.Join(enumRules, " | ")
return sc.addRule(ruleName, rule), nil
} else if properties, exists := schema["properties"].(map[string]any); schemaType == "object" && exists {
propOrder := sc.propOrder
var propPairs []struct {
propName string
propSchema map[string]any
}
for propName, propSchema := range properties {
propPairs = append(propPairs, struct {
propName string
propSchema map[string]any
}{propName: propName, propSchema: propSchema.(map[string]any)})
}
slices.SortFunc(propPairs, func(a, b struct {
propName string
propSchema map[string]any
}) int {
// Use presence in the order map (not a non-zero sentinel) so that
// the first listed key — index 0 — is honored. Keys present in
// properties_order sort by their index and ahead of any key that
// isn't listed; unlisted keys keep a stable alphabetical order.
aOrder, aOK := propOrder[a.propName]
bOrder, bOK := propOrder[b.propName]
switch {
case aOK && bOK:
return cmp.Compare(aOrder, bOrder)
case aOK:
return -1
case bOK:
return 1
default:
return cmp.Compare(a.propName, b.propName)
}
})
var rule strings.Builder
rule.WriteString(`"{" space`)
for i, propPair := range propPairs {
propName := propPair.propName
propSchema := propPair.propSchema
propRuleName, err := sc.visit(propSchema, fmt.Sprintf("%s-%s", ruleName, propName), rootSchema)
if err != nil {
return "", err
}
lPropName, err := sc.formatLiteral(propName)
if err != nil {
return "", err
}
if i > 0 {
rule.WriteString(` "," space`)
}
rule.WriteString(fmt.Sprintf(` %s space ":" space %s`, lPropName, propRuleName))
}
rule.WriteString(` "}" space`)
return sc.addRule(ruleName, rule.String()), nil
} else if items, exists := schema["items"].(map[string]any); schemaType == "array" && exists {
itemRuleName, err := sc.visit(items, fmt.Sprintf("%s-item", ruleName), rootSchema)
if err != nil {
return "", err
}
rule := fmt.Sprintf(`"[" space (%s ("," space %s)*)? "]" space`, itemRuleName, itemRuleName)
return sc.addRule(ruleName, rule), nil
} else if properties, _ := schema["properties"].(map[string]any); (schemaType == "object" || schemaType == "") && len(properties) == 0 {
// Handle empty object schema (no properties)
rule := `"{" space "}" space`
return sc.addRule(ruleName, rule), nil
} else {
// Handle primitive types, including multi-type arrays like ["string", "null"]
if len(schemaTypes) > 1 {
// Generate a union of multiple primitive types
var typeRules []string
for _, t := range schemaTypes {
primitiveRule, exists := PRIMITIVE_RULES[t]
if !exists {
return "", fmt.Errorf("unrecognized type in multi-type schema: %s (schema: %v)", t, schema)
}
typeRules = append(typeRules, primitiveRule)
}
rule := "(" + strings.Join(typeRules, " | ") + ")"
return sc.addRule(ruleName, rule), nil
} else {
// Single type
primitiveRule, exists := PRIMITIVE_RULES[schemaType]
if !exists {
return "", fmt.Errorf("unrecognized schema: %v (type: %s)", schema, schemaType)
}
if ruleName == "root" {
schemaType = "root"
}
return sc.addRule(schemaType, primitiveRule), nil
}
}
}
func (sc *JSONSchemaConverter) resolveReference(ref string, rootSchema map[string]any) (map[string]any, error) {
if !strings.HasPrefix(ref, "#/$defs/") {
return nil, fmt.Errorf("invalid reference format: %s", ref)
}
defKey := strings.TrimPrefix(ref, "#/$defs/")
definitions, exists := rootSchema["$defs"].(map[string]any)
if !exists {
return nil, fmt.Errorf("no definitions found in the schema: %s", rootSchema)
}
def, exists := definitions[defKey].(map[string]any)
if !exists {
return nil, fmt.Errorf("definition not found: %s %+v", defKey, definitions)
}
return def, nil
}
func (sc *JSONSchemaConverter) Grammar(schema map[string]any, options ...func(*GrammarOption)) (string, error) {
sc.addRule("freestring", PRIMITIVE_RULES["freestring"])
_, err := sc.visit(schema, "", schema)
if err != nil {
return "", err
}
return sc.rules.ToGrammar(options...), nil
}
func (sc *JSONSchemaConverter) GrammarFromBytes(b []byte, options ...func(*GrammarOption)) (string, error) {
var schema map[string]any
err := json.Unmarshal(b, &schema)
if err != nil {
return "", err
}
return sc.Grammar(schema, options...)
}