diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md
index 15259df..5a1f442 100644
--- a/agent-contract/outer/openai-compatible-api.md
+++ b/agent-contract/outer/openai-compatible-api.md
@@ -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"`를 반환한다.
-지원하는 텍스트 블록의 최소 형태는 `>>JSON-or-text`이다.
+지원하는 텍스트 블록의 최소 형태는 `>>JSON-or-text` 또는 `{{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 실행 의미에는 반영하지 않는다.
diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go
index 63b7661..66c770b 100644
--- a/apps/edge/internal/openai/server_test.go
+++ b/apps/edge/internal/openai/server_test.go
@@ -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: "true"}
@@ -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)
diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go
index 8bbc067..ab2efe9 100644
--- a/apps/edge/internal/openai/types.go
+++ b/apps/edge/internal/openai/types.go
@@ -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 = "(.*?)<\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 = " 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