diff --git a/internal/gateway/fallback.go b/internal/gateway/fallback.go index 041e5508..3b68f92d 100644 --- a/internal/gateway/fallback.go +++ b/internal/gateway/fallback.go @@ -639,6 +639,12 @@ var unservedWords = regexp.MustCompile(`(?i)model.{0,80}(not (supported|accessib // refusal of the provider, not of the request, another member may serve. var refusedWords = regexp.MustCompile(`(?i)unapproved channel|illegal api invocation`) +// promptRefused says a vendor answered with that refusal: the agent's +// prompt, not the account, is at fault. +func promptRefused(status int, body []byte) bool { + return status >= 400 && refusedWords.Match(body) +} + // shapeWords are how a vendor says it can't read the request's shape — an // item, field or parameter it doesn't know, which another vendor's API may // take: xAI's 422 "Failed to deserialize the JSON body …: unknown item type" diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index acb1a26c..7a6d97ea 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1888,6 +1888,23 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } continue } + if !last && hw.failed() && promptRefused(hw.code(), hw.errBody()) { + // WorkBuddy's "Illegal API invocation from an unapproved + // channel": its security policy turns away the agent's system + // prompt (Claude Code's own, #182), whichever account and + // reasoning level it is asked at (Discord, lemon: both WorkBuddy + // AI accounts rested over an agent's chat, for every agent). + // Nothing is wrong with the account, so it doesn't rest; its + // mates, carrying the same prompt, are asked last + if other == nil { + other = &Try{Status: call.Status, Error: call.Error} + } + try.Fail = failPrompt + s.trace.update(tr, func(t *Route) { t.Tries[len(t.Tries)-1] = try }) + skipped = append(skipped, c.label()+": "+call.Error) + matesLast(cands[i+1:], c) + continue + } if c.p.Account != nil && !hw.passing && hw.code() >= 400 && hw.code() < 500 && modelTakes(c, sent) { if takes, ok := effortRefused(hw.errBody(), sent); ok { // the account's plan doesn't take the level, which the @@ -2095,6 +2112,8 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro } } else if hw.refused { try.Fail = failRefused + } else if promptRefused(call.Status, []byte(call.Error)) { + try.Fail = failPrompt } else { try.Fail = failureOf(c, call.Status, []byte(call.Error)) if try.Fail == failVerify && !held { diff --git a/internal/gateway/prompt_refused_test.go b/internal/gateway/prompt_refused_test.go new file mode 100644 index 00000000..033769f6 --- /dev/null +++ b/internal/gateway/prompt_refused_test.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// WorkBuddy's security policy turning away an agent's system prompt (Claude +// Code's own) "from an unapproved channel", whichever account it goes to: +// the next account is asked, and the one that answered so doesn't rest — +// before, lemon's two WorkBuddy AI accounts both rested over one agent's +// chat, for every agent (Discord). Told as the agent's prompt, not a +// failure of the account. +func TestPromptRefusalRestsNobody(t *testing.T) { + refuse := func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + io.WriteString(w, `{"code":11128,"msg":"Illegal API invocation from an unapproved channel"}`) + } + for _, x := range []struct { + name string + also []string // the second account refuses too + }{ + {"next answers", nil}, + {"nobody left", []string{"acct-2"}}, + } { + t.Run(x.name, func(t *testing.T) { + codexSignedIn(t, "spare@example.com") + if err := provider.SetRouting("codex", provider.Ordered); err != nil { + t.Fatal(err) + } + var tried []string + chatgptRefusing(t, &tried, refuse, x.also...) + s := New() + code, body := codexAskOn(s, `{"model":"gpt-5.5","stream":true,"input":"ping"}`) + if strings.Join(tried, ",") != "acct-1,acct-2" { + t.Fatalf("tried %v", tried) + } + r := lastRoute(s) + if len(r.Tries) != 2 || r.Tries[0].Fail != failPrompt || r.Tries[0].Rest != nil { + t.Fatalf("tries: %+v", r.Tries) + } + if x.also == nil { + if code != 200 || !strings.Contains(body, "pong") || r.Tries[1].Status != 200 { + t.Fatalf("status %d: %s", code, body) + } + } else if code != 400 || !strings.Contains(body, "unapproved channel") || r.Tries[1].Fail != failPrompt || r.Tries[1].Rest != nil { + t.Fatalf("status %d: %s; tries %+v", code, body, r.Tries) + } + for _, c := range []string{"codex", "codex@me@example.com", "codex@spare@example.com"} { + if _, ok := restOf(c); ok { + t.Fatalf("%s set aside by a prompt the vendor turns away", c) + } + } + }) + } +} diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go index 3dca772e..6ebff614 100644 --- a/internal/gateway/routing.go +++ b/internal/gateway/routing.go @@ -151,6 +151,10 @@ const ( // failShape: the vendor couldn't read the request's shape (#350) — the // next one is asked, and nobody rests failShape = "shape" + // failPrompt: the vendor turns away the agent's system prompt, as + // WorkBuddy's "unapproved channel" does Claude Code's — the next one is + // asked, its own mates last, and nobody rests + failPrompt = "prompt" // failEffort: the account's plan doesn't take the reasoning level // asked for (#520) — another account is asked, and nobody rests failEffort = "effort" diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 3a45f322..3b27373b 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2545,6 +2545,9 @@ const I18N = { "reply too short": "回复长度过短", "needs verification": "账号需验证", "refused (safety filter)": "被安全过滤拒绝", + "agent's prompt turned away": "Agent 的提示词被拒", + "{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, so it goes on to the next before any of the reply reaches {agent}. Nothing is wrong with {who}, so it doesn't rest.": "{who} 返回 {status}:服务商不接受 {agent} 的系统提示词,换哪个账号都一样,已在回复到达 {agent} 前转给下一个。{who} 本身无问题,不休息。", + "{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, and nobody else is left to try, so {agent} gets the error. Nothing is wrong with {who}, so it doesn't rest.": "{who} 返回 {status}:服务商不接受 {agent} 的系统提示词,换哪个账号都一样,且没有其他可尝试的,{agent} 收到该错误。{who} 本身无问题,不休息。", "request not understood": "请求格式不被接受", "proxy not reachable": "代理连不上", "reasoning effort not in its plan": "套餐不支持该推理强度", @@ -5905,6 +5908,9 @@ const I18N = { "reply too short": "応答が短すぎる", "needs verification": "要認証", "refused (safety filter)": "拒否(安全フィルター)", + "agent's prompt turned away": "エージェントのプロンプトを拒否", + "{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, so it goes on to the next before any of the reply reaches {agent}. Nothing is wrong with {who}, so it doesn't rest.": "{who} が {status} を返しました:ベンダーはどのアカウントでも {agent} のシステムプロンプトを拒否するため、応答が {agent} に届く前に次へ進みます。{who} に問題はないので休止しません。", + "{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, and nobody else is left to try, so {agent} gets the error. Nothing is wrong with {who}, so it doesn't rest.": "{who} が {status} を返しました:ベンダーはどのアカウントでも {agent} のシステムプロンプトを拒否し、他に試せる相手もいないため、{agent} にエラーを返します。{who} に問題はないので休止しません。", "request not understood": "リクエストを理解できない", "proxy not reachable": "プロキシに接続できない", "reasoning effort not in its plan": "プランにない推論レベル", @@ -9258,6 +9264,9 @@ const I18N = { "reply too short": "Antwort zu kurz", "needs verification": "Verifizierung nötig", "refused (safety filter)": "abgelehnt (Sicherheitsfilter)", + "agent's prompt turned away": "Prompt des Agenten abgelehnt", + "{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, so it goes on to the next before any of the reply reaches {agent}. Nothing is wrong with {who}, so it doesn't rest.": "{who} antwortete mit {status}: Der Anbieter lehnt den Systemprompt von {agent} bei jedem Konto ab, daher geht sie an den nächsten, bevor etwas von der Antwort {agent} erreicht. Mit {who} ist alles in Ordnung, daher pausiert es nicht.", + "{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, and nobody else is left to try, so {agent} gets the error. Nothing is wrong with {who}, so it doesn't rest.": "{who} antwortete mit {status}: Der Anbieter lehnt den Systemprompt von {agent} bei jedem Konto ab, und niemand sonst ist übrig, daher bekommt {agent} den Fehler. Mit {who} ist alles in Ordnung, daher pausiert es nicht.", "request not understood": "Anfrage nicht verstanden", "proxy not reachable": "Proxy nicht erreichbar", "reasoning effort not in its plan": "Reasoning-Effort nicht im Plan", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 4216e024..266491fb 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -191,7 +191,7 @@ // (WorkBuddy's credits, #659): "355 / 500 credits · 71% used" const quota = (w, used, left, vars) => (w.limit > 0 ? quotaCount(w) + " · " : "") + t(quotaLeft ? left : used, { n: pct(share(w)), ...vars }); const fill = (w) => Math.max(0, Math.min(100, share(w))) + "%"; - const FAIL = { rate: "rate limited", credit: "out of credit", quota: "quota used up", other: "failed", auth: "sign-in required", canceled: "canceled", foreign: "another account's reasoning", floor: "reply too short", verify: "needs verification", refused: "refused (safety filter)", shape: "request not understood", proxy: "proxy not reachable", effort: "reasoning effort not in its plan", overflow: "too long for its model", slow: "slow to start" }; + const FAIL = { rate: "rate limited", credit: "out of credit", quota: "quota used up", other: "failed", auth: "sign-in required", canceled: "canceled", foreign: "another account's reasoning", floor: "reply too short", verify: "needs verification", refused: "refused (safety filter)", shape: "request not understood", proxy: "proxy not reachable", effort: "reasoning effort not in its plan", overflow: "too long for its model", slow: "slow to start", prompt: "agent's prompt turned away" }; const failWord = (why) => t(FAIL[why] || "failed"); // a try that answered: one whose reply broke off after it began (its 200 // sent, then the vendor's error, #733) didn't, and has a fail @@ -550,7 +550,8 @@ // a try in words, and how its reasoning came to differ from the agent's function tryWhy(r, i) { const tr = r.tries[i], said = trySaid(r, i); - if (!tr.effort || !r.effort || r.effort === tr.effort) return said; + // a prompt turned away is turned away at every level: the level is no part of it + if (!tr.effort || !r.effort || r.effort === tr.effort || tr.fail === "prompt") return said; const agent = agentName(r.agent); return said + (/[。!?]$/.test(said) ? "" : " ") + (tr.fixed ? t("The group fixes this model at {fixed} reasoning, in place of the {asked} {agent} asked for.", { fixed: tr.fixed, asked: r.effort, agent }) @@ -575,7 +576,7 @@ let name = w ? `${who(w)} (${w.model})` : tr.id; // Copilot's Auto: the model it picked last, which this try went as if (w && tr.auto?.length) name = `${who(w)} (${w.model} → ${tr.auto[tr.auto.length - 1].model})`; - if (tr.effort) name += " " + t("at {level} reasoning", { level: tr.effort }); + if (tr.effort && tr.fail !== "prompt") name += " " + t("at {level} reasoning", { level: tr.effort }); if (!tr.done) return t("{who} is answering…", { who: name }); // a Codex reset spent by itself: with Codex's own sign-in the one try // it was spent for is the one that then answered @@ -616,6 +617,10 @@ return r.tries[i + 1] ? t("{who}'s safety filter refused the request before saying anything, so it goes on to the next before any of the reply reaches {agent}. Nothing is wrong with {who}, so it doesn't rest.", { who: name, agent }) : t("{who}'s safety filter refused the request before saying anything, and nobody is left to try, so {agent} gets an error saying so, not an empty reply to ask again for.", { who: name, agent }); + if (tr.fail === "prompt") + return r.tries[i + 1] + ? t("{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, so it goes on to the next before any of the reply reaches {agent}. Nothing is wrong with {who}, so it doesn't rest.", { who: name, status: tr.status, agent }) + : t("{who} answered {status}: the vendor turns away {agent}'s system prompt whichever account it goes to, and nobody else is left to try, so {agent} gets the error. Nothing is wrong with {who}, so it doesn't rest.", { who: name, status: tr.status, agent }); if (tr.fail === "shape") return t("{who} answered {status}: its API couldn't read something in the request that another's may, so it goes on to the next before any of the reply reaches {agent}. Nothing is wrong with {who}, so it doesn't rest.", { who: name, status: tr.status, agent }); if (tr.fail === "effort") diff --git a/internal/gui/tests/routing-wb-refused.test.cjs b/internal/gui/tests/routing-wb-refused.test.cjs index a4e2de9b..a03448c6 100644 --- a/internal/gui/tests/routing-wb-refused.test.cjs +++ b/internal/gui/tests/routing-wb-refused.test.cjs @@ -3,7 +3,10 @@ // prompt, #182): the Routing page's story gives the vendor's words as they // were, and what to do about it apart from them, in the page's language // (Discord, v0.1.478: WorkBuddy AI: Illegal API invocation from an -// unapproved channel). +// unapproved channel). Told as the agent's prompt turned away, which no +// reasoning level changes: the level it went at is left out of it (Discord, +// lemon: "at low reasoning … low is the model's nearest to the none LCode +// asked for" read as the level's fault). const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -17,14 +20,16 @@ const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString(); const hint = "WorkBuddy refuses chats from Codex and Claude Code (their system prompt); use it from Hermes, OpenCode or Pi, or add another provider to this group"; const vendor = "WorkBuddy AI: Illegal API invocation from an unapproved channel"; const key = { id: "workbuddy-ai", provider: "workbuddy-ai", name: "WorkBuddy AI", kind: "provider", model: "deepseek-v4.1-flash" }; -// newest first: the refusal, then another 400 with nothing to add +// newest first: the refusal (sent at low, none asked), then another 400 +// with nothing to add const errs = [vendor + " — " + hint, "WorkBuddy AI: first message is not system prompt", "", "", "", ""]; const routes = errs.map((error, i) => { const status = error ? 400 : 200; return { id: 100 - i, seq: 100 - i, time: at(i), agent: "codex", model: "workbuddy-ai/deepseek-v4.1-flash", provider: "workbuddy-ai", - order: [key], tries: [{ id: key.id, model: key.model, start: at(i), done: true, status, ms: 400, ...(error ? { error } : {}) }], - done: true, status, ms: 400, ...(error ? { error } : {}), + order: [key], tries: [{ id: key.id, model: key.model, start: at(i), done: true, status, ms: 400, ...(error ? { error } : {}), + ...(i === 0 ? { fail: "prompt", effort: "low" } : {}) }], + done: true, status, ms: 400, ...(error ? { error } : {}), ...(i === 0 ? { effort: "none" } : {}), }; }); @@ -55,8 +60,8 @@ function serve(lang) { } const want = { - en: { said: "It said: " + vendor, hint }, - zh: { said: "原话:" + vendor, hint: "WorkBuddy 会因系统提示词拒绝 Codex 和 Claude Code 的对话;请在 Hermes、OpenCode 或 Pi 中使用,或在此分组中再加一个供应商" }, + en: { said: "It said: " + vendor, hint, why: "the vendor turns away Codex's system prompt whichever account it goes to", level: "low reasoning" }, + zh: { said: "原话:" + vendor, hint: "WorkBuddy 会因系统提示词拒绝 Codex 和 Claude Code 的对话;请在 Hermes、OpenCode 或 Pi 中使用,或在此分组中再加一个供应商", why: "服务商不接受 Codex 的系统提示词,换哪个账号都一样", level: "low 推理" }, }; for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { @@ -91,6 +96,10 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", assert.deepEqual(got.filter(([c]) => c === "aside said").map(([, s]) => s), [want[lang].said]); // and the hint on its own line, in the page's language assert.deepEqual(got.filter(([, s]) => s === want[lang].hint).map(([c]) => c), ["aside"]); + // the try told as the prompt turned away, not at a level + const told = got.map(([, s]) => s).filter((s) => s.includes(want[lang].why)); + assert.equal(told.length, 1, JSON.stringify(got)); + assert(!told[0].includes(want[lang].level), told[0]); // another 400 gets no hint await page.locator(".rt-req").nth(1).click();