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 {