From 5dbd28c39ebbcde73d9aff95a3d2966ea4f2c153 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 26 Sep 2026 18:45:49 +0800 Subject: [PATCH] =?UTF-8?q?zcode:=20what=20you=20choose=20in=20ZCode=20for?= =?UTF-8?q?=20magpie=20stays=20=E2=80=94=20the=20provider=20turned=20off?= =?UTF-8?q?=20stays=20off,=20its=20rule=20stays=20where=20you=20moved=20it?= =?UTF-8?q?,=20and=20a=20rule=20you=20removed=20isn't=20put=20back=20on=20?= =?UTF-8?q?the=20next=20sync.=20gateway:=20a=20model=20that=20always=20thi?= =?UTF-8?q?nks=20(GLM-5.3=20answers=201210=20to=20thinking=20turned=20off)?= =?UTF-8?q?=20is=20asked=20again=20with=20thinking=20left=20to=20it,=20rat?= =?UTF-8?q?her=20than=20failing.=20Asked=20on=20Discord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/zcode.go | 31 +++++++++++++---- internal/agent/zcode_test.go | 59 ++++++++++++++++++++++++++++++++ internal/gateway/anthropic.go | 19 ++++++++++ internal/gateway/gateway.go | 12 +++++++ internal/gateway/gateway_test.go | 36 +++++++++++++++++++ 5 files changed, 150 insertions(+), 7 deletions(-) diff --git a/internal/agent/zcode.go b/internal/agent/zcode.go index 3a5ef015..42caca30 100644 --- a/internal/agent/zcode.go +++ b/internal/agent/zcode.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "slices" "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/gateway" @@ -64,10 +65,14 @@ func zcode(home string) *Agent { if !wired() { return nil } - if err := zcodeRules(rules, true); err != nil { - return err + // a ZCode that keeps its providers as rules, with magpie's taken + // out there, had it removed in ZCode: it isn't put back + if _, err := os.Stat(rules); err != nil || zcodeRuled(rules) { + if err := zcodeRules(rules, true); err != nil { + return err + } } - return edit.SetJSON(path, edit.KV{Path: key, Value: zcodeProviderJSON()}) + return edit.SetJSON(path, edit.KV{Path: key, Value: zcodeProviderJSON(path)}) }, Fields: []Field{{ Key: "provider", Label: "provider", @@ -84,7 +89,7 @@ func zcode(home string) *Agent { if v == "" { return edit.DelJSON(path, key) } - return edit.SetJSON(path, edit.KV{Path: key, Value: zcodeProviderJSON()}) + return edit.SetJSON(path, edit.KV{Path: key, Value: zcodeProviderJSON(path)}) }, Options: func(map[string]string) []Option { return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie", Note: "every magpie model in ZCode's picker"}} @@ -93,7 +98,9 @@ func zcode(home string) *Agent { } } -func zcodeProviderJSON() any { +// zcodeProviderJSON is magpie's provider in config.json at path; one +// turned off in ZCode stays off. +func zcodeProviderJSON(path string) any { ms := map[string]any{} for _, m := range magpieModels("zcode") { window := m.Context @@ -107,7 +114,11 @@ func zcodeProviderJSON() any { ms[m.ID] = map[string]any{"name": m.Name, "limit": map[string]any{"context": window}, "modalities": map[string]any{"input": in, "output": []string{"text"}}} } - return map[string]any{"name": "magpie", "kind": "anthropic", "enabled": true, "source": "custom", + on := true + if v, ok := edit.GetJSON(path, "provider."+magpieID+".enabled"); ok && v == "false" { + on = false + } + return map[string]any{"name": "magpie", "kind": "anthropic", "enabled": on, "source": "custom", "options": map[string]any{"apiKey": gateway.Token, "baseURL": gateway.URL()}, "models": ms} } @@ -161,9 +172,11 @@ func zcodeRules(path string, on bool) error { var old map[string]any var providers []any + at := -1 // where magpie's was, which it keeps for _, r := range zcodeList(pcr, "providerRules") { if mine(r) { old, _ = r.(map[string]any) + at = len(providers) continue } providers = append(providers, r) @@ -213,7 +226,11 @@ func zcodeRules(path string, on bool) error { if e, ok := old["enabled"].(bool); ok { rule["enabled"] = e } - providers = append(providers, rule) + if at >= 0 { + providers = slices.Insert(providers, at, any(rule)) + } else { + providers = append(providers, rule) + } } pcr["providerRules"] = zcodeNonNil(providers) mcr["providerModelRules"] = zcodeNonNil(models) diff --git a/internal/agent/zcode_test.go b/internal/agent/zcode_test.go index e40cb00f..41643b06 100644 --- a/internal/agent/zcode_test.go +++ b/internal/agent/zcode_test.go @@ -132,3 +132,62 @@ func TestZCode(t *testing.T) { t.Fatal("sync added the provider") } } + +// What the user did in ZCode stays: magpie turned off stays off, in both +// files; its rule stays where it is among theirs; and a rule they removed +// isn't put back when magpie syncs. +func TestZCodeKeepsUserChoices(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro"}}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".zcode", "v2", "config.json") + rules := filepath.Join(home, ".zcode", "v2", "provider_config.json") + os.MkdirAll(filepath.Dir(path), 0o755) + os.WriteFile(path, []byte(`{"provider":{"magpie":{"name":"magpie","kind":"anthropic","enabled":false}}}`), 0o644) + os.WriteFile(rules, []byte(`{"schemaVersion":1,"config":{"providerConfigRules":{"providerRules":[ + {"providerId":"a","config":{}},{"providerId":"magpie","enabled":false,"config":{}},{"providerId":"b","config":{}}]}}}`), 0o600) + a := zcode(home) + if err := a.Sync(); err != nil { + t.Fatal(err) + } + ids := func() (out []string, on []any) { + var d struct { + Config struct { + ProviderConfigRules struct { + ProviderRules []map[string]any `json:"providerRules"` + } `json:"providerConfigRules"` + } `json:"config"` + } + b, _ := os.ReadFile(rules) + json.Unmarshal(b, &d) + for _, r := range d.Config.ProviderConfigRules.ProviderRules { + out = append(out, r["providerId"].(string)) + on = append(on, r["enabled"]) + } + return out, on + } + if got, on := ids(); len(got) != 3 || got[1] != "magpie" || on[1] != false { + t.Fatalf("rules %v %v", got, on) + } + var c struct { + Provider map[string]map[string]any `json:"provider"` + } + b, _ := os.ReadFile(path) + json.Unmarshal(b, &c) + if c.Provider["magpie"]["enabled"] != false || c.Provider["magpie"]["models"] == nil { + t.Fatalf("config.json: %s", b) + } + + // removed in ZCode: not put back + os.WriteFile(rules, []byte(`{"schemaVersion":1,"config":{"providerConfigRules":{"providerRules":[{"providerId":"a","config":{}}]}}}`), 0o600) + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if got, _ := ids(); len(got) != 1 { + t.Fatalf("magpie put back: %v", got) + } +} diff --git a/internal/gateway/anthropic.go b/internal/gateway/anthropic.go index 47b3211e..f36361d2 100644 --- a/internal/gateway/anthropic.go +++ b/internal/gateway/anthropic.go @@ -154,6 +154,25 @@ func thinkingOffUnlessAsked(body []byte) []byte { return withFields(body, map[string]any{"thinking": map[string]any{"type": "disabled"}}) } +// alwaysThinks is a vendor refusing to turn a model's thinking off: Z.ai's +// GLM-5.3 answers 1210, "…always engages in thinking…". +var alwaysThinks = regexp.MustCompile(`(?i)always engages in thinking|thinking (?:can ?not|can't) be (?:disabled|turned off)`) + +// withoutThinkingOff is body with its thinking left to the model, when it +// says thinking is off; false when it doesn't. +func withoutThinkingOff(body []byte) ([]byte, bool) { + var q map[string]json.RawMessage + var th struct { + Type string `json:"type"` + } + if json.Unmarshal(body, &q) != nil || json.Unmarshal(q["thinking"], &th) != nil || th.Type != "disabled" { + return nil, false + } + delete(q, "thinking") + out, err := json.Marshal(q) + return out, err == nil +} + // buildAnthropic renders a request for an Anthropic-style upstream. // claudeVersion finds the family's version in a Claude model id however a // relay spells it: claude-opus-4-6, claude-opus-5, anthropic.claude-sonnet-4.6-v1. diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 8f7af555..7c7a4b06 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -755,6 +755,18 @@ func (s *Server) passthrough(w http.ResponseWriter, r *http.Request, p provider. if err != nil { return writeError(w, proto, 502, p.Name+": "+err.Error()), err.Error(), true } + if proto == provider.Anthropic && res.StatusCode == http.StatusBadRequest { + // a model that always thinks refuses thinking turned off: asked + // again with it left to the model + b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + res.Body.Close() + res.Body = io.NopCloser(bytes.NewReader(b)) + if nb, ok := withoutThinkingOff(body); ok && alwaysThinks.Match(b) { + if res, err = s.forward(r.Context(), p, proto, pathOf(proto), p.Prepare(nb), r.Header); err != nil { + return writeError(w, proto, 502, p.Name+": "+err.Error()), err.Error(), true + } + } + } defer res.Body.Close() if res.StatusCode >= 400 { b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 8023f522..b8aab015 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -29,11 +29,23 @@ type fake struct { reply string // SSE body ctype string // "" means text/event-stream; "none" sends no header at all code int + // refuse, when set, answers a request it gives a code for with it + refuse func(body []byte) (code int, reply string) + calls int } func (f *fake) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.got, _ = io.ReadAll(r.Body) f.path, f.head = r.URL.Path, r.Header + f.calls++ + if f.refuse != nil { + if code, reply := f.refuse(f.got); code != 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + io.WriteString(w, reply) + return + } + } ct := f.ctype if ct == "" { ct = "text/event-stream" @@ -479,6 +491,30 @@ func TestAnthropicPassthroughTurnsThinkingOffUnlessAsked(t *testing.T) { } } +// GLM-5.3 always thinks and refuses thinking turned off (Z.ai's 1210): +// the request magpie turned it off for is asked again with it left to the +// model, once +func TestAnthropicPassthroughModelThatAlwaysThinks(t *testing.T) { + f := &fake{t: t, ctype: "application/json", reply: `{"id":"msg","type":"message","content":[]}`} + f.refuse = func(b []byte) (int, string) { + if bytes.Contains(b, []byte(`"disabled"`)) { + return 400, `{"type":"error","error":{"type":"1210","message":"GLM-5.3 always engages in thinking; use low, high, or max"}}` + } + return 0, "" + } + setup(t, provider.Anthropic, f) + code, body := post(t, "/v1/messages", `{"model":"m1","max_tokens":5,"messages":[{"role":"user","content":"title?"}]}`) + if code != 200 || f.calls != 2 || bytes.Contains(f.got, []byte("thinking")) || !bytes.Contains(f.got, []byte(`"title?"`)) { + t.Fatalf("%d %s after %d calls, last sent %s", code, body, f.calls, f.got) + } + // another 400 is the agent's to see, not asked again + f.calls = 0 + f.refuse = func([]byte) (int, string) { return 400, `{"type":"error","error":{"message":"bad request"}}` } + if code, _ := post(t, "/v1/messages", `{"model":"m1","max_tokens":5,"messages":[]}`); code != 400 || f.calls != 1 { + t.Errorf("%d after %d calls", code, f.calls) + } +} + // effort without thinking asks for no reasoning on a Chat upstream func TestAnthropicEffortWithoutThinking(t *testing.T) { f := &fake{t: t, reply: sse(`data: {"id":"c1","choices":[{"delta":{"content":"T"},"finish_reason":"stop"}]}`, `data: [DONE]`)}