pi: a model that always thinks has off hidden on the Messages API, and its Chinese refusal of thinking off is asked again (#699, liuweifeng: 走 Anthropic Messages API 的 always-thinks 模型被删掉 thinkingLevelMap 的 off,Pi 仍提供 off 导致上游 400) — piThinkingLevels left off out of thinkingLevelMap for every model on anthropic-messages, which is right for Claude (Pi offers a level left out, and Claude takes thinking disabled whatever its levels) but had Pi offer off for ZCode's GLM-5.3-Flash too, whose levels are low/high/max and which answers thinking disabled with 400 "该模型始终支持思考,不可关闭". Now off is left out only for a model that thinks only when asked: gateway.ThinksOnlyWhenAsked, the gateway's own anthropicModel (the test that decides it leaves a Claude's request as sent and turns another vendor's thinking off unless asked), plus a relay's opus-5.5 spelling. Another vendor's model there gets "off": null unless none is among its levels (GLM-5.2's none still maps off to it), as on Chat and Responses. The gateway's alwaysThinks, which asks a refused thinking-off request again with thinking left to the model, didn't know the Chinese wording, so the 400 reached Pi (and any agent whose request the gateway turned off); it now matches 始终…思考 / 思考…不可关闭. OpenCode's variants, dsh's levels and omp's efforts name only the model's own levels, off only for none, so they don't offer a level the model refuses; omp can't hide its off, and the gateway's retry now covers it. TestPiOffOnMessagesAPI (GLM-5.3-Flash off null and not offered; GLM-5.2 off=none; Claude and opus-5.5 off left out) and TestAnthropicPassthroughAlwaysThinksInChinese (asked again once without thinking) fail on the old code and pass. Pi's models.json generated in a temp HOME with PI_CODING_AGENT_DIR in it: zc/GLM-5.3-Flash has "off": null, low/high/max; claude-sonnet-5 has no off key. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestDevinSignedInOnlyInMagpie, a timing test that passes alone. Not tried with a real ZCode account.

This commit is contained in:
yetone committed 2026-10-03 23:36:50 +08:00
1 parent 89d305229b
commit de290249bb
4 files changed
+80 -11

No files matched your search

+10 -7
View File
@@ -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
+38
View File
@@ -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)
}
}
}
+14 -4
View File
@@ -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.
+18
View File
@@ -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 {