From de290249bbcf90cb06accf53fa1124e70699f885 Mon Sep 17 00:00:00 2001 From: yetone Date: Sat, 3 Oct 2026 23:36:20 +0800 Subject: [PATCH] =?UTF-8?q?pi:=20a=20model=20that=20always=20thinks=20has?= =?UTF-8?q?=20off=20hidden=20on=20the=20Messages=20API,=20and=20its=20Chin?= =?UTF-8?q?ese=20refusal=20of=20thinking=20off=20is=20asked=20again=20(#69?= =?UTF-8?q?9,=20liuweifeng:=20=E8=B5=B0=20Anthropic=20Messages=20API=20?= =?UTF-8?q?=E7=9A=84=20always-thinks=20=E6=A8=A1=E5=9E=8B=E8=A2=AB?= =?UTF-8?q?=E5=88=A0=E6=8E=89=20thinkingLevelMap=20=E7=9A=84=20off?= =?UTF-8?q?=EF=BC=8CPi=20=E4=BB=8D=E6=8F=90=E4=BE=9B=20off=20=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E4=B8=8A=E6=B8=B8=20400)=20=E2=80=94=20piThinkingLeve?= =?UTF-8?q?ls=20left=20off=20out=20of=20thinkingLevelMap=20for=20every=20m?= =?UTF-8?q?odel=20on=20anthropic-messages,=20which=20is=20right=20for=20Cl?= =?UTF-8?q?aude=20(Pi=20offers=20a=20level=20left=20out,=20and=20Claude=20?= =?UTF-8?q?takes=20thinking=20disabled=20whatever=20its=20levels)=20but=20?= =?UTF-8?q?had=20Pi=20offer=20off=20for=20ZCode's=20GLM-5.3-Flash=20too,?= =?UTF-8?q?=20whose=20levels=20are=20low/high/max=20and=20which=20answers?= =?UTF-8?q?=20thinking=20disabled=20with=20400=20"=E8=AF=A5=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=A7=8B=E7=BB=88=E6=94=AF=E6=8C=81=E6=80=9D=E8=80=83?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=8F=AF=E5=85=B3=E9=97=AD".=20Now=20off=20i?= =?UTF-8?q?s=20left=20out=20only=20for=20a=20model=20that=20thinks=20only?= =?UTF-8?q?=20when=20asked:=20gateway.ThinksOnlyWhenAsked,=20the=20gateway?= =?UTF-8?q?'s=20own=20anthropicModel=20(the=20test=20that=20decides=20it?= =?UTF-8?q?=20leaves=20a=20Claude's=20request=20as=20sent=20and=20turns=20?= =?UTF-8?q?another=20vendor's=20thinking=20off=20unless=20asked),=20plus?= =?UTF-8?q?=20a=20relay's=20opus-5.5=20spelling.=20Another=20vendor's=20mo?= =?UTF-8?q?del=20there=20gets=20"off":=20null=20unless=20none=20is=20among?= =?UTF-8?q?=20its=20levels=20(GLM-5.2's=20none=20still=20maps=20off=20to?= =?UTF-8?q?=20it),=20as=20on=20Chat=20and=20Responses.=20The=20gateway's?= =?UTF-8?q?=20alwaysThinks,=20which=20asks=20a=20refused=20thinking-off=20?= =?UTF-8?q?request=20again=20with=20thinking=20left=20to=20the=20model,=20?= =?UTF-8?q?didn't=20know=20the=20Chinese=20wording,=20so=20the=20400=20rea?= =?UTF-8?q?ched=20Pi=20(and=20any=20agent=20whose=20request=20the=20gatewa?= =?UTF-8?q?y=20turned=20off);=20it=20now=20matches=20=E5=A7=8B=E7=BB=88?= =?UTF-8?q?=E2=80=A6=E6=80=9D=E8=80=83=20/=20=E6=80=9D=E8=80=83=E2=80=A6?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E5=85=B3=E9=97=AD.=20OpenCode's=20variants,?= =?UTF-8?q?=20dsh's=20levels=20and=20omp's=20efforts=20name=20only=20the?= =?UTF-8?q?=20model's=20own=20levels,=20off=20only=20for=20none,=20so=20th?= =?UTF-8?q?ey=20don't=20offer=20a=20level=20the=20model=20refuses;=20omp?= =?UTF-8?q?=20can't=20hide=20its=20off,=20and=20the=20gateway's=20retry=20?= =?UTF-8?q?now=20covers=20it.=20TestPiOffOnMessagesAPI=20(GLM-5.3-Flash=20?= =?UTF-8?q?off=20null=20and=20not=20offered;=20GLM-5.2=20off=3Dnone;=20Cla?= =?UTF-8?q?ude=20and=20opus-5.5=20off=20left=20out)=20and=20TestAnthropicP?= =?UTF-8?q?assthroughAlwaysThinksInChinese=20(asked=20again=20once=20witho?= =?UTF-8?q?ut=20thinking)=20fail=20on=20the=20old=20code=20and=20pass.=20P?= =?UTF-8?q?i's=20models.json=20generated=20in=20a=20temp=20HOME=20with=20P?= =?UTF-8?q?I=5FCODING=5FAGENT=5FDIR=20in=20it:=20zc/GLM-5.3-Flash=20has=20?= =?UTF-8?q?"off":=20null,=20low/high/max;=20claude-sonnet-5=20has=20no=20o?= =?UTF-8?q?ff=20key.=20go=20vet,=20builds=20(darwin,=20GOOS=3Dlinux,=20GOO?= =?UTF-8?q?S=3Dwindows)=20and=20go=20test=20-tags=20nogui=20-count=3D1=20.?= =?UTF-8?q?/...=20under=20a=20temp=20HOME=20pass=20but=20TestDevinSignedIn?= =?UTF-8?q?OnlyInMagpie,=20a=20timing=20test=20that=20passes=20alone.=20No?= =?UTF-8?q?t=20tried=20with=20a=20real=20ZCode=20account.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/agents.go | 17 ++++++++------ internal/agent/pi_test.go | 38 ++++++++++++++++++++++++++++++++ internal/gateway/anthropic.go | 18 +++++++++++---- internal/gateway/gateway_test.go | 18 +++++++++++++++ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 41a1fc75..edbf3f4e 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -311,7 +311,7 @@ func piModelJSON(m catalog.Model, gw string, native bool) map[string]any { if m.Images { e["input"] = []string{"text", "image"} } - if levels := piThinkingLevels(m.Efforts, e["api"] == "anthropic-messages"); levels != nil { + if levels := piThinkingLevels(m.Efforts, e["api"] == "anthropic-messages" && gateway.ThinksOnlyWhenAsked(m.ID)); levels != nil { e["thinkingLevelMap"] = levels } // without it Pi takes every model for a 128K one, and compacts @@ -354,11 +354,14 @@ func openCodeVariants(efforts []string) map[string]any { // turned away (#243). Pi's off is the model's none when it takes one. A // model without none has off hidden — Pi's off asks a Responses model for // effort none and leaves a Chat model on the vendor's default thinking — -// except on Anthropic's Messages API, where Pi's off sends thinking -// disabled, which Claude takes whatever its levels; there off is left out, -// for Pi to offer as before. A model whose levels magpie doesn't know gets -// no map: Pi's own defaults, as before. -func piThinkingLevels(efforts []string, anthropic bool) map[string]any { +// except a Claude on Anthropic's Messages API (claudeOff), where Pi's off +// sends thinking disabled, which Claude takes whatever its levels; there +// off is left out, for Pi to offer as before. Another vendor's model there +// keeps off hidden: without none it always thinks, and ZCode's +// GLM-5.3-Flash turned Pi's off away ("该模型始终支持思考,不可关闭", #699), +// as a level left out is one Pi offers. A model whose levels magpie doesn't +// know gets no map: Pi's own defaults, as before. +func piThinkingLevels(efforts []string, claudeOff bool) map[string]any { if len(efforts) == 0 { return nil } @@ -374,7 +377,7 @@ func piThinkingLevels(efforts []string, anthropic bool) map[string]any { levels[l] = nil } } - if levels["off"] == nil && anthropic { + if levels["off"] == nil && claudeOff { delete(levels, "off") } return levels diff --git a/internal/agent/pi_test.go b/internal/agent/pi_test.go index 2c6e5ac8..a185c81a 100644 --- a/internal/agent/pi_test.go +++ b/internal/agent/pi_test.go @@ -3,6 +3,8 @@ package agent import ( "reflect" "testing" + + "github.com/yetone/magpie/internal/catalog" ) // Every one of Pi's levels is in the map, the model's own mapped and the @@ -36,3 +38,39 @@ func TestPiThinkingLevels(t *testing.T) { } } } + +// #699: ZCode's GLM-5.3-Flash, on Anthropic's Messages API, always thinks +// (its levels low, high and max, no none) and turns thinking disabled away +// ("该模型始终支持思考,不可关闭"); its entry left off out, which Pi offers, +// so off is null for it. A Claude there still has off left for Pi to offer, +// and a vendor's model that takes none maps off to it. +func TestPiOffOnMessagesAPI(t *testing.T) { + for _, c := range []struct { + id string + efforts []string + off any // the map's off; "left out" when it has none + offered []string + }{ + {"zcode/GLM-5.3-Flash", []string{"low", "high", "max"}, nil, []string{"low", "high", "max"}}, + {"zcode/GLM-5.2", []string{"none", "high", "max"}, "none", []string{"off", "high", "max"}}, + {"anth/claude-sonnet-5", []string{"low", "medium", "high", "xhigh", "max"}, "left out", []string{"off", "low", "medium", "high", "xhigh", "max"}}, + {"relay/opus-5.5", []string{"low", "high", "max"}, "left out", []string{"off", "low", "high", "max"}}, + } { + m := catalog.Model{ID: c.id, Name: c.id, Efforts: c.efforts, APIs: []string{"anthropic"}} + e := piModelJSON(m, "http://127.0.0.1:1", true) + if e["api"] != "anthropic-messages" { + t.Fatalf("%s on %v", c.id, e["api"]) + } + levels := e["thinkingLevelMap"].(map[string]any) + off, set := levels["off"] + if !set { + off = "left out" + } + if off != c.off { + t.Errorf("%s: off is %v, want %v", c.id, off, c.off) + } + if got := piSupported(e); !reflect.DeepEqual(got, c.offered) { + t.Errorf("%s: Pi offers %v, want %v", c.id, got, c.offered) + } + } +} diff --git a/internal/gateway/anthropic.go b/internal/gateway/anthropic.go index c5282abc..1d6f7db2 100644 --- a/internal/gateway/anthropic.go +++ b/internal/gateway/anthropic.go @@ -202,10 +202,20 @@ func thinkingOffUnlessAsked(body []byte) []byte { var anthropicModel = regexp.MustCompile(`(?i)(?:^|[/.:-])claude-`) // alwaysThinks is a vendor refusing to turn a model's thinking off: Z.ai's -// GLM-5.3 answers 1210, "…always engages in thinking…", and DashScope's own -// glm-5.3 answers "The value of the enable_thinking parameter is restricted -// to True." -var alwaysThinks = regexp.MustCompile(`(?i)always engages in thinking|thinking (?:can ?not|can't) be (?:disabled|turned off)|enable_thinking[^"]{0,60}restricted to true`) +// GLM-5.3 answers 1210, "…always engages in thinking…", or, in Chinese +// (ZCode's GLM-5.3-Flash), "该模型始终支持思考,不可关闭" (#699); DashScope's +// own glm-5.3 answers "The value of the enable_thinking parameter is +// restricted to True." +var alwaysThinks = regexp.MustCompile(`(?i)always engages in thinking|thinking (?:can ?not|can't) be (?:disabled|turned off)|enable_thinking[^"]{0,60}restricted to true|始终(?:支持|开启|启用)?思考|思考[^"]{0,20}(?:不可|无法|不能)关闭`) + +// ThinksOnlyWhenAsked is a model that takes thinking turned off on the +// Messages API whatever its levels: one of Anthropic's own (anthropicModel, +// or a relay's opus-5.5). Another vendor's model there takes it off only +// when none is among its levels; without it, it always thinks, and GLM-5.3 +// turns thinking disabled away (#699). +func ThinksOnlyWhenAsked(model string) bool { + return anthropicModel.MatchString(model) || claudeVersion.MatchString(strings.ToLower(model)) +} // withoutThinkingOff is body with its thinking left to the model, when it // says thinking is off; false when it doesn't. diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 99d18531..c6f240c5 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -589,6 +589,24 @@ func TestAnthropicPassthroughDashScopeThinkingRestricted(t *testing.T) { } } +// #699: ZCode's GLM-5.3-Flash refuses thinking turned off in Chinese +// ("该模型始终支持思考,不可关闭"), which Pi's off sent and the agent got as +// a 400: asked again with thinking left to the model, once +func TestAnthropicPassthroughAlwaysThinksInChinese(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":"invalid_request_error","message":"该模型始终支持思考,不可关闭"}}` + } + return 0, "" + } + setup(t, provider.Anthropic, f) + code, body := post(t, "/v1/messages", `{"model":"m1","max_tokens":5,"messages":[{"role":"user","content":"title?"}],"thinking":{"type":"disabled"}}`) + 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) + } +} + func TestOpenRouterMandatoryReasoningRetries(t *testing.T) { const refusal = `{"error":{"message":"OpenRouter: Reasoning is mandatory for this endpoint and cannot be disabled.","type":"invalid_request_error"},"type":"error"}` for _, tc := range []struct {