gateway, gui: WorkBuddy turning away an agent's system prompt ("unapproved channel") rests no account, its mates are asked last, and the Routing page tells it as the prompt, not the reasoning level (lemon on Discord)

WorkBuddy AI answers 400 "Illegal API invocation from an unapproved
channel" (code 11128, "blocked by security policy") to a chat whose
system prompt is Claude Code's own identity line, at any reasoning level
and on any account (checked against a real WorkBuddy AI account with
deepseek-v4.1-flash). It was treated as the account failing: each account
rested, so one agent's chats took WorkBuddy away from every agent. It is
now failPrompt: the next member is asked, the account's mates last, and
nobody rests. The Routing page says the vendor turns the agent's prompt
away and leaves out the level the try went at.
This commit is contained in:
yetone committed 2026-10-05 22:19:40 +08:00
1 parent 11a2d359a1
commit 2033df0faa
7 files changed
+122 -9

No files matched your search

+6
View File
@@ -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"
+19
View File
@@ -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 {
+61
View File
@@ -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)
}
}
})
}
}
+4
View File
@@ -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"
+9
View File
@@ -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",
+8 -3
View File
@@ -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")
+15 -6
View File
@@ -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();