mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-12 02:38:19 -04:00
* fix(grammars): honor properties_order entry at index 0 The JSON-schema-to-GBNF property sort used `aOrder != 0 && bOrder != 0` as its "is this key ordered?" guard. That treats index 0 — the first key listed in properties_order — as unset, so `properties_order: name,arguments` fell back to alphabetical ordering and still emitted "arguments" before "name". Use presence in the order map instead: listed keys sort by their index and ahead of unlisted keys, which keep a stable alphabetical order. This makes the documented `properties_order: name,arguments` actually produce name-first tool-call JSON. Relates to #10052. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * fix(functions): defer tool grammar to the backend when the tokenizer template owns templating (#10052) When use_tokenizer_template delegates templating to the backend (llama.cpp), the backend also owns tool-call grammar generation and parsing. LocalAI was still generating its own GBNF grammar and sending it down. With a grammar present, llama.cpp does not hand the tools to its template, so its native peg/json tool parser never engages: it streams the grammar-constrained tool-call JSON back as plain content instead of emitting tool_calls. In streaming mode the JSON object leaked into the content field, and the Go-side incremental detector never gated content because the LocalAI-generated grammar emitted "arguments" before "name". The GGUF auto-import path already couples use_tokenizer_template with grammar.disable, but that block is skipped when a template is already configured, so gallery and hand-written configs (e.g. qwen3) that set the tokenizer template directly never got the paired grammar.disable. - SetDefaults now enforces the coupling for every config: when use_tokenizer_template is set, grammar generation is disabled and tools flow to the backend's native (name-first) pipeline. This also fixes already-installed models without editing each config. - Set function.grammar.disable in the shared gallery/qwen3.yaml, which is the base config referenced by every qwen3 gallery entry. Verified end to end against qwen3-4b with stream:true + tools: content no longer carries the tool-call JSON, reasoning is classified separately, and tool calls stream as proper name-first tool_calls deltas. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
273 lines
7.9 KiB
Go
273 lines
7.9 KiB
Go
package grammars
|
|
|
|
// a golang port of https://github.com/ggerganov/llama.cpp/pull/1887
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
type JSONSchemaConverter struct {
|
|
propOrder map[string]int
|
|
rules Rules
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 {
|
|
referencedSchema, err := sc.resolveReference(ref, rootSchema)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return sc.visit(referencedSchema, name, rootSchema)
|
|
} 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...)
|
|
}
|