diff --git a/internal/gateway/textcalls.go b/internal/gateway/textcalls.go
index 38863d74..52c0fe3e 100644
--- a/internal/gateway/textcalls.go
+++ b/internal/gateway/textcalls.go
@@ -12,7 +12,9 @@ import (
// A model deep into a long conversation may write its tool calls into its
// text instead of making them (#823, Dazzle-sys: DeepSeek through a group,
// in Pi, ~700k tokens in): Hermes' {"name": …, "arguments": …}
-// , or DeepSeek's own <|DSML|invoke name="…"> with its
+// , GLM's name……
+// (#906), or DeepSeek's own <|DSML|invoke
+// name="…"> with its
// parameters, often with the other's closing tags strewn after it. The
// agent got it as text, ran nothing and ended the turn; told to go on, the
// model read its own text-written call back and wrote the next one the
@@ -122,6 +124,10 @@ func parseTextCalls(s string, names map[string]bool) (calls []writtenCall, rest
continue
}
c, n, good := hermesCall(s[h+len(""):], names)
+ if !good && !strings.HasPrefix(strings.TrimLeft(s[h+len(""):], " \t\r\n"), "{") {
+ // GLM's own: the name, then its arguments in pairs (#906)
+ c, n, good = glmCall(s[h+len(""):], names)
+ }
if !good {
return nil, "", false
}
@@ -203,6 +209,53 @@ func hermesCall(s string, names map[string]bool) (writtenCall, int, bool) {
return writtenCall{Name: v.Name, Args: args}, n, true
}
+var (
+ glmPair = regexp.MustCompile(`(?s)^\s*(.*?)\s*(.*?)`)
+ glmName = regexp.MustCompile(`^\s*([^\s<>{}"]+)\s*`)
+)
+
+// glmCall reads GLM's own call (#906, nullburn: GLM-5.3-Flash through
+// vLLM wrote them into its text in Codex): the tool's name right after
+// , then …… for
+// each argument, then . A value that is JSON (a number, an
+// object, a list, true) is taken as it, as GLM's template writes them;
+// else it is the string it says. It says how much of s it took.
+func glmCall(s string, names map[string]bool) (writtenCall, int, bool) {
+ m := glmName.FindStringSubmatchIndex(s)
+ if m == nil || !names[s[m[2]:m[3]]] {
+ return writtenCall{}, 0, false
+ }
+ name, n := s[m[2]:m[3]], m[1]
+ args := map[string]any{}
+ for {
+ p := glmPair.FindStringSubmatchIndex(s[n:])
+ if p == nil {
+ break
+ }
+ key, val := strings.TrimSpace(s[n+p[2]:n+p[3]]), s[n+p[4]:n+p[5]]
+ var v any
+ if json.Unmarshal([]byte(strings.TrimSpace(val)), &v) == nil {
+ args[key] = v
+ } else {
+ args[key] = val
+ }
+ n += p[1]
+ }
+ t := strings.TrimLeft(s[n:], " \t\r\n")
+ switch {
+ case strings.HasPrefix(t, ""):
+ n = len(s) - len(t) + len("")
+ case t != "" && !strings.HasPrefix(t, ""):
+ // something else follows the name: not a call of GLM's
+ return writtenCall{}, 0, false
+ }
+ b, err := json.Marshal(args)
+ if err != nil {
+ return writtenCall{}, 0, false
+ }
+ return writtenCall{Name: name, Args: b}, n, true
+}
+
// dsmlCall reads DeepSeek's <|DSML|invoke name="…"> and its parameters,
// up to its close, the next invoke or the end of s.
func dsmlCall(s, name string, open int, names map[string]bool) (writtenCall, int, bool) {
diff --git a/internal/gateway/textcalls_glm_test.go b/internal/gateway/textcalls_glm_test.go
new file mode 100644
index 00000000..439428c6
--- /dev/null
+++ b/internal/gateway/textcalls_glm_test.go
@@ -0,0 +1,183 @@
+package gateway
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/yetone/magpie/internal/provider"
+)
+
+// glmExec is GLM's own call of Codex's exec, as GLM-5.3-Flash through vLLM
+// wrote it into its text (#906, nullburn).
+const glmExec = "exec\ninput\nconst r = await tools.shell({cmd: \"ls\"});\nconsole.log(r);\n"
+
+const glmExecInput = "const r = await tools.shell({cmd: \"ls\"});\nconsole.log(r);"
+
+// responsesItems sends body to the gateway's Responses endpoint, upstream
+// answering up, and gives back the output items the client read.
+func responsesItems(t *testing.T, body string, up ...string) (items []map[string]any, raw string) {
+ t.Helper()
+ fresh(t)
+ a := &scripted{replies: []reply{{200, "text/event-stream", sse(append(up, `data: [DONE]`)...)}}}
+ scriptedOn(t, "a", provider.Chat, a)
+ rec := httptest.NewRecorder()
+ New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/responses", strings.NewReader(body)))
+ if rec.Code != 200 {
+ t.Fatalf("%d %s", rec.Code, rec.Body.String())
+ }
+ raw = rec.Body.String()
+ if strings.Contains(body, `"stream":true`) {
+ for _, line := range strings.Split(raw, "\n") {
+ data, ok := strings.CutPrefix(line, "data: ")
+ if !ok {
+ continue
+ }
+ var ev struct {
+ Type string `json:"type"`
+ Item map[string]any `json:"item"`
+ }
+ if json.Unmarshal([]byte(data), &ev) == nil && ev.Type == "response.output_item.done" {
+ items = append(items, ev.Item)
+ }
+ }
+ return items, raw
+ }
+ var r struct {
+ Output []map[string]any `json:"output"`
+ }
+ if err := json.Unmarshal([]byte(raw), &r); err != nil {
+ t.Fatalf("unreadable reply: %v\n%s", err, raw)
+ }
+ return r.Output, raw
+}
+
+// Codex's exec is a custom tool: GLM's call of it, its tags split across
+// chunks, reaches Codex as the custom_tool_call it is, with the code as
+// its input, after the text before it — streamed and whole.
+func TestGLMTextCallCustomTool(t *testing.T) {
+ text := "Let me look.\n\n" + glmExec
+ for _, stream := range []string{"true", "false"} {
+ items, raw := responsesItems(t, `{"model":"a/m","stream":`+stream+`,"input":"list the files",`+
+ `"tools":[{"type":"custom","name":"exec","description":"Run JavaScript.","format":{"type":"grammar","syntax":"lark","definition":"start: /[\\s\\S]+/"}}]}`,
+ chunkOf(text[:20]), chunkOf(text[20:31]), chunkOf(text[31:52]), chunkOf(text[52:]), stopChunk)
+ var call map[string]any
+ var said string
+ for _, it := range items {
+ switch it["type"] {
+ case "custom_tool_call":
+ call = it
+ case "message":
+ b, _ := json.Marshal(it["content"])
+ said += string(b)
+ }
+ }
+ if call == nil || call["name"] != "exec" || call["input"] != glmExecInput {
+ t.Fatalf("stream=%s call %v\n%s", stream, call, raw)
+ }
+ if !strings.Contains(said, "Let me look.") || strings.Contains(raw, "arg_key") || strings.Contains(raw, "tool_call>") {
+ t.Fatalf("stream=%s text %s\n%s", stream, said, raw)
+ }
+ }
+}
+
+// A function tool's arguments: a value that is JSON is kept as JSON, any
+// other is the string it says; two calls in a row are both made.
+func TestGLMTextCallFunctionTool(t *testing.T) {
+ items, raw := responsesItems(t, `{"model":"a/m","stream":true,"input":"go",`+
+ `"tools":[{"type":"function","name":"shell","parameters":{"type":"object"}},{"type":"function","name":"read","parameters":{"type":"object"}}]}`,
+ chunkOf("shellcommand[\"ls\",\"-la\"]timeout_ms"),
+ chunkOf("5000workdir/tmp/x\nread\npath\na.md\n"),
+ stopChunk)
+ var calls []map[string]any
+ for _, it := range items {
+ if it["type"] == "function_call" {
+ calls = append(calls, it)
+ }
+ }
+ if len(calls) != 2 || calls[0]["name"] != "shell" || calls[1]["name"] != "read" || strings.Contains(raw, "arg_value") {
+ t.Fatalf("calls %v\n%s", calls, raw)
+ }
+ var shell map[string]any
+ if err := json.Unmarshal([]byte(calls[0]["arguments"].(string)), &shell); err != nil {
+ t.Fatalf("arguments %v: %v", calls[0]["arguments"], err)
+ }
+ cmd, _ := shell["command"].([]any)
+ if len(cmd) != 2 || cmd[0] != "ls" || shell["timeout_ms"] != float64(5000) || shell["workdir"] != "/tmp/x" {
+ t.Fatalf("shell %v", shell)
+ }
+ if calls[1]["arguments"] != `{"path":"a.md"}` {
+ t.Fatalf("read %v", calls[1]["arguments"])
+ }
+}
+
+// Chat's relay and Anthropic's translation read GLM's call as well.
+func TestGLMTextCallChatAndMessages(t *testing.T) {
+ text, calls, finish, raw := relayedChat(t, `{"model":"a/m","stream":true,"messages":[{"role":"user","content":"hi"}],`+textCallTools+`}`,
+ chunkOf("Looking.bashcom"), chunkOf("mandls -la"), stopChunk)
+ if text != "Looking." || calls["bash"] != `{"command":"ls -la"}` || finish != "tool_calls" || strings.Contains(raw, "arg_key") {
+ t.Fatalf("chat: text %q calls %v finish %q\n%s", text, calls, finish, raw)
+ }
+ fresh(t)
+ up := sse(chunkOf("bash\ncommand\nls\n"), stopChunk, `data: [DONE]`)
+ a := &scripted{replies: []reply{{200, "text/event-stream", up}}}
+ scriptedOn(t, "a", provider.Chat, a)
+ for _, stream := range []string{"true", "false"} {
+ a.replies = []reply{{200, "text/event-stream", up}}
+ rec := httptest.NewRecorder()
+ New().Handler().ServeHTTP(rec, httptest.NewRequest("POST", "/v1/messages", strings.NewReader(
+ `{"model":"a/m","max_tokens":100,"stream":`+stream+`,"messages":[{"role":"user","content":"hi"}],"tools":[{"name":"bash","input_schema":{"type":"object"}}]}`)))
+ body := rec.Body.String()
+ if rec.Code != 200 || strings.Contains(body, "arg_key") || !strings.Contains(body, `"tool_use"`) || !strings.Contains(body, `"stop_reason":"tool_use"`) {
+ t.Fatalf("messages stream=%s %d %s", stream, rec.Code, body)
+ }
+ }
+}
+
+// A GLM block naming a tool the request didn't offer stays the text it was.
+func TestGLMTextCallUnofferedLeftAsText(t *testing.T) {
+ block := "rm_rfpath/"
+ text, calls, finish, _ := relayedChat(t, `{"model":"a/m","stream":true,"messages":[{"role":"user","content":"hi"}],`+textCallTools+`}`,
+ chunkOf("Like this: "+block), stopChunk)
+ if text != "Like this: "+block || len(calls) != 0 || finish != "stop" {
+ t.Fatalf("text %q calls %v finish %q", text, calls, finish)
+ }
+ items, raw := responsesItems(t, `{"model":"a/m","stream":true,"input":"go","tools":[{"type":"custom","name":"exec"}]}`,
+ chunkOf(block), stopChunk)
+ for _, it := range items {
+ if it["type"] != "message" {
+ t.Fatalf("item %v\n%s", it, raw)
+ }
+ }
+ if !strings.Contains(raw, "rm_rf") {
+ t.Fatalf("text lost: %s", raw)
+ }
+}
+
+// A plugin's model writing GLM's call is read the same: Codex gets its
+// custom tool call.
+func TestPluginGLMTextCall(t *testing.T) {
+ pid := besideFake(t, "glm-local", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ for _, part := range []string{glmExec[:15], glmExec[15:]} {
+ b, _ := json.Marshal(map[string]any{"id": "c1", "object": "chat.completion.chunk", "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": part}, "finish_reason": nil}}})
+ io.WriteString(w, "data: "+string(b)+"\n\n")
+ }
+ io.WriteString(w, `data: {"id":"c1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`+"\n\ndata: [DONE]\n\n")
+ }))
+ req := httptest.NewRequest("POST", "/v1/responses", strings.NewReader(`{"model":"`+pid+`/fake-1","stream":true,"input":"go","tools":[{"type":"custom","name":"exec"}]}`))
+ req.Header.Set("Authorization", "Bearer magpie")
+ rec := httptest.NewRecorder()
+ New().Handler().ServeHTTP(rec, req)
+ body := rec.Body.String()
+ if rec.Code != 200 || strings.Contains(body, "arg_key") || !strings.Contains(body, `"custom_tool_call"`) {
+ t.Fatalf("%d %s", rec.Code, body)
+ }
+ in, _ := json.Marshal(glmExecInput)
+ if !strings.Contains(body, `"input":`+string(in)) {
+ t.Fatalf("input: %s", body)
+ }
+}