mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 03:20:12 -04:00
* 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>
305 lines
9.6 KiB
Go
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...)
|
|
}
|