fix(openai): Cline 템플릿 툴 호출을 지원한다
This commit is contained in:
parent
135259619e
commit
65172918ed
3 changed files with 763 additions and 14 deletions
|
|
@ -167,7 +167,8 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
|
|||
|
||||
`tools`가 있는 Chat Completions 요청에서 Edge는 backend-native tool call stream을 일반 구조화 이벤트로 중계하지 않는다.
|
||||
단, backend가 assistant content에 Cline-style 텍스트 tool call 블록을 출력하고 그 function name이 요청의 `tools[].function.name`에 포함되어 있으면 Edge는 해당 블록을 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 변환하고 `finish_reason: "tool_calls"`를 반환한다.
|
||||
지원하는 텍스트 블록의 최소 형태는 `<tool_call><function=<name>><parameter=<key>>JSON-or-text</parameter></function></tool_call>`이다.
|
||||
지원하는 텍스트 블록의 최소 형태는 `<tool_call><function=<name>><parameter=<key>>JSON-or-text</parameter></function></tool_call>` 또는 `{{function_name(key=Python/JSON-like-literal)}}`이다.
|
||||
템플릿 호출 형태의 인자는 single-quoted string, `True`/`False`/`None`, 배열, 객체를 JSON `arguments` 문자열로 정규화한다.
|
||||
호환성을 위해 `tool_choice`가 생략되었거나 `auto`/강제 tool 선택 형태로 들어오면 내부 실행 입력에서는 `tool_choice: "none"`으로 낮춰 백엔드의 auto tool-calling 요구 조건에 의해 요청 전체가 실패하지 않게 한다.
|
||||
`parallel_tool_calls`와 `stream_options`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,101 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "사용자가 변경 내용을 푸시해달라고 요청하셨습니다. 먼저 현재 git 상태를 확인하여 변경된 파일과 커밋 가능한 내용을 파악하겠습니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"messages":[{"role":"user","content":"push changes"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
|
||||
"tool_choice":"auto"
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp chatCompletionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
choice := resp.Choices[0]
|
||||
if choice.FinishReason != "tool_calls" {
|
||||
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
|
||||
}
|
||||
if strings.Contains(choice.Message.Content, "{{run_commands") {
|
||||
t.Fatalf("content still contains raw template tool call: %q", choice.Message.Content)
|
||||
}
|
||||
if len(choice.Message.ToolCalls) != 1 {
|
||||
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
|
||||
}
|
||||
call, ok := choice.Message.ToolCalls[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
|
||||
}
|
||||
fn, ok := call["function"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("function shape: %+v", call["function"])
|
||||
}
|
||||
if fn["name"] != "run_commands" {
|
||||
t.Fatalf("function name: got %+v", fn["name"])
|
||||
}
|
||||
var args map[string][]map[string]any
|
||||
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
||||
t.Fatalf("arguments JSON: %v", err)
|
||||
}
|
||||
commands := args["commands"]
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("commands len: got %d", len(commands))
|
||||
}
|
||||
if commands[0]["command"] != "git status" || commands[0]["description"] != "현재 git 상태 확인" || commands[0]["runInTerminal"] != true {
|
||||
t.Fatalf("commands args: %+v", commands[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) {
|
||||
content := "확인합니다.\n\n{{run_commands(commands=[{'command': \"printf ')}}'\", 'description': 'marker test', 'runInTerminal': False}])}}"
|
||||
tools := []any{map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "run_commands",
|
||||
},
|
||||
}}
|
||||
|
||||
cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test")
|
||||
|
||||
if cleaned != "확인합니다." {
|
||||
t.Fatalf("content: got %q", cleaned)
|
||||
}
|
||||
if len(toolCalls) != 1 {
|
||||
t.Fatalf("tool_calls len: got %d", len(toolCalls))
|
||||
}
|
||||
call, ok := toolCalls[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool call shape: %+v", toolCalls[0])
|
||||
}
|
||||
fn, ok := call["function"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("function shape: %+v", call["function"])
|
||||
}
|
||||
var args map[string][]map[string]any
|
||||
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
||||
t.Fatalf("arguments JSON: %v", err)
|
||||
}
|
||||
commands := args["commands"]
|
||||
if len(commands) != 1 {
|
||||
t.Fatalf("commands len: got %d", len(commands))
|
||||
}
|
||||
if commands[0]["command"] != "printf ')}}'" || commands[0]["runInTerminal"] != false {
|
||||
t.Fatalf("commands args: %+v", commands[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=delete_everything><parameter=confirm>true</parameter></function></tool_call>"}
|
||||
|
|
@ -352,6 +447,36 @@ func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"qwen3.6:35b",
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands"}}]
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp chatCompletionResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 {
|
||||
t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0])
|
||||
}
|
||||
if !strings.Contains(resp.Choices[0].Message.Content, "{{delete_everything") {
|
||||
t.Fatalf("raw unknown template tool call should remain content: %q", resp.Choices[0].Message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsMapsMaxCompletionTokens(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
||||
|
|
@ -441,6 +566,44 @@ func TestChatCompletionsStreamSynthesizesToolCallsFromClineTextBlock(t *testing.
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n"}
|
||||
fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
|
||||
fake.events <- &iop.RunEvent{Type: "complete"}
|
||||
|
||||
srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil)
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
||||
"model":"stream-model",
|
||||
"stream":true,
|
||||
"messages":[{"role":"user","content":"status"}],
|
||||
"tools":[{"type":"function","function":{"name":"run_commands"}}]
|
||||
}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
srv.handleChatCompletions(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) {
|
||||
t.Fatalf("stream did not synthesize tool_calls:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "{{run_commands") {
|
||||
t.Fatalf("stream leaked raw template tool call:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"index":0`) {
|
||||
t.Fatalf("stream tool_call delta should include index:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `\"runInTerminal\":true`) {
|
||||
t.Fatalf("stream tool_call arguments should include parsed boolean:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) {
|
||||
t.Fatalf("stream should preserve text before tool call:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsStreamingReportsClosedRunStream(t *testing.T) {
|
||||
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
||||
close(fake.events)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -173,18 +175,33 @@ func contentToString(v any) string {
|
|||
}
|
||||
|
||||
var (
|
||||
textToolCallBlockRE = regexp.MustCompile(`(?is)<\s*tool_call\s*>(.*?)<\s*/\s*tool_call\s*>`)
|
||||
textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*function(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
|
||||
textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*parameter(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
|
||||
textToolCallOpeningHint = "<tool_call"
|
||||
textToolCallBlockRE = regexp.MustCompile(`(?is)<\s*tool_call\s*>(.*?)<\s*/\s*tool_call\s*>`)
|
||||
textToolFunctionRE = regexp.MustCompile(`(?is)<\s*function\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*function(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
|
||||
textToolParameterRE = regexp.MustCompile(`(?is)<\s*parameter\s*=\s*([A-Za-z_][A-Za-z0-9_.:-]*)\s*>(.*?)<\s*/\s*parameter(?:\s*=\s*[A-Za-z_][A-Za-z0-9_.:-]*)?\s*>`)
|
||||
textToolCallOpeningHint = "<tool_call"
|
||||
textMustacheToolCallOpenHint = "{{"
|
||||
)
|
||||
|
||||
type textToolCallMatch struct {
|
||||
start int
|
||||
end int
|
||||
name string
|
||||
arguments string
|
||||
}
|
||||
|
||||
type mustacheTextToolCallBlock struct {
|
||||
start int
|
||||
end int
|
||||
name string
|
||||
args string
|
||||
}
|
||||
|
||||
func synthesizeToolCallsFromText(content string, tools []any, runID string) (string, []any) {
|
||||
allowed := requestedToolNames(tools)
|
||||
if len(allowed) == 0 || !strings.Contains(strings.ToLower(content), textToolCallOpeningHint) {
|
||||
if len(allowed) == 0 {
|
||||
return content, nil
|
||||
}
|
||||
matches := textToolCallBlockRE.FindAllStringSubmatchIndex(content, -1)
|
||||
matches := collectTextToolCallMatches(content, allowed)
|
||||
if len(matches) == 0 {
|
||||
return content, nil
|
||||
}
|
||||
|
|
@ -193,19 +210,17 @@ func synthesizeToolCallsFromText(content string, tools []any, runID string) (str
|
|||
cursor := 0
|
||||
toolCalls := make([]any, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
block := content[match[0]:match[1]]
|
||||
name, arguments, ok := parseTextToolCallBlock(block, allowed)
|
||||
if !ok {
|
||||
if match.start < cursor {
|
||||
continue
|
||||
}
|
||||
cleaned.WriteString(content[cursor:match[0]])
|
||||
cursor = match[1]
|
||||
cleaned.WriteString(content[cursor:match.start])
|
||||
cursor = match.end
|
||||
toolCalls = append(toolCalls, map[string]any{
|
||||
"id": textToolCallID(runID, len(toolCalls)+1),
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": name,
|
||||
"arguments": arguments,
|
||||
"name": match.name,
|
||||
"arguments": match.arguments,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -216,6 +231,158 @@ func synthesizeToolCallsFromText(content string, tools []any, runID string) (str
|
|||
return strings.TrimSpace(cleaned.String()), toolCalls
|
||||
}
|
||||
|
||||
func collectTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch {
|
||||
var matches []textToolCallMatch
|
||||
matches = append(matches, collectXMLTextToolCallMatches(content, allowed)...)
|
||||
matches = append(matches, collectMustacheTextToolCallMatches(content, allowed)...)
|
||||
sort.SliceStable(matches, func(i, j int) bool {
|
||||
if matches[i].start == matches[j].start {
|
||||
return matches[i].end > matches[j].end
|
||||
}
|
||||
return matches[i].start < matches[j].start
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func collectXMLTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch {
|
||||
if !strings.Contains(strings.ToLower(content), textToolCallOpeningHint) {
|
||||
return nil
|
||||
}
|
||||
rawMatches := textToolCallBlockRE.FindAllStringSubmatchIndex(content, -1)
|
||||
if len(rawMatches) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]textToolCallMatch, 0, len(rawMatches))
|
||||
for _, rawMatch := range rawMatches {
|
||||
block := content[rawMatch[0]:rawMatch[1]]
|
||||
name, arguments, ok := parseTextToolCallBlock(block, allowed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, textToolCallMatch{
|
||||
start: rawMatch[0],
|
||||
end: rawMatch[1],
|
||||
name: name,
|
||||
arguments: arguments,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func collectMustacheTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch {
|
||||
if !strings.Contains(content, textMustacheToolCallOpenHint) {
|
||||
return nil
|
||||
}
|
||||
blocks := scanMustacheTextToolCallBlocks(content)
|
||||
if len(blocks) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]textToolCallMatch, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
if _, ok := allowed[block.name]; !ok {
|
||||
continue
|
||||
}
|
||||
args, ok := parseMustacheToolCallArguments(block.args)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
encoded, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, textToolCallMatch{
|
||||
start: block.start,
|
||||
end: block.end,
|
||||
name: block.name,
|
||||
arguments: string(encoded),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scanMustacheTextToolCallBlocks(content string) []mustacheTextToolCallBlock {
|
||||
var blocks []mustacheTextToolCallBlock
|
||||
for start := 0; start < len(content); {
|
||||
open := strings.Index(content[start:], textMustacheToolCallOpenHint)
|
||||
if open < 0 {
|
||||
break
|
||||
}
|
||||
blockStart := start + open
|
||||
nameStart := skipASCIISpaces(content, blockStart+len(textMustacheToolCallOpenHint))
|
||||
nameEnd := nameStart
|
||||
for nameEnd < len(content) && isTextToolIdentifierChar(content[nameEnd], nameEnd == nameStart) {
|
||||
nameEnd++
|
||||
}
|
||||
name := content[nameStart:nameEnd]
|
||||
if name == "" {
|
||||
start = blockStart + len(textMustacheToolCallOpenHint)
|
||||
continue
|
||||
}
|
||||
callOpen := skipASCIISpaces(content, nameEnd)
|
||||
if callOpen >= len(content) || content[callOpen] != '(' {
|
||||
start = blockStart + len(textMustacheToolCallOpenHint)
|
||||
continue
|
||||
}
|
||||
argsStart := callOpen + 1
|
||||
argsEnd, blockEnd, ok := findMustacheToolCallEnd(content, argsStart)
|
||||
if !ok {
|
||||
start = blockStart + len(textMustacheToolCallOpenHint)
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, mustacheTextToolCallBlock{
|
||||
start: blockStart,
|
||||
end: blockEnd,
|
||||
name: name,
|
||||
args: content[argsStart:argsEnd],
|
||||
})
|
||||
start = blockEnd
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func findMustacheToolCallEnd(content string, argsStart int) (int, int, bool) {
|
||||
depth := 0
|
||||
var quote byte
|
||||
escaped := false
|
||||
for i := argsStart; i < len(content); i++ {
|
||||
c := content[i]
|
||||
if quote != 0 {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '\'', '"':
|
||||
quote = c
|
||||
case '[', '{', '(':
|
||||
depth++
|
||||
case ']', '}':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case ')':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
continue
|
||||
}
|
||||
closeStart := skipASCIISpaces(content, i+1)
|
||||
if closeStart+1 < len(content) && content[closeStart] == '}' && content[closeStart+1] == '}' {
|
||||
return i, closeStart + 2, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func textToolCallID(runID string, ordinal int) string {
|
||||
normalized := normalizeToolCallIDPart(runID)
|
||||
if normalized == "" {
|
||||
|
|
@ -319,6 +486,424 @@ func parseTextToolParameterValue(raw string) any {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
func parseMustacheToolCallArguments(raw string) (map[string]any, bool) {
|
||||
if args, ok := parseMustacheToolCallArgumentsStrict(raw); ok {
|
||||
return args, true
|
||||
}
|
||||
// Some Cline/provider outputs include one extra "}" before the closing
|
||||
// template parens, e.g. `...True}]})}}`. Recover only after strict parsing.
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if strings.HasSuffix(trimmed, "}") {
|
||||
return parseMustacheToolCallArgumentsStrict(strings.TrimSpace(trimmed[:len(trimmed)-1]))
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func parseMustacheToolCallArgumentsStrict(raw string) (map[string]any, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
args := map[string]any{}
|
||||
if trimmed == "" {
|
||||
return args, true
|
||||
}
|
||||
for _, part := range splitTopLevel(trimmed, ',') {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
eq := indexTopLevel(part, '=')
|
||||
if eq < 0 {
|
||||
return nil, false
|
||||
}
|
||||
key := strings.TrimSpace(part[:eq])
|
||||
if !isTextToolIdentifier(key) {
|
||||
return nil, false
|
||||
}
|
||||
value, ok := parseTextToolLiteral(part[eq+1:])
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
args[key] = value
|
||||
}
|
||||
return args, true
|
||||
}
|
||||
|
||||
func parseTextToolLiteral(raw string) (any, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return "", true
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err == nil {
|
||||
return decoded, true
|
||||
}
|
||||
parser := pythonishLiteralParser{s: trimmed}
|
||||
value, ok := parser.parseValue()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
parser.skipSpaces()
|
||||
if parser.pos != len(parser.s) {
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func splitTopLevel(s string, sep byte) []string {
|
||||
parts := []string{}
|
||||
start := 0
|
||||
depth := 0
|
||||
var quote byte
|
||||
escaped := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if quote != 0 {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '\'', '"':
|
||||
quote = c
|
||||
case '[', '{', '(':
|
||||
depth++
|
||||
case ']', '}', ')':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
case sep:
|
||||
if depth == 0 {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = append(parts, s[start:])
|
||||
return parts
|
||||
}
|
||||
|
||||
func indexTopLevel(s string, target byte) int {
|
||||
depth := 0
|
||||
var quote byte
|
||||
escaped := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if quote != 0 {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case '\'', '"':
|
||||
quote = c
|
||||
case '[', '{', '(':
|
||||
depth++
|
||||
case ']', '}', ')':
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
default:
|
||||
if c == target && depth == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func skipASCIISpaces(s string, pos int) int {
|
||||
for pos < len(s) {
|
||||
switch s[pos] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
pos++
|
||||
default:
|
||||
return pos
|
||||
}
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
type pythonishLiteralParser struct {
|
||||
s string
|
||||
pos int
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) parseValue() (any, bool) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.s) {
|
||||
return nil, false
|
||||
}
|
||||
switch p.s[p.pos] {
|
||||
case '\'', '"':
|
||||
return p.parseString()
|
||||
case '[':
|
||||
return p.parseArray()
|
||||
case '{':
|
||||
return p.parseObject()
|
||||
}
|
||||
if p.consumeWord("True") || p.consumeWord("true") {
|
||||
return true, true
|
||||
}
|
||||
if p.consumeWord("False") || p.consumeWord("false") {
|
||||
return false, true
|
||||
}
|
||||
if p.consumeWord("None") || p.consumeWord("null") {
|
||||
return nil, true
|
||||
}
|
||||
return p.parseNumberOrBare()
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) parseString() (string, bool) {
|
||||
if p.pos >= len(p.s) {
|
||||
return "", false
|
||||
}
|
||||
quote := p.s[p.pos]
|
||||
p.pos++
|
||||
var b strings.Builder
|
||||
for p.pos < len(p.s) {
|
||||
c := p.s[p.pos]
|
||||
p.pos++
|
||||
if c == quote {
|
||||
return b.String(), true
|
||||
}
|
||||
if c != '\\' {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
if p.pos >= len(p.s) {
|
||||
return "", false
|
||||
}
|
||||
esc := p.s[p.pos]
|
||||
p.pos++
|
||||
switch esc {
|
||||
case '\\', '\'', '"':
|
||||
b.WriteByte(esc)
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
case 'b':
|
||||
b.WriteByte('\b')
|
||||
case 'f':
|
||||
b.WriteByte('\f')
|
||||
case 'u':
|
||||
if p.pos+4 > len(p.s) {
|
||||
return "", false
|
||||
}
|
||||
r, err := strconv.ParseInt(p.s[p.pos:p.pos+4], 16, 32)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
b.WriteRune(rune(r))
|
||||
p.pos += 4
|
||||
default:
|
||||
b.WriteByte(esc)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) parseArray() ([]any, bool) {
|
||||
p.pos++
|
||||
items := []any{}
|
||||
p.skipSpaces()
|
||||
if p.consumeByte(']') {
|
||||
return items, true
|
||||
}
|
||||
for {
|
||||
value, ok := p.parseValue()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
items = append(items, value)
|
||||
p.skipSpaces()
|
||||
if p.consumeByte(']') {
|
||||
return items, true
|
||||
}
|
||||
if !p.consumeByte(',') {
|
||||
return nil, false
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.consumeByte(']') {
|
||||
return items, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) parseObject() (map[string]any, bool) {
|
||||
p.pos++
|
||||
obj := map[string]any{}
|
||||
p.skipSpaces()
|
||||
if p.consumeByte('}') {
|
||||
return obj, true
|
||||
}
|
||||
for {
|
||||
key, ok := p.parseObjectKey()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
p.skipSpaces()
|
||||
if !p.consumeByte(':') {
|
||||
return nil, false
|
||||
}
|
||||
value, ok := p.parseValue()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
obj[key] = value
|
||||
p.skipSpaces()
|
||||
if p.consumeByte('}') {
|
||||
return obj, true
|
||||
}
|
||||
if !p.consumeByte(',') {
|
||||
return nil, false
|
||||
}
|
||||
p.skipSpaces()
|
||||
if p.consumeByte('}') {
|
||||
return obj, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) parseObjectKey() (string, bool) {
|
||||
p.skipSpaces()
|
||||
if p.pos >= len(p.s) {
|
||||
return "", false
|
||||
}
|
||||
if p.s[p.pos] == '\'' || p.s[p.pos] == '"' {
|
||||
return p.parseString()
|
||||
}
|
||||
start := p.pos
|
||||
for p.pos < len(p.s) && isTextToolIdentifierChar(p.s[p.pos], p.pos == start) {
|
||||
p.pos++
|
||||
}
|
||||
if start == p.pos {
|
||||
return "", false
|
||||
}
|
||||
return p.s[start:p.pos], true
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) parseNumberOrBare() (any, bool) {
|
||||
start := p.pos
|
||||
for p.pos < len(p.s) && !isLiteralDelimiter(p.s[p.pos]) {
|
||||
p.pos++
|
||||
}
|
||||
if start == p.pos {
|
||||
return nil, false
|
||||
}
|
||||
token := p.s[start:p.pos]
|
||||
if value, ok := parseNumberToken(token); ok {
|
||||
return value, true
|
||||
}
|
||||
if isTextToolIdentifier(token) {
|
||||
return token, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) skipSpaces() {
|
||||
for p.pos < len(p.s) {
|
||||
switch p.s[p.pos] {
|
||||
case ' ', '\n', '\r', '\t':
|
||||
p.pos++
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) consumeByte(b byte) bool {
|
||||
if p.pos < len(p.s) && p.s[p.pos] == b {
|
||||
p.pos++
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *pythonishLiteralParser) consumeWord(word string) bool {
|
||||
if !strings.HasPrefix(p.s[p.pos:], word) {
|
||||
return false
|
||||
}
|
||||
end := p.pos + len(word)
|
||||
if end < len(p.s) && isBareLiteralChar(p.s[end]) {
|
||||
return false
|
||||
}
|
||||
p.pos = end
|
||||
return true
|
||||
}
|
||||
|
||||
func parseNumberToken(token string) (any, bool) {
|
||||
if token == "" {
|
||||
return nil, false
|
||||
}
|
||||
if strings.ContainsAny(token, ".eE") {
|
||||
value, err := strconv.ParseFloat(token, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
value, err := strconv.ParseInt(token, 10, 64)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func isTextToolIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if !isTextToolIdentifierChar(s[i], i == 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isTextToolIdentifierChar(c byte, first bool) bool {
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' {
|
||||
return true
|
||||
}
|
||||
if !first && (c >= '0' && c <= '9' || c == '.' || c == ':' || c == '-') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBareLiteralChar(c byte) bool {
|
||||
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_'
|
||||
}
|
||||
|
||||
func isLiteralDelimiter(c byte) bool {
|
||||
switch c {
|
||||
case ',', ']', '}', ' ', '\n', '\r', '\t':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func previewString(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
|
|
|
|||
Loading…
Reference in a new issue