Spaces:
Running
Running
Amlan-109
feat: Initial commit of LocalAI Amlan Edition with premium branding and personalization
750bbe6
| package functions | |
| import ( | |
| "encoding/json" | |
| "errors" | |
| "io" | |
| "regexp" | |
| "slices" | |
| "strings" | |
| "unicode/utf8" | |
| "github.com/mudler/LocalAI/pkg/functions/grammars" | |
| "github.com/mudler/LocalAI/pkg/utils" | |
| "github.com/mudler/xlog" | |
| ) | |
| // @Description GrammarConfig contains configuration for grammar parsing | |
| type GrammarConfig struct { | |
| // ParallelCalls enables the LLM to return multiple function calls in the same response | |
| ParallelCalls bool `yaml:"parallel_calls,omitempty" json:"parallel_calls,omitempty"` | |
| DisableParallelNewLines bool `yaml:"disable_parallel_new_lines,omitempty" json:"disable_parallel_new_lines,omitempty"` | |
| // MixedMode enables the LLM to return strings and not only JSON objects | |
| // This is useful for models to not constraining returning only JSON and also messages back to the user | |
| MixedMode bool `yaml:"mixed_mode,omitempty" json:"mixed_mode,omitempty"` | |
| // NoMixedFreeString disables the mixed mode for free strings | |
| // In this way if the LLM selects a free string, it won't be mixed necessarily with JSON objects. | |
| // For example, if enabled the LLM or returns a JSON object or a free string, but not a mix of both | |
| // If disabled(default): the LLM can return a JSON object surrounded by free strings (e.g. `this is the JSON result: { "bar": "baz" } for your question`). This forces the LLM to return at least a JSON object, but its not going to be strict | |
| NoMixedFreeString bool `yaml:"no_mixed_free_string,omitempty" json:"no_mixed_free_string,omitempty"` | |
| // NoGrammar disables the grammar parsing and parses the responses directly from the LLM | |
| NoGrammar bool `yaml:"disable,omitempty" json:"disable,omitempty"` | |
| // Prefix is the suffix to append to the grammar when being generated | |
| // This is useful when models prepend a tag before returning JSON | |
| Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"` | |
| // ExpectStringsAfterJSON enables mixed string suffix | |
| ExpectStringsAfterJSON bool `yaml:"expect_strings_after_json,omitempty" json:"expect_strings_after_json,omitempty"` | |
| // PropOrder selects what order to print properties | |
| // for instance name,arguments will make print { "name": "foo", "arguments": { "bar": "baz" } } | |
| // instead of { "arguments": { "bar": "baz" }, "name": "foo" } | |
| PropOrder string `yaml:"properties_order,omitempty" json:"properties_order,omitempty"` | |
| // SchemaType can be configured to use a specific schema type to force the grammar | |
| // available : json, llama3.1 | |
| SchemaType string `yaml:"schema_type,omitempty" json:"schema_type,omitempty"` | |
| GrammarTriggers []GrammarTrigger `yaml:"triggers,omitempty" json:"triggers,omitempty"` | |
| } | |
| // @Description GrammarTrigger defines a trigger word for grammar parsing | |
| type GrammarTrigger struct { | |
| // Trigger is the string that triggers the grammar | |
| Word string `yaml:"word,omitempty" json:"word,omitempty"` | |
| } | |
| // @Description FunctionsConfig is the configuration for the tool/function call. | |
| // It includes setting to map the function name and arguments from the response | |
| // and, for instance, also if processing the requests with BNF grammars. | |
| type FunctionsConfig struct { | |
| // DisableNoAction disables the "no action" tool | |
| // By default we inject a tool that does nothing and is used to return an answer from the LLM | |
| DisableNoAction bool `yaml:"disable_no_action,omitempty" json:"disable_no_action,omitempty"` | |
| // Grammar is the configuration for the grammar | |
| GrammarConfig GrammarConfig `yaml:"grammar,omitempty" json:"grammar,omitempty"` | |
| // NoActionFunctionName is the name of the function that does nothing. It defaults to "answer" | |
| NoActionFunctionName string `yaml:"no_action_function_name,omitempty" json:"no_action_function_name,omitempty"` | |
| // NoActionDescriptionName is the name of the function that returns the description of the no action function | |
| NoActionDescriptionName string `yaml:"no_action_description_name,omitempty" json:"no_action_description_name,omitempty"` | |
| // ResponseRegex is a named regex to extract the function name and arguments from the response | |
| ResponseRegex []string `yaml:"response_regex,omitempty" json:"response_regex,omitempty"` | |
| // JSONRegexMatch is a regex to extract the JSON object from the response | |
| JSONRegexMatch []string `yaml:"json_regex_match,omitempty" json:"json_regex_match,omitempty"` | |
| // ArgumentRegex is a named regex to extract the arguments from the response. Use ArgumentRegexKey and ArgumentRegexValue to set the names of the named regex for key and value of the arguments. | |
| ArgumentRegex []string `yaml:"argument_regex,omitempty" json:"argument_regex,omitempty"` | |
| // ArgumentRegex named regex names for key and value extractions. default: key and value | |
| ArgumentRegexKey string `yaml:"argument_regex_key_name,omitempty" json:"argument_regex_key_name,omitempty"` // default: key | |
| ArgumentRegexValue string `yaml:"argument_regex_value_name,omitempty" json:"argument_regex_value_name,omitempty"` // default: value | |
| // ReplaceFunctionResults allow to replace strings in the results before parsing them | |
| ReplaceFunctionResults []ReplaceResult `yaml:"replace_function_results,omitempty" json:"replace_function_results,omitempty"` | |
| // ReplaceLLMResult allow to replace strings in the results before parsing them | |
| ReplaceLLMResult []ReplaceResult `yaml:"replace_llm_results,omitempty" json:"replace_llm_results,omitempty"` | |
| // CaptureLLMResult is a regex to extract a string from the LLM response | |
| // that is used as return string when using tools. | |
| // This is useful for e.g. if the LLM outputs a reasoning and we want to get the reasoning as a string back | |
| CaptureLLMResult []string `yaml:"capture_llm_results,omitempty" json:"capture_llm_results,omitempty"` | |
| // FunctionName enable the LLM to return { "name": "function_name", "arguments": { "arg1": "value1", "arg2": "value2" } } | |
| // instead of { "function": "function_name", "arguments": { "arg1": "value1", "arg2": "value2" } }. | |
| // This might be useful for certain models trained with the function name as the first token. | |
| FunctionNameKey string `yaml:"function_name_key,omitempty" json:"function_name_key,omitempty"` | |
| FunctionArgumentsKey string `yaml:"function_arguments_key,omitempty" json:"function_arguments_key,omitempty"` | |
| // XMLFormatPreset is an optional preset format name to force (e.g., "qwen3-coder", "glm-4.5", "minimax-m2") | |
| // If empty, auto-detection will try all formats | |
| XMLFormatPreset string `yaml:"xml_format_preset,omitempty" json:"xml_format_preset,omitempty"` | |
| // XMLFormat is an optional custom XML format configuration | |
| // If set, only this format will be tried (overrides XMLFormatPreset) | |
| XMLFormat *XMLToolCallFormat `yaml:"xml_format,omitempty" json:"xml_format,omitempty"` | |
| } | |
| // @Description ReplaceResult defines a key-value replacement for function results | |
| type ReplaceResult struct { | |
| Key string `yaml:"key,omitempty" json:"key,omitempty"` | |
| Value string `yaml:"value,omitempty" json:"value,omitempty"` | |
| } | |
| // @Description XMLToolCallFormat defines the structure for parsing XML-style tool calls | |
| // This mirrors llama.cpp's xml_tool_call_format structure | |
| type XMLToolCallFormat struct { | |
| // ScopeStart is the optional wrapper start tag (e.g., "<minimax:tool_call>") | |
| ScopeStart string `yaml:"scope_start,omitempty" json:"scope_start,omitempty"` | |
| // ToolStart is the tool call start tag (e.g., "<tool_call>", "<invoke name=\"") | |
| ToolStart string `yaml:"tool_start,omitempty" json:"tool_start,omitempty"` | |
| // ToolSep is the separator after tool name (e.g., ">", "\">") | |
| ToolSep string `yaml:"tool_sep,omitempty" json:"tool_sep,omitempty"` | |
| // KeyStart is the parameter key start tag (e.g., "<parameter=", "<arg_key>") | |
| KeyStart string `yaml:"key_start,omitempty" json:"key_start,omitempty"` | |
| // KeyValSep is the separator between key and value (e.g., ">", "</arg_key>") | |
| KeyValSep string `yaml:"key_val_sep,omitempty" json:"key_val_sep,omitempty"` | |
| // ValEnd is the parameter value end tag (e.g., "</parameter>", "</arg_value>") | |
| ValEnd string `yaml:"val_end,omitempty" json:"val_end,omitempty"` | |
| // ToolEnd is the tool call end tag (e.g., "</tool_call>", "</invoke>") | |
| ToolEnd string `yaml:"tool_end,omitempty" json:"tool_end,omitempty"` | |
| // ScopeEnd is the optional wrapper end tag (e.g., "</minimax:tool_call>") | |
| ScopeEnd string `yaml:"scope_end,omitempty" json:"scope_end,omitempty"` | |
| // KeyValSep2 is the optional second separator (for GLM 4.5 format: "</arg_key>\n<arg_value>") | |
| KeyValSep2 *string `yaml:"key_val_sep2,omitempty" json:"key_val_sep2,omitempty"` | |
| // RawArgVal indicates whether to treat values as raw strings (true) vs JSON (false), nil means both allowed | |
| RawArgVal *bool `yaml:"raw_argval,omitempty" json:"raw_argval,omitempty"` | |
| // LastValEnd is the alternative value end for last parameter | |
| LastValEnd *string `yaml:"last_val_end,omitempty" json:"last_val_end,omitempty"` | |
| // LastToolEnd is the alternative tool end for last tool call | |
| LastToolEnd *string `yaml:"last_tool_end,omitempty" json:"last_tool_end,omitempty"` | |
| // TrimRawArgVal indicates whether to trim whitespace from raw values | |
| TrimRawArgVal bool `yaml:"trim_raw_argval,omitempty" json:"trim_raw_argval,omitempty"` | |
| // AllowToolcallInThink allows tool calls inside thinking/reasoning blocks | |
| AllowToolcallInThink bool `yaml:"allow_toolcall_in_think,omitempty" json:"allow_toolcall_in_think,omitempty"` | |
| } | |
| type FuncCallResults struct { | |
| Name string | |
| Arguments string | |
| } | |
| func (g FunctionsConfig) GrammarOptions() []func(o *grammars.GrammarOption) { | |
| opts := []func(o *grammars.GrammarOption){} | |
| if g.GrammarConfig.MixedMode { | |
| opts = append(opts, grammars.EnableMaybeString) | |
| } | |
| if g.GrammarConfig.ParallelCalls { | |
| opts = append(opts, grammars.EnableMaybeArray) | |
| } | |
| if g.GrammarConfig.DisableParallelNewLines { | |
| opts = append(opts, grammars.DisableParallelNewLines) | |
| } | |
| if g.GrammarConfig.Prefix != "" { | |
| opts = append(opts, grammars.SetPrefix(g.GrammarConfig.Prefix)) | |
| } | |
| if g.GrammarConfig.NoMixedFreeString { | |
| opts = append(opts, grammars.NoMixedFreeString) | |
| } | |
| if g.GrammarConfig.ExpectStringsAfterJSON { | |
| opts = append(opts, grammars.ExpectStringsAfterJSON) | |
| } | |
| if g.GrammarConfig.SchemaType != "" { | |
| opts = append(opts, grammars.WithSchemaType(grammars.NewType(g.GrammarConfig.SchemaType))) | |
| } | |
| if g.FunctionNameKey != "" { | |
| opts = append(opts, grammars.WithFunctionName(g.FunctionNameKey)) | |
| } | |
| opts = append(opts, grammars.SetPropOrder(g.GrammarConfig.PropOrder)) | |
| return opts | |
| } | |
| func CleanupLLMResult(llmresult string, functionConfig FunctionsConfig) string { | |
| xlog.Debug("LLM result", "result", llmresult) | |
| for _, item := range functionConfig.ReplaceLLMResult { | |
| k, v := item.Key, item.Value | |
| xlog.Debug("Replacing", "key", k, "value", v) | |
| re := regexp.MustCompile(k) | |
| llmresult = re.ReplaceAllString(llmresult, v) | |
| } | |
| xlog.Debug("LLM result(processed)", "result", llmresult) | |
| return llmresult | |
| } | |
| func ParseTextContent(llmresult string, functionConfig FunctionsConfig) string { | |
| xlog.Debug("ParseTextContent", "result", llmresult) | |
| xlog.Debug("CaptureLLMResult", "config", functionConfig.CaptureLLMResult) | |
| for _, r := range functionConfig.CaptureLLMResult { | |
| // We use a regex to extract the JSON object from the response | |
| var respRegex = regexp.MustCompile(r) | |
| match := respRegex.FindStringSubmatch(llmresult) | |
| if len(match) >= 1 { | |
| m := strings.TrimSpace(match[1]) | |
| return m | |
| } | |
| } | |
| return "" | |
| } | |
| // ParseJSON is a function that parses a JSON string that might contain multiple JSON objects | |
| // and syntax errors in between by shifting the offset | |
| // This for e.g. allow to parse | |
| // { "foo": "bar" } invalid { "baz": "qux" } | |
| // into | |
| // [ { "foo": "bar" }, { "baz": "qux" } ] | |
| // Credits to Michael Yang (https://github.com/mxyng) for the original implementation | |
| // This is a slightly reworked version, improved for readability and error handling | |
| // ParseJSON parses JSON objects from a string, supporting multiple JSON objects | |
| // Now defaults to iterative parser for better streaming support | |
| // Falls back to legacy parser if iterative parser fails | |
| func ParseJSON(s string) ([]map[string]any, error) { | |
| // Try iterative parser first (non-partial mode for complete parsing) | |
| results, err := ParseJSONIterative(s, false) | |
| if err == nil && len(results) > 0 { | |
| return results, nil | |
| } | |
| // Fall back to legacy parser for backward compatibility | |
| return parseJSONLegacy(s) | |
| } | |
| // ParseJSONIterative parses JSON using the iterative parser | |
| // Supports partial parsing for streaming scenarios | |
| // Returns objects and arrays (matching llama.cpp behavior) | |
| func ParseJSONIterative(s string, isPartial bool) ([]map[string]any, error) { | |
| parser := NewChatMsgParser(s, isPartial) | |
| var results []map[string]any | |
| // Try to parse JSON values one by one | |
| for parser.Pos() < len(parser.Input()) { | |
| jsonValue, isPartialJSON, _, err := parser.TryConsumeJSON() | |
| if err != nil { | |
| // If it's a partial exception and we're in partial mode, return what we have | |
| if _, ok := err.(*ChatMsgPartialException); ok && isPartial { | |
| break | |
| } | |
| // For non-partial errors or when not in partial mode, try legacy parsing | |
| return parseJSONLegacy(s) | |
| } | |
| if jsonValue != nil { | |
| // Convert to map[string]any if it's an object, or handle arrays | |
| if obj, ok := jsonValue.(map[string]any); ok { | |
| results = append(results, obj) | |
| } else if arr, ok := jsonValue.([]any); ok { | |
| // Handle arrays: extract objects from array | |
| for _, item := range arr { | |
| if obj, ok := item.(map[string]any); ok { | |
| results = append(results, obj) | |
| } | |
| } | |
| } | |
| } | |
| if isPartialJSON { | |
| break | |
| } | |
| // Skip whitespace between JSON values | |
| parser.ConsumeSpaces() | |
| } | |
| if len(results) > 0 { | |
| return results, nil | |
| } | |
| // Fallback to legacy parsing if iterative parser found nothing | |
| return parseJSONLegacy(s) | |
| } | |
| // parseJSONLegacy is the original decoder-based JSON parsing (kept for compatibility) | |
| func parseJSONLegacy(s string) ([]map[string]any, error) { | |
| var objs []map[string]any | |
| offset := 0 | |
| for offset < len(s) { | |
| var obj map[string]any | |
| decoder := json.NewDecoder(strings.NewReader(s[offset:])) | |
| err := decoder.Decode(&obj) | |
| switch { | |
| case errors.Is(err, io.EOF): | |
| return objs, nil | |
| case err == nil: | |
| offset += int(decoder.InputOffset()) | |
| objs = append(objs, obj) | |
| default: // handle the error type | |
| var syntaxErr *json.SyntaxError | |
| var unmarshalTypeErr *json.UnmarshalTypeError | |
| switch { | |
| case errors.As(err, &syntaxErr): | |
| offset += int(syntaxErr.Offset) | |
| case errors.As(err, &unmarshalTypeErr): | |
| offset += int(unmarshalTypeErr.Offset) | |
| default: | |
| return objs, err | |
| } | |
| } | |
| } | |
| return objs, nil | |
| } | |
| // GetXMLFormatPreset returns a preset XML format by name, or nil if not found | |
| // This is exported for use in chat.go streaming integration | |
| func GetXMLFormatPreset(name string) *XMLToolCallFormat { | |
| formats := getAllXMLFormats() | |
| for _, format := range formats { | |
| if format.name == name { | |
| return format.format | |
| } | |
| } | |
| return nil | |
| } | |
| // xmlFormatPreset holds a preset format with its name | |
| type xmlFormatPreset struct { | |
| name string | |
| format *XMLToolCallFormat | |
| } | |
| // getAllXMLFormats returns all preset XML formats matching llama.cpp's formats | |
| func getAllXMLFormats() []xmlFormatPreset { | |
| falseVal := false | |
| commaSpace := ", " | |
| emptyValEnd := "" | |
| return []xmlFormatPreset{ | |
| { | |
| name: "functionary", | |
| format: &XMLToolCallFormat{ | |
| ScopeStart: "", | |
| ToolStart: "<function=", | |
| ToolSep: ">", | |
| KeyStart: "", // Parameters are JSON, not XML tags | |
| KeyValSep: "", | |
| ValEnd: "", | |
| ToolEnd: "</function>", | |
| ScopeEnd: "", | |
| RawArgVal: &falseVal, // JSON only | |
| }, | |
| }, | |
| { | |
| name: "qwen3-coder", | |
| format: &XMLToolCallFormat{ | |
| ScopeStart: "<tool_call>", | |
| ToolStart: "<function=", | |
| ToolSep: ">", | |
| KeyStart: "<parameter=", | |
| KeyValSep: ">", | |
| ValEnd: "</parameter>", | |
| ToolEnd: "</function>", | |
| ScopeEnd: "</tool_call>", | |
| TrimRawArgVal: true, | |
| }, | |
| }, | |
| { | |
| name: "glm-4.5", | |
| format: &XMLToolCallFormat{ | |
| ScopeStart: "", | |
| ToolStart: "<tool_call>", | |
| ToolSep: "", | |
| KeyStart: "<arg_key>", | |
| KeyValSep: "</arg_key>", | |
| KeyValSep2: func() *string { s := "<arg_value>"; return &s }(), | |
| ValEnd: "</arg_value>", | |
| ToolEnd: "</tool_call>", | |
| ScopeEnd: "", | |
| }, | |
| }, | |
| { | |
| name: "minimax-m2", | |
| format: &XMLToolCallFormat{ | |
| ScopeStart: "<minimax:tool_call>", | |
| ToolStart: "<invoke name=\"", | |
| ToolSep: "\">", | |
| KeyStart: "<parameter name=\"", | |
| KeyValSep: "\">", | |
| ValEnd: "</parameter>", | |
| ToolEnd: "</invoke>", | |
| ScopeEnd: "</minimax:tool_call>", | |
| }, | |
| }, | |
| { | |
| name: "kimi-k2", | |
| format: &XMLToolCallFormat{ | |
| ScopeStart: "<|tool_calls_section_begin|>", | |
| ToolStart: "<|tool_call_begin|>", | |
| ToolSep: "<|tool_call_argument_begin|>{", | |
| KeyStart: "\"", | |
| KeyValSep: "\":", | |
| ValEnd: ",", | |
| ToolEnd: "}<|tool_call_end|>", | |
| ScopeEnd: "<|tool_calls_section_end|>", | |
| LastValEnd: &emptyValEnd, | |
| RawArgVal: &falseVal, | |
| AllowToolcallInThink: true, // Kimi-K2 supports tool calls in thinking blocks | |
| }, | |
| }, | |
| { | |
| name: "apriel-1.5", | |
| format: &XMLToolCallFormat{ | |
| ScopeStart: "<tool_calls>[", | |
| ToolStart: "{\"name\": \"", | |
| ToolSep: "\", \"arguments\": {", | |
| KeyStart: "\"", | |
| KeyValSep: "\": ", | |
| ValEnd: commaSpace, | |
| ToolEnd: "}, ", | |
| ScopeEnd: "]</tool_calls>", | |
| LastValEnd: &emptyValEnd, | |
| LastToolEnd: func() *string { s := "}"; return &s }(), | |
| RawArgVal: &falseVal, | |
| }, | |
| }, | |
| { | |
| name: "xiaomi-mimo", | |
| format: &XMLToolCallFormat{ | |
| ScopeStart: "", | |
| ToolStart: "<tool_call>\n{\"name\": \"", | |
| ToolSep: "\", \"arguments\": {", | |
| KeyStart: "\"", | |
| KeyValSep: "\": ", | |
| ValEnd: commaSpace, | |
| ToolEnd: "}\n</tool_call>", | |
| ScopeEnd: "", | |
| LastValEnd: &emptyValEnd, | |
| RawArgVal: &falseVal, | |
| }, | |
| }, | |
| } | |
| } | |
| // parseXMLAutoDetect tries all preset formats in sequence and returns results from the first one that succeeds | |
| func parseXMLAutoDetect(s string) ([]FuncCallResults, error) { | |
| formats := getAllXMLFormats() | |
| for _, preset := range formats { | |
| results, err := parseXMLWithFormat(s, preset.format) | |
| if err == nil && len(results) > 0 { | |
| xlog.Debug("XML auto-detection succeeded", "format", preset.name, "count", len(results)) | |
| return results, nil | |
| } | |
| } | |
| return nil, nil | |
| } | |
| // ParseXML is a function that parses XML-style tool calls from a string that might contain | |
| // text and valid XML tool calls. If format is nil, it will auto-detect by trying all formats. | |
| // Returns a slice of FuncCallResults with function names and JSON-encoded arguments. | |
| // Now defaults to iterative parser for better streaming and partial parsing support. | |
| // Falls back to regex parser if iterative parser fails for backward compatibility. | |
| func ParseXML(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { | |
| // Try iterative parser first (non-partial mode for complete parsing) | |
| results, err := ParseXMLIterative(s, format, false) | |
| if err == nil && len(results) > 0 { | |
| return results, nil | |
| } | |
| // Fall back to regex parser for backward compatibility | |
| if format == nil { | |
| return parseXMLAutoDetect(s) | |
| } | |
| return parseXMLWithFormat(s, format) | |
| } | |
| // ParseXMLIterative parses XML tool calls using the iterative parser | |
| // This provides better streaming and partial parsing support | |
| func ParseXMLIterative(s string, format *XMLToolCallFormat, isPartial bool) ([]FuncCallResults, error) { | |
| parser := NewChatMsgParser(s, isPartial) | |
| // Auto-detect format if not provided | |
| if format == nil { | |
| formats := getAllXMLFormats() | |
| for _, fmtPreset := range formats { | |
| if fmtPreset.format != nil { | |
| // Try parsing with this format | |
| parser.MoveTo(0) | |
| parser.ClearTools() | |
| success, err := parser.TryConsumeXMLToolCalls(fmtPreset.format) | |
| if err != nil { | |
| // Check if it's a partial exception (recoverable) | |
| if _, ok := err.(*ChatMsgPartialException); ok { | |
| // Partial parse, return what we have | |
| return parser.ToolCalls(), nil | |
| } | |
| // Try next format | |
| continue | |
| } | |
| if success && len(parser.ToolCalls()) > 0 { | |
| return parser.ToolCalls(), nil | |
| } | |
| } | |
| } | |
| // No format matched, return empty | |
| return []FuncCallResults{}, nil | |
| } | |
| // Use specified format | |
| success, err := parser.TryConsumeXMLToolCalls(format) | |
| if err != nil { | |
| // Check if it's a partial exception (recoverable) | |
| if _, ok := err.(*ChatMsgPartialException); ok { | |
| // Partial parse, return what we have | |
| return parser.ToolCalls(), nil | |
| } | |
| return nil, err | |
| } | |
| if !success { | |
| return []FuncCallResults{}, nil | |
| } | |
| return parser.ToolCalls(), nil | |
| } | |
| // ParseXMLPartial parses XML tool calls that may be incomplete (for streaming support) | |
| // It returns both complete results and partial results that can be emitted during streaming | |
| // Reference: llama.cpp's partial parsing support | |
| // Uses iterative parser for better partial detection | |
| func ParseXMLPartial(s string, format *XMLToolCallFormat) (*PartialXMLResult, error) { | |
| // Use iterative parser with partial flag enabled for better streaming support | |
| results, err := ParseXMLIterative(s, format, true) | |
| if err != nil { | |
| return nil, err | |
| } | |
| // Check if the input ends with incomplete XML tags (indicating partial content) | |
| isPartial := false | |
| trimmed := strings.TrimSpace(s) | |
| // Auto-detect format if not provided to check for partial content | |
| if format == nil { | |
| formats := getAllXMLFormats() | |
| for _, fmtPreset := range formats { | |
| if fmtPreset.format != nil { | |
| format = fmtPreset.format | |
| break | |
| } | |
| } | |
| } | |
| if format != nil { | |
| // Check if string ends with incomplete tool_end or val_end | |
| // Also check for incomplete tags like "</parameter" (missing >) | |
| if !strings.HasSuffix(trimmed, format.ToolEnd) { | |
| if format.LastToolEnd != nil && !strings.HasSuffix(trimmed, *format.LastToolEnd) { | |
| // Check if it starts with tool_end but is incomplete | |
| if len(trimmed) > 0 && len(format.ToolEnd) > 0 { | |
| suffix := trimmed[max(0, len(trimmed)-len(format.ToolEnd)):] | |
| if strings.HasPrefix(format.ToolEnd, suffix) && suffix != format.ToolEnd { | |
| isPartial = true | |
| } | |
| } | |
| } | |
| // Also check for incomplete closing tags (ends with < but not complete) | |
| if strings.HasSuffix(trimmed, "<") || strings.HasSuffix(trimmed, "</") { | |
| isPartial = true | |
| } | |
| } | |
| if !strings.HasSuffix(trimmed, format.ValEnd) { | |
| if format.LastValEnd != nil && !strings.HasSuffix(trimmed, *format.LastValEnd) { | |
| if len(trimmed) > 0 && len(format.ValEnd) > 0 { | |
| suffix := trimmed[max(0, len(trimmed)-len(format.ValEnd)):] | |
| if strings.HasPrefix(format.ValEnd, suffix) && suffix != format.ValEnd { | |
| isPartial = true | |
| } | |
| } | |
| } | |
| // Check for incomplete closing tags | |
| if strings.HasSuffix(trimmed, "<") || strings.HasSuffix(trimmed, "</") { | |
| isPartial = true | |
| } | |
| } | |
| // Check for incomplete parameter tags | |
| if format.KeyStart != "" && (strings.HasSuffix(trimmed, "<parameter") || strings.HasSuffix(trimmed, "<parameter=")) { | |
| isPartial = true | |
| } | |
| // Check if we have tool_start but missing tool_end (incomplete tool call) | |
| if strings.Contains(trimmed, format.ToolStart) && !strings.HasSuffix(trimmed, format.ToolEnd) { | |
| if format.LastToolEnd == nil || !strings.HasSuffix(trimmed, *format.LastToolEnd) { | |
| // Check if tool_end appears anywhere (if not, it's partial) | |
| if !strings.Contains(trimmed, format.ToolEnd) { | |
| isPartial = true | |
| } | |
| } | |
| } | |
| } | |
| return &PartialXMLResult{ | |
| Results: results, | |
| IsPartial: isPartial, | |
| }, nil | |
| } | |
| func max(a, b int) int { | |
| if a > b { | |
| return a | |
| } | |
| return b | |
| } | |
| // parseXMLWithFormat parses XML tool calls using a specific format configuration | |
| // Returns parsed results and error. Handles errors gracefully by continuing to parse other tool calls. | |
| func parseXMLWithFormat(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { | |
| var results []FuncCallResults | |
| // Handle Functionary format (JSON parameters inside XML tags) | |
| if format.KeyStart == "" && format.ToolStart == "<function=" { | |
| return parseFunctionaryFormat(s, format) | |
| } | |
| // Handle formats with JSON-like structure (Apriel-1.5, Xiaomi-MiMo) | |
| // Note: Kimi-K2 is NOT JSON-like - it uses standard XML format with JSON arguments | |
| if format.ToolStart != "" && strings.Contains(format.ToolStart, "{\"name\"") { | |
| return parseJSONLikeXMLFormat(s, format) | |
| } | |
| // Handle GLM 4.5 format specially (function name on separate line after <tool_call>) | |
| if format.ToolStart == "<tool_call>" && format.ToolSep == "" && format.KeyStart == "<arg_key>" { | |
| return parseGLM45Format(s, format) | |
| } | |
| // Build regex patterns from format configuration | |
| // Escape special regex characters in format strings | |
| escapeRegex := func(str string) string { | |
| return regexp.QuoteMeta(str) | |
| } | |
| // Build scope pattern (optional) | |
| // llama.cpp validates that only whitespace appears before scope_start | |
| var scopePattern *regexp.Regexp | |
| if format.ScopeStart != "" { | |
| // Match scope_start with optional whitespace before it, but validate it's only whitespace | |
| scopeRegex := `(?s)(\s*)` + escapeRegex(format.ScopeStart) + `\s*(.*?)\s*` + escapeRegex(format.ScopeEnd) | |
| scopePattern = regexp.MustCompile(scopeRegex) | |
| } | |
| // Build tool call patterns - try both primary and alternative tool_end | |
| var toolCallPatterns []*regexp.Regexp | |
| buildToolCallPattern := func(toolEnd string) string { | |
| toolCallRegex := `(?s)` + escapeRegex(format.ToolStart) | |
| if format.ToolSep != "" { | |
| // Tool name is between ToolStart and ToolSep | |
| // Use non-greedy match to capture function name until ToolSep | |
| // We can't use [^...] for multi-character strings, so use .*? with ToolSep | |
| toolCallRegex += `(.*?)` + escapeRegex(format.ToolSep) | |
| toolCallRegex += `(.*?)` + escapeRegex(toolEnd) | |
| } else { | |
| // Tool name might be on a separate line (GLM 4.5) or after ToolStart | |
| // For GLM 4.5: <tool_call>\nfunction_name\n<arg_key>... | |
| // Match function name until we find key_start or newline | |
| if format.KeyStart != "" { | |
| // Match whitespace/newlines, then function name, then whitespace, then key_start | |
| // We'll capture the function name and the rest (including key_start) | |
| toolCallRegex += `\s*([^\n` + escapeRegex(format.KeyStart) + `]+?)\s*` + escapeRegex(format.KeyStart) + `(.*?)` + escapeRegex(toolEnd) | |
| } else { | |
| // Match until newline | |
| toolCallRegex += `\s*([^\n]+)\s*(.*?)` + escapeRegex(toolEnd) | |
| } | |
| } | |
| return toolCallRegex | |
| } | |
| // Primary pattern with tool_end | |
| toolCallPatterns = append(toolCallPatterns, regexp.MustCompile(buildToolCallPattern(format.ToolEnd))) | |
| // Alternative pattern with last_tool_end if specified | |
| if format.LastToolEnd != nil && *format.LastToolEnd != "" { | |
| toolCallPatterns = append(toolCallPatterns, regexp.MustCompile(buildToolCallPattern(*format.LastToolEnd))) | |
| } | |
| // Extract content to search in | |
| searchContent := s | |
| if scopePattern != nil { | |
| scopeMatches := scopePattern.FindAllStringSubmatch(s, -1) | |
| if len(scopeMatches) == 0 { | |
| // Scope not found | |
| // If scope_end is not empty/whitespace, this might be an error | |
| // But scope is optional, so try parsing without scope | |
| if strings.TrimSpace(format.ScopeEnd) != "" { | |
| // Scope expected but not found - this might indicate incomplete input | |
| // For now, try parsing without scope (scope is optional) | |
| xlog.Debug("scope_start not found but scope_end is non-empty", "scope_end", format.ScopeEnd) | |
| } | |
| searchContent = s | |
| } else { | |
| // Process each scope match separately | |
| for _, scopeMatch := range scopeMatches { | |
| if len(scopeMatch) >= 3 { | |
| // scopeMatch[1] is the whitespace before scope_start (we validate it's only whitespace) | |
| // scopeMatch[2] is the content inside the scope | |
| prelude := scopeMatch[1] | |
| // Validate that prelude contains only whitespace (llama.cpp behavior) | |
| allWhitespace := true | |
| for _, r := range prelude { | |
| if !strings.ContainsRune(" \t\n\r", r) { | |
| allWhitespace = false | |
| break | |
| } | |
| } | |
| if !allWhitespace { | |
| // Non-whitespace before scope_start, skip this match | |
| // This matches llama.cpp's behavior (line 394) | |
| xlog.Debug("non-whitespace before scope_start, skipping match", "prelude", prelude) | |
| continue | |
| } | |
| scopeContent := scopeMatch[2] | |
| // Validate scope_end is present in the match (scope pattern should include it) | |
| // The regex pattern already includes scope_end, so if we matched, it should be there | |
| // But we can verify the match is complete | |
| // Find all tool calls within this scope - try both patterns | |
| var toolCallMatches [][]string | |
| for _, pattern := range toolCallPatterns { | |
| matches := pattern.FindAllStringSubmatch(scopeContent, -1) | |
| toolCallMatches = append(toolCallMatches, matches...) | |
| } | |
| for _, match := range toolCallMatches { | |
| if len(match) >= 3 { | |
| functionName := strings.TrimSpace(match[1]) | |
| // Handle Kimi-K2 function name prefix stripping: "functions.name:index" -> "name" | |
| if strings.HasPrefix(functionName, "functions.") { | |
| // Remove "functions." prefix | |
| functionName = functionName[10:] | |
| // Remove ":index" suffix if present | |
| if idx := strings.LastIndex(functionName, ":"); idx != -1 { | |
| // Check if what follows ":" is all digits | |
| suffix := functionName[idx+1:] | |
| if len(suffix) > 0 { | |
| allDigits := true | |
| for _, r := range suffix { | |
| if r < '0' || r > '9' { | |
| allDigits = false | |
| break | |
| } | |
| } | |
| if allDigits { | |
| functionName = functionName[:idx] | |
| } | |
| } | |
| } | |
| } | |
| var functionContent string | |
| if format.ToolSep == "" && format.KeyStart != "" { | |
| // Content includes key_start, so prepend it | |
| functionContent = format.KeyStart + match[2] | |
| } else { | |
| functionContent = match[2] | |
| } | |
| // Check for empty tool call: if tool_end appears in function name or content is empty | |
| // This matches llama.cpp's behavior (lines 419-424) | |
| if strings.Contains(functionName, format.ToolEnd) || (format.LastToolEnd != nil && strings.Contains(functionName, *format.LastToolEnd)) { | |
| // Empty tool call - emit with empty arguments | |
| cleanName := strings.TrimSpace(functionName) | |
| if idx := strings.Index(cleanName, format.ToolEnd); idx != -1 { | |
| cleanName = strings.TrimSpace(cleanName[:idx]) | |
| } else if format.LastToolEnd != nil { | |
| if idx := strings.Index(cleanName, *format.LastToolEnd); idx != -1 { | |
| cleanName = strings.TrimSpace(cleanName[:idx]) | |
| } | |
| } | |
| results = append(results, FuncCallResults{ | |
| Name: cleanName, | |
| Arguments: "{}", | |
| }) | |
| continue | |
| } | |
| // Check if content is empty or only whitespace | |
| if strings.TrimSpace(functionContent) == "" { | |
| // Empty tool call - emit with empty arguments | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: "{}", | |
| }) | |
| continue | |
| } | |
| // Parse parameters based on format | |
| args, err := parseXMLParametersWithFormat(functionContent, format) | |
| if err != nil { | |
| xlog.Debug("error parsing XML parameters", "error", err, "content", functionContent) | |
| continue | |
| } | |
| // If no parameters were parsed and content was not empty, still create tool call with empty args | |
| if len(args) == 0 && strings.TrimSpace(functionContent) != "" { | |
| // Check if there's any parameter-like content that just didn't match | |
| if !strings.Contains(functionContent, format.KeyStart) { | |
| argsJSON, _ := json.Marshal(args) | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: string(argsJSON), | |
| }) | |
| continue | |
| } | |
| } | |
| argsJSON, _ := json.Marshal(args) | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: string(argsJSON), | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| return results, nil | |
| } | |
| } | |
| // No scope, find all tool calls directly in the string - try both patterns | |
| var toolCallMatches [][]string | |
| for _, pattern := range toolCallPatterns { | |
| matches := pattern.FindAllStringSubmatch(searchContent, -1) | |
| toolCallMatches = append(toolCallMatches, matches...) | |
| } | |
| if len(toolCallMatches) == 0 { | |
| return nil, nil | |
| } | |
| // Process each tool call | |
| for _, match := range toolCallMatches { | |
| if len(match) < 3 { | |
| continue | |
| } | |
| // Validate tool_end is complete (exact size match) | |
| // This matches llama.cpp's behavior (line 595) | |
| fullMatch := match[0] | |
| expectedToolEnd := format.ToolEnd | |
| if format.LastToolEnd != nil && strings.HasSuffix(fullMatch, *format.LastToolEnd) { | |
| expectedToolEnd = *format.LastToolEnd | |
| } | |
| if !strings.HasSuffix(fullMatch, expectedToolEnd) { | |
| // tool_end not found at end, skip this match | |
| xlog.Debug("tool_end validation failed", "expected", expectedToolEnd, "match", fullMatch) | |
| continue | |
| } | |
| // Verify the tool_end is exactly the expected size (not a partial match) | |
| // Extract the tool_end from the end of the match | |
| if len(fullMatch) < len(expectedToolEnd) { | |
| // Match is shorter than expected tool_end, skip | |
| continue | |
| } | |
| actualToolEnd := fullMatch[len(fullMatch)-len(expectedToolEnd):] | |
| if actualToolEnd != expectedToolEnd { | |
| // tool_end doesn't match exactly, skip | |
| xlog.Debug("tool_end size validation failed", "expected", expectedToolEnd, "actual", actualToolEnd) | |
| continue | |
| } | |
| functionName := strings.TrimSpace(match[1]) | |
| // Handle Kimi-K2 function name prefix stripping: "functions.name:index" -> "name" | |
| if strings.HasPrefix(functionName, "functions.") { | |
| // Remove "functions." prefix | |
| functionName = functionName[10:] | |
| // Remove ":index" suffix if present | |
| if idx := strings.LastIndex(functionName, ":"); idx != -1 { | |
| // Check if what follows ":" is all digits | |
| suffix := functionName[idx+1:] | |
| if len(suffix) > 0 { | |
| allDigits := true | |
| for _, r := range suffix { | |
| if r < '0' || r > '9' { | |
| allDigits = false | |
| break | |
| } | |
| } | |
| if allDigits { | |
| functionName = functionName[:idx] | |
| } | |
| } | |
| } | |
| } | |
| var functionContent string | |
| if len(match) >= 3 { | |
| if format.ToolSep == "" && format.KeyStart != "" { | |
| // For GLM 4.5 format, match[2] contains the content starting from key_start | |
| functionContent = match[2] | |
| } else { | |
| functionContent = match[2] | |
| } | |
| } | |
| // Check for empty tool call: if tool_end appears in function name prelude or content is empty | |
| // This matches llama.cpp's behavior (lines 419-424) | |
| // If the function name contains tool_end, it indicates the tool call has no arguments | |
| if strings.Contains(functionName, format.ToolEnd) || (format.LastToolEnd != nil && strings.Contains(functionName, *format.LastToolEnd)) { | |
| // Empty tool call - emit with empty arguments | |
| results = append(results, FuncCallResults{ | |
| Name: strings.TrimSpace(strings.Split(functionName, format.ToolEnd)[0]), | |
| Arguments: "{}", | |
| }) | |
| continue | |
| } | |
| // Check if content is empty or only whitespace (another indicator of empty tool call) | |
| if strings.TrimSpace(functionContent) == "" { | |
| // Empty tool call - emit with empty arguments | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: "{}", | |
| }) | |
| continue | |
| } | |
| // Parse parameters based on format | |
| args, err := parseXMLParametersWithFormat(functionContent, format) | |
| if err != nil { | |
| xlog.Debug("error parsing XML parameters", "error", err, "content", functionContent) | |
| continue | |
| } | |
| // If no parameters were parsed and content was not empty, still create tool call with empty args | |
| // This handles cases where parameters exist but couldn't be parsed | |
| if len(args) == 0 && strings.TrimSpace(functionContent) != "" { | |
| // Check if there's any parameter-like content that just didn't match | |
| // If not, treat as empty tool call | |
| if !strings.Contains(functionContent, format.KeyStart) { | |
| argsJSON, _ := json.Marshal(args) | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: string(argsJSON), | |
| }) | |
| continue | |
| } | |
| } | |
| argsJSON, _ := json.Marshal(args) | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: string(argsJSON), | |
| }) | |
| } | |
| return results, nil | |
| } | |
| // parseGLM45Format handles GLM 4.5 format: <tool_call>\nfunction_name\n<arg_key>...</arg_key><arg_value>...</arg_value>... | |
| func parseGLM45Format(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { | |
| var results []FuncCallResults | |
| // Pattern: <tool_call>\nfunction_name\n<arg_key>...</arg_key><arg_value>...</arg_value>...</tool_call> | |
| pattern := regexp.MustCompile(`(?s)<tool_call>\s*([^\n<]+)\s*(.*?)\s*</tool_call>`) | |
| matches := pattern.FindAllStringSubmatch(s, -1) | |
| for _, match := range matches { | |
| if len(match) >= 3 { | |
| functionName := strings.TrimSpace(match[1]) | |
| // Handle Kimi-K2 function name prefix stripping: "functions.name:index" -> "name" | |
| if strings.HasPrefix(functionName, "functions.") { | |
| // Remove "functions." prefix | |
| functionName = functionName[10:] | |
| // Remove ":index" suffix if present | |
| if idx := strings.LastIndex(functionName, ":"); idx != -1 { | |
| // Check if what follows ":" is all digits | |
| suffix := functionName[idx+1:] | |
| if len(suffix) > 0 { | |
| allDigits := true | |
| for _, r := range suffix { | |
| if r < '0' || r > '9' { | |
| allDigits = false | |
| break | |
| } | |
| } | |
| if allDigits { | |
| functionName = functionName[:idx] | |
| } | |
| } | |
| } | |
| } | |
| functionContent := match[2] | |
| // Check for empty tool call: if content is empty or only whitespace | |
| if strings.TrimSpace(functionContent) == "" { | |
| // Empty tool call - emit with empty arguments | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: "{}", | |
| }) | |
| continue | |
| } | |
| // Parse parameters using GLM 4.5 format | |
| args, err := parseXMLParametersWithFormat(functionContent, format) | |
| if err != nil { | |
| xlog.Debug("error parsing GLM 4.5 parameters", "error", err, "content", functionContent) | |
| continue | |
| } | |
| // If no parameters were parsed, still create tool call with empty args | |
| if len(args) == 0 { | |
| argsJSON, _ := json.Marshal(args) | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: string(argsJSON), | |
| }) | |
| continue | |
| } | |
| argsJSON, _ := json.Marshal(args) | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: string(argsJSON), | |
| }) | |
| } | |
| } | |
| return results, nil | |
| } | |
| // parseFunctionaryFormat handles Functionary format: <function=name>{"key": "value"}</function> | |
| func parseFunctionaryFormat(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { | |
| var results []FuncCallResults | |
| // Pattern: <function=name>JSON</function> | |
| pattern := regexp.MustCompile(`(?s)<function=([^>]+)>(.*?)</function>`) | |
| matches := pattern.FindAllStringSubmatch(s, -1) | |
| for _, match := range matches { | |
| if len(match) >= 3 { | |
| functionName := strings.TrimSpace(match[1]) | |
| jsonContent := strings.TrimSpace(match[2]) | |
| // Parse JSON content as arguments | |
| var args map[string]any | |
| if err := json.Unmarshal([]byte(jsonContent), &args); err != nil { | |
| xlog.Debug("error parsing Functionary JSON", "error", err, "content", jsonContent) | |
| continue | |
| } | |
| argsJSON, _ := json.Marshal(args) | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: string(argsJSON), | |
| }) | |
| } | |
| } | |
| return results, nil | |
| } | |
| // parseJSONLikeXMLFormat handles formats like Apriel-1.5, Xiaomi-MiMo, Kimi-K2 that have JSON-like structure | |
| func parseJSONLikeXMLFormat(s string, format *XMLToolCallFormat) ([]FuncCallResults, error) { | |
| var results []FuncCallResults | |
| // Build pattern to match the JSON-like structure | |
| escapeRegex := func(str string) string { | |
| return regexp.QuoteMeta(str) | |
| } | |
| // Pattern: scope_start + tool_start + name + tool_sep + arguments + tool_end + scope_end | |
| var pattern *regexp.Regexp | |
| if format.ScopeStart != "" { | |
| patternStr := `(?s)` + escapeRegex(format.ScopeStart) + `(.*?)` + escapeRegex(format.ScopeEnd) | |
| pattern = regexp.MustCompile(patternStr) | |
| } else { | |
| patternStr := `(?s)` + escapeRegex(format.ToolStart) + `([^"]+)"` + escapeRegex(format.ToolSep) + `(.*?)` + escapeRegex(format.ToolEnd) | |
| pattern = regexp.MustCompile(patternStr) | |
| } | |
| matches := pattern.FindAllStringSubmatch(s, -1) | |
| for _, match := range matches { | |
| if len(match) < 2 { | |
| continue | |
| } | |
| // Extract JSON content | |
| jsonContent := match[1] | |
| if format.ScopeStart != "" { | |
| // Need to extract individual tool calls from the array | |
| // Pattern: {"name": "...", "arguments": {...}} | |
| toolPattern := regexp.MustCompile(`(?s)\{\s*"name"\s*:\s*"([^"]+)"\s*,\s*"arguments"\s*:\s*(\{.*?\})\s*\}`) | |
| toolMatches := toolPattern.FindAllStringSubmatch(jsonContent, -1) | |
| for _, toolMatch := range toolMatches { | |
| if len(toolMatch) >= 3 { | |
| functionName := strings.TrimSpace(toolMatch[1]) | |
| argsJSON := toolMatch[2] | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: argsJSON, | |
| }) | |
| } | |
| } | |
| } else { | |
| // Single tool call | |
| namePattern := regexp.MustCompile(`"name"\s*:\s*"([^"]+)"`) | |
| nameMatch := namePattern.FindStringSubmatch(jsonContent) | |
| if len(nameMatch) >= 2 { | |
| functionName := strings.TrimSpace(nameMatch[1]) | |
| argsPattern := regexp.MustCompile(`"arguments"\s*:\s*(\{.*\})`) | |
| argsMatch := argsPattern.FindStringSubmatch(jsonContent) | |
| argsJSON := "{}" | |
| if len(argsMatch) >= 2 { | |
| argsJSON = argsMatch[1] | |
| } | |
| results = append(results, FuncCallResults{ | |
| Name: functionName, | |
| Arguments: argsJSON, | |
| }) | |
| } | |
| } | |
| } | |
| return results, nil | |
| } | |
| // utf8TruncateSafe truncates a string at a safe UTF-8 boundary | |
| // This prevents truncation in the middle of multi-byte characters | |
| // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 27-58 | |
| func utf8TruncateSafe(s string) string { | |
| if len(s) == 0 { | |
| return s | |
| } | |
| // Check if the string ends at a valid UTF-8 boundary | |
| // If not, truncate to the last valid boundary | |
| for i := len(s); i > 0 && i > len(s)-4; i-- { | |
| if utf8.ValidString(s[:i]) { | |
| return s[:i] | |
| } | |
| } | |
| // If we can't find a valid boundary in the last 4 bytes, truncate conservatively | |
| if len(s) > 3 { | |
| return s[:len(s)-3] | |
| } | |
| return "" | |
| } | |
| // PartialXMLResult represents a partial XML parsing result that can be emitted during streaming | |
| type PartialXMLResult struct { | |
| Results []FuncCallResults | |
| IsPartial bool | |
| PartialArg string // The argument that was partially parsed | |
| } | |
| // XML_TOOL_CALL_PARTIAL_FLAG is a marker used to indicate partial JSON in tool calls | |
| // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp line 314 | |
| const XML_TOOL_CALL_PARTIAL_FLAG = "XML_TOOL_CALL_PARTIAL_FLAG" | |
| // partialJSON cleans up partial JSON by removing incomplete parts marked with XML_TOOL_CALL_PARTIAL_FLAG | |
| // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 314-330 | |
| func partialJSON(jsonStr string) (string, bool) { | |
| pos := strings.LastIndex(jsonStr, XML_TOOL_CALL_PARTIAL_FLAG) | |
| if pos == -1 { | |
| return jsonStr, false | |
| } | |
| // Check that only valid JSON characters follow the flag | |
| for i := pos + len(XML_TOOL_CALL_PARTIAL_FLAG); i < len(jsonStr); i++ { | |
| ch := jsonStr[i] | |
| if ch != '\'' && ch != '"' && ch != '}' && ch != ':' && ch != ']' && !strings.ContainsRune(" \t\n\r", rune(ch)) { | |
| return jsonStr, false | |
| } | |
| } | |
| // Remove the flag and everything after it | |
| if pos > 0 && jsonStr[pos-1] == '"' { | |
| pos-- | |
| } | |
| return jsonStr[:pos], true | |
| } | |
| // genPartialJSON generates partial JSON with XML_TOOL_CALL_PARTIAL_FLAG marker | |
| // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 332-343 | |
| func genPartialJSON(args map[string]any, functionName string, rest string, needle string) (string, bool) { | |
| // Add the partial argument with the flag | |
| args[rest+needle] = XML_TOOL_CALL_PARTIAL_FLAG | |
| jsonBytes, err := json.Marshal(args) | |
| if err != nil { | |
| return "", false | |
| } | |
| jsonStr := string(jsonBytes) | |
| // Try to clean up the partial JSON | |
| if cleaned, isPartial := partialJSON(jsonStr); isPartial { | |
| return cleaned, true | |
| } | |
| return jsonStr, false | |
| } | |
| // parseXMLParametersWithFormat extracts parameters from XML content based on format configuration | |
| func parseXMLParametersWithFormat(content string, format *XMLToolCallFormat) (map[string]any, error) { | |
| args := make(map[string]any) | |
| // Handle GLM 4.5 format: <arg_key>key</arg_key><arg_value>value</arg_value> | |
| if format.KeyValSep2 != nil && *format.KeyValSep2 == "<arg_value>" { | |
| return parseGLM45Parameters(content, format) | |
| } | |
| // Special case: If content is already valid JSON and format expects JSON (like Kimi-K2), | |
| // try to parse it as JSON first | |
| if format.KeyStart == "\"" && format.KeyValSep == "\":" && (format.RawArgVal == nil || !*format.RawArgVal) { | |
| // Try parsing as complete JSON object first | |
| content = strings.TrimSpace(content) | |
| if strings.HasPrefix(content, "{") && strings.HasSuffix(content, "}") { | |
| var jsonArgs map[string]any | |
| if err := json.Unmarshal([]byte(content), &jsonArgs); err == nil { | |
| // Successfully parsed as JSON, return it | |
| return jsonArgs, nil | |
| } | |
| } | |
| } | |
| // Handle standard parameter format: <parameter=name>value</parameter> or <parameter name="name">value</parameter> | |
| if format.KeyStart != "" { | |
| return parseStandardParameters(content, format) | |
| } | |
| return args, nil | |
| } | |
| // parseMsgWithXMLToolCalls parses content with reasoning blocks and XML tool calls | |
| // This handles <think> or <think> tags and extracts tool calls | |
| // Reference: llama.cpp/common/chat-parser-xml-toolcall.cpp lines 654-872 | |
| func parseMsgWithXMLToolCalls(s string, format *XMLToolCallFormat, startThink string, endThink string) ([]FuncCallResults, string, error) { | |
| if startThink == "" { | |
| startThink = "<think>" | |
| } | |
| if endThink == "" { | |
| endThink = "</think>" | |
| } | |
| var results []FuncCallResults | |
| var reasoningContent strings.Builder | |
| var content strings.Builder | |
| // Simple approach: find reasoning blocks and tool calls | |
| // For more complex scenarios, we'd need iterative parsing | |
| thinkStartIdx := strings.Index(s, startThink) | |
| if thinkStartIdx == -1 { | |
| // No reasoning blocks, just parse tool calls | |
| xmlResults, err := parseXMLWithFormat(s, format) | |
| return xmlResults, "", err | |
| } | |
| // Process content before first thinking block | |
| if thinkStartIdx > 0 { | |
| preContent := s[:thinkStartIdx] | |
| xmlResults, _ := parseXMLWithFormat(preContent, format) | |
| results = append(results, xmlResults...) | |
| content.WriteString(preContent) | |
| } | |
| // Process thinking blocks and tool calls | |
| pos := 0 | |
| for pos < len(s) { | |
| thinkStart := strings.Index(s[pos:], startThink) | |
| if thinkStart == -1 { | |
| // No more thinking blocks, process rest | |
| remaining := s[pos:] | |
| xmlResults, _ := parseXMLWithFormat(remaining, format) | |
| results = append(results, xmlResults...) | |
| content.WriteString(remaining) | |
| break | |
| } | |
| thinkStart += pos | |
| thinkEnd := strings.Index(s[thinkStart+len(startThink):], endThink) | |
| if thinkEnd == -1 { | |
| // Unclosed thinking block | |
| if format.AllowToolcallInThink { | |
| // Allow tool calls in unclosed thinking block | |
| thinkingContent := s[thinkStart+len(startThink):] | |
| reasoningContent.WriteString(thinkingContent) | |
| // Try to parse tool calls from thinking content | |
| xmlResults, _ := parseXMLWithFormat(thinkingContent, format) | |
| results = append(results, xmlResults...) | |
| } else { | |
| // Skip tool calls in unclosed thinking block | |
| content.WriteString(s[pos:thinkStart]) | |
| } | |
| break | |
| } | |
| thinkEnd += thinkStart + len(startThink) | |
| // Extract thinking content | |
| thinkingContent := s[thinkStart+len(startThink) : thinkEnd] | |
| reasoningContent.WriteString(thinkingContent) | |
| // Check for tool calls between thinking blocks | |
| betweenContent := s[pos:thinkStart] | |
| if len(betweenContent) > 0 { | |
| xmlResults, _ := parseXMLWithFormat(betweenContent, format) | |
| results = append(results, xmlResults...) | |
| content.WriteString(betweenContent) | |
| } | |
| // Check for tool calls after thinking block | |
| pos = thinkEnd + len(endThink) | |
| } | |
| return results, reasoningContent.String(), nil | |
| } | |
| // parseGLM45Parameters handles GLM 4.5 format with <arg_key> and <arg_value> pairs | |
| func parseGLM45Parameters(content string, format *XMLToolCallFormat) (map[string]any, error) { | |
| args := make(map[string]any) | |
| // Pattern: <arg_key>key</arg_key><arg_value>value</arg_value> | |
| pattern := regexp.MustCompile(`(?s)<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>`) | |
| matches := pattern.FindAllStringSubmatch(content, -1) | |
| for _, match := range matches { | |
| if len(match) >= 3 { | |
| paramName := strings.TrimSpace(match[1]) | |
| paramValue := strings.TrimSpace(match[2]) | |
| args[paramName] = parseParameterValue(paramValue, format) | |
| } | |
| } | |
| return args, nil | |
| } | |
| // parseStandardParameters handles standard parameter formats | |
| func parseStandardParameters(content string, format *XMLToolCallFormat) (map[string]any, error) { | |
| args := make(map[string]any) | |
| escapeRegex := func(str string) string { | |
| return regexp.QuoteMeta(str) | |
| } | |
| // Build parameter patterns - try both primary and alternative endings | |
| var parameterPatterns []*regexp.Regexp | |
| if strings.Contains(format.KeyStart, "=") { | |
| // Format: <parameter=name>value</parameter> | |
| patternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^>]+)` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(format.ValEnd) | |
| parameterPatterns = append(parameterPatterns, regexp.MustCompile(patternStr)) | |
| // Add alternative ending if specified | |
| if format.LastValEnd != nil && *format.LastValEnd != "" { | |
| altPatternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^>]+)` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(*format.LastValEnd) | |
| parameterPatterns = append(parameterPatterns, regexp.MustCompile(altPatternStr)) | |
| } | |
| } else if strings.Contains(format.KeyStart, "name=\"") { | |
| // Format: <parameter name="name">value</parameter> | |
| patternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^"]+)"` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(format.ValEnd) | |
| parameterPatterns = append(parameterPatterns, regexp.MustCompile(patternStr)) | |
| // Add alternative ending if specified | |
| if format.LastValEnd != nil && *format.LastValEnd != "" { | |
| altPatternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^"]+)"` + escapeRegex(format.KeyValSep) + `(.*?)` + escapeRegex(*format.LastValEnd) | |
| parameterPatterns = append(parameterPatterns, regexp.MustCompile(altPatternStr)) | |
| } | |
| } else { | |
| // Fallback: try to match key_start...key_val_sep...val_end | |
| patternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^` + escapeRegex(format.KeyValSep) + `]+)` + escapeRegex(format.KeyValSep) | |
| if format.KeyValSep2 != nil { | |
| patternStr += escapeRegex(*format.KeyValSep2) | |
| } | |
| patternStr += `(.*?)` + escapeRegex(format.ValEnd) | |
| parameterPatterns = append(parameterPatterns, regexp.MustCompile(patternStr)) | |
| // Add alternative ending if specified | |
| if format.LastValEnd != nil && *format.LastValEnd != "" { | |
| altPatternStr := `(?s)` + escapeRegex(format.KeyStart) + `([^` + escapeRegex(format.KeyValSep) + `]+)` + escapeRegex(format.KeyValSep) | |
| if format.KeyValSep2 != nil { | |
| altPatternStr += escapeRegex(*format.KeyValSep2) | |
| } | |
| altPatternStr += `(.*?)` + escapeRegex(*format.LastValEnd) | |
| parameterPatterns = append(parameterPatterns, regexp.MustCompile(altPatternStr)) | |
| } | |
| } | |
| // Track which parameters we've parsed to avoid duplicates | |
| // Use a map to store position info so we can handle last_val_end correctly | |
| type paramMatch struct { | |
| name string | |
| value string | |
| position int | |
| } | |
| var allMatches []paramMatch | |
| // Collect all matches from all patterns | |
| for _, pattern := range parameterPatterns { | |
| matches := pattern.FindAllStringSubmatch(content, -1) | |
| for _, match := range matches { | |
| if len(match) >= 3 { | |
| paramName := strings.TrimSpace(match[1]) | |
| paramValue := strings.TrimSpace(match[2]) | |
| // Find the position of this match in the content | |
| pos := strings.Index(content, match[0]) | |
| if pos != -1 { | |
| allMatches = append(allMatches, paramMatch{ | |
| name: paramName, | |
| value: paramValue, | |
| position: pos, | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| // Sort by position to process in order | |
| // If we have last_val_end, the last parameter should use it | |
| // For now, we'll use the first match for each parameter name (primary pattern takes precedence) | |
| seenParams := make(map[string]bool) | |
| for _, match := range allMatches { | |
| if !seenParams[match.name] { | |
| args[match.name] = parseParameterValue(match.value, format) | |
| seenParams[match.name] = true | |
| } | |
| } | |
| return args, nil | |
| } | |
| // parseParameterValue parses a parameter value based on format configuration | |
| // Implements JSON-first parsing: tries JSON parsing first (if raw_argval is false/null), | |
| // validates JSON is complete, then falls back to text parsing. | |
| // This matches llama.cpp's behavior in chat-parser-xml-toolcall.cpp lines 501-555 | |
| func parseParameterValue(paramValue string, format *XMLToolCallFormat) any { | |
| // Trim if configured | |
| if format.TrimRawArgVal { | |
| paramValue = strings.TrimSpace(paramValue) | |
| } | |
| // Handle raw_argval option | |
| if format.RawArgVal != nil { | |
| if *format.RawArgVal { | |
| // Raw string only - no JSON parsing | |
| return paramValue | |
| } | |
| // raw_argval is false - JSON only, must be valid JSON | |
| var jsonValue any | |
| if err := json.Unmarshal([]byte(paramValue), &jsonValue); err == nil { | |
| // Valid JSON - return parsed value (including primitives) | |
| return jsonValue | |
| } | |
| // JSON parsing failed but raw_argval is false - return as string anyway | |
| // (llama.cpp would throw an error, but we're more lenient) | |
| return paramValue | |
| } | |
| // Default: raw_argval is nil - try JSON first, fallback to text | |
| // This matches llama.cpp's behavior where both are allowed when raw_argval is nullopt | |
| var jsonValue any | |
| if err := json.Unmarshal([]byte(paramValue), &jsonValue); err != nil { | |
| // Not valid JSON, treat as plain text string | |
| return paramValue | |
| } | |
| // Valid JSON was parsed - return the parsed value | |
| // This includes objects, arrays, and primitives (null, true, false, numbers, strings) | |
| // This matches llama.cpp's behavior where JSON values (including primitives) are used as-is | |
| return jsonValue | |
| } | |
| func ParseFunctionCall(llmresult string, functionConfig FunctionsConfig) []FuncCallResults { | |
| xlog.Debug("LLM result", "result", llmresult) | |
| for _, item := range functionConfig.ReplaceFunctionResults { | |
| k, v := item.Key, item.Value | |
| xlog.Debug("Replacing", "key", k, "value", v) | |
| re := regexp.MustCompile(k) | |
| llmresult = re.ReplaceAllString(llmresult, v) | |
| } | |
| xlog.Debug("LLM result(function cleanup)", "result", llmresult) | |
| functionNameKey := defaultFunctionNameKey | |
| functionArgumentsKey := defaultFunctionArgumentsKey | |
| if functionConfig.FunctionNameKey != "" { | |
| functionNameKey = functionConfig.FunctionNameKey | |
| } | |
| if functionConfig.FunctionArgumentsKey != "" { | |
| functionArgumentsKey = functionConfig.FunctionArgumentsKey | |
| } | |
| results := []FuncCallResults{} | |
| llmResults := []string{} | |
| extractJSON := func(results []string) (result []FuncCallResults, e error) { | |
| // As we have to change the result before processing, we can't stream the answer token-by-token (yet?) | |
| result = make([]FuncCallResults, 0) | |
| for _, s := range results { | |
| var ss []map[string]any | |
| s = utils.EscapeNewLines(s) | |
| ss, err := ParseJSON(s) | |
| //err := json.Unmarshal([]byte(s), &ss) | |
| if err != nil { | |
| xlog.Debug("unable to unmarshal llm result in a single object or an array of JSON objects", "error", err, "escapedLLMResult", s) | |
| } | |
| xlog.Debug("Function return", "result", s, "parsed", ss) | |
| for _, s := range ss { | |
| // The grammar defines the function name as "function", while OpenAI returns "name" | |
| func_name, ok := s[functionNameKey] | |
| if !ok { | |
| continue | |
| //return result, fmt.Errorf("unable to find function name in result") | |
| } | |
| // Arguments from grammar result is a map[string]interface{}, but OpenAI expects a stringified JSON object | |
| // We marshal it to JSON string here to match OpenAI's format | |
| args, ok := s[functionArgumentsKey] | |
| if !ok { | |
| continue | |
| //return result, fmt.Errorf("unable to find arguments in result") | |
| } | |
| // Marshal arguments to JSON string (handles both object and string cases) | |
| var d []byte | |
| if argsStr, ok := args.(string); ok { | |
| // Already a string, use it directly | |
| d = []byte(argsStr) | |
| } else { | |
| // Object, marshal to JSON | |
| d, _ = json.Marshal(args) | |
| } | |
| funcName, ok := func_name.(string) | |
| if !ok { | |
| continue | |
| //return result, fmt.Errorf("unable to cast function name to string") | |
| } | |
| result = append(result, FuncCallResults{Name: funcName, Arguments: string(d)}) | |
| } | |
| } | |
| return result, nil | |
| } | |
| // the response is a string that we have to parse | |
| result := make(map[string]string) | |
| if len(functionConfig.JSONRegexMatch) != 0 { | |
| for _, r := range functionConfig.JSONRegexMatch { | |
| // We use a regex to extract the JSON object from the response | |
| var respRegex = regexp.MustCompile(r) | |
| match := respRegex.FindAllStringSubmatch(llmresult, -1) | |
| var allMatches []string | |
| for _, m := range match { | |
| if len(m) > 1 { | |
| // we match the first group | |
| allMatches = append(allMatches, m[1]) | |
| } | |
| } | |
| if len(allMatches) > 0 { | |
| llmResults = append(llmResults, allMatches...) | |
| break | |
| } | |
| } | |
| } | |
| if len(functionConfig.ResponseRegex) > 0 { | |
| // We use named regexes here to extract the function name and arguments | |
| // obviously, this expects the LLM to be stable and return correctly formatted JSON | |
| // Pre-compile regexes for better performance | |
| compiledRegexes := make([]*regexp.Regexp, 0, len(functionConfig.ResponseRegex)) | |
| for _, r := range functionConfig.ResponseRegex { | |
| compiledRegexes = append(compiledRegexes, regexp.MustCompile(r)) | |
| } | |
| for _, respRegex := range compiledRegexes { | |
| matches := respRegex.FindAllStringSubmatch(llmresult, -1) | |
| for _, match := range matches { | |
| for i, name := range respRegex.SubexpNames() { | |
| if i != 0 && name != "" && len(match) > i { | |
| result[name] = match[i] | |
| } | |
| } | |
| functionName := result[functionNameKey] | |
| if functionName == "" { | |
| return results | |
| } | |
| results = append(results, FuncCallResults{Name: result[functionNameKey], Arguments: ParseFunctionCallArgs(result[functionArgumentsKey], functionConfig)}) | |
| } | |
| } | |
| } else { | |
| if len(llmResults) == 0 { | |
| llmResults = append(llmResults, llmresult) | |
| } | |
| results, _ = extractJSON(llmResults) | |
| } | |
| // Determine which XML format to use (if any) | |
| var xmlFormat *XMLToolCallFormat | |
| if functionConfig.XMLFormat != nil { | |
| // Custom format specified | |
| xmlFormat = functionConfig.XMLFormat | |
| xlog.Debug("Using custom XML format") | |
| } else if functionConfig.XMLFormatPreset != "" { | |
| // Preset format specified | |
| xmlFormat = GetXMLFormatPreset(functionConfig.XMLFormatPreset) | |
| if xmlFormat == nil { | |
| xlog.Debug("Unknown XML format preset, falling back to auto-detection", "preset", functionConfig.XMLFormatPreset) | |
| } else { | |
| xlog.Debug("Using XML format preset", "preset", functionConfig.XMLFormatPreset) | |
| } | |
| } | |
| // If xmlFormat is still nil, ParseXML will auto-detect | |
| // If no results from JSON parsing, try XML parsing | |
| // This handles cases where the response contains XML tool calls instead of JSON, | |
| // or mixed content with XML tool calls | |
| // Skip XML parsing if JSONRegexMatch or ResponseRegex was used and found results (to avoid double-parsing) | |
| // ResponseRegex extracts content that might look like XML (e.g., <function=name>args</function>) | |
| // but we've already parsed it, so we shouldn't try XML parsing on the same content | |
| skipXMLParsing := (len(functionConfig.JSONRegexMatch) > 0 || len(functionConfig.ResponseRegex) > 0) && len(results) > 0 | |
| if len(results) == 0 && !skipXMLParsing { | |
| xmlResults, err := ParseXML(llmresult, xmlFormat) | |
| if err == nil && len(xmlResults) > 0 { | |
| xlog.Debug("Found XML tool calls", "count", len(xmlResults)) | |
| results = append(results, xmlResults...) | |
| } | |
| } else if len(results) > 0 && !skipXMLParsing { | |
| // Even if we found JSON results, check for XML tool calls in the response | |
| // This handles mixed content scenarios (text + JSON + XML) | |
| // But skip if JSONRegexMatch or ResponseRegex was used (they already extracted the content) | |
| xmlResults, err := ParseXML(llmresult, xmlFormat) | |
| if err == nil && len(xmlResults) > 0 { | |
| // Check if JSON is inside XML tags, if so, skip it | |
| for _, result := range xmlResults { | |
| jsonResults, _ := extractJSON([]string{result.Name}) | |
| if len(jsonResults) > 0 { | |
| xlog.Debug("Found valid JSON inside XML tags, skipping XML parsing", "json_count", len(jsonResults)) | |
| } else { | |
| xlog.Debug("Found additional XML tool calls alongside JSON", "xml_count", len(xmlResults)) | |
| results = append(results, xmlResults...) | |
| } | |
| } | |
| } | |
| } | |
| return results | |
| } | |
| func ParseFunctionCallArgs(functionArguments string, functionConfig FunctionsConfig) string { | |
| // Clean up double curly braces (common issue with template engines) | |
| // Replace {{ with { and }} with } but only if they appear at the start/end | |
| // This handles cases like {{"key":"value"}} -> {"key":"value"} | |
| cleaned := functionArguments | |
| //if strings.HasPrefix(cleaned, "{{") && strings.HasSuffix(cleaned, "}}") { | |
| // Check if it's double braces at the boundaries | |
| // cleaned = strings.TrimPrefix(cleaned, "{") | |
| // cleaned = strings.TrimSuffix(cleaned, "}") | |
| //} | |
| if len(functionConfig.ArgumentRegex) == 0 { | |
| return cleaned | |
| } | |
| // We use named regexes here to extract the function argument key value pairs and convert this to valid json. | |
| // TODO: there might be responses where an object as a value is expected/required. This is currently not handled. | |
| args := make(map[string]string) | |
| agrsRegexKeyName := "key" | |
| agrsRegexValueName := "value" | |
| if functionConfig.ArgumentRegexKey != "" { | |
| agrsRegexKeyName = functionConfig.ArgumentRegexKey | |
| } | |
| if functionConfig.ArgumentRegexValue != "" { | |
| agrsRegexValueName = functionConfig.ArgumentRegexValue | |
| } | |
| for _, r := range functionConfig.ArgumentRegex { | |
| var respRegex = regexp.MustCompile(r) | |
| var nameRange []string = respRegex.SubexpNames() | |
| var keyIndex = slices.Index(nameRange, agrsRegexKeyName) | |
| var valueIndex = slices.Index(nameRange, agrsRegexValueName) | |
| matches := respRegex.FindAllStringSubmatch(functionArguments, -1) | |
| for _, match := range matches { | |
| args[match[keyIndex]] = match[valueIndex] | |
| } | |
| } | |
| jsonBytes, _ := json.Marshal(args) | |
| return string(jsonBytes) | |
| } | |