diff --git a/packages/zcode/README.md b/packages/zcode/README.md
index a4d21df1..f2ef18ef 100644
--- a/packages/zcode/README.md
+++ b/packages/zcode/README.md
@@ -168,9 +168,25 @@ behind it go straight to the plan. A gift that cannot be asked at all —
the network, the host — is answered from the plan the same way, the
route read dropped for the next request, while a caller's own abort
stays an abort. The agent sees the plan's answer
-either way. And the `-Trial` entries keep their own strict semantics —
+either way. When a refusal is fresh, the usage card says so — **"ZCode's
+gift is refusing requests — freeFirst answers from the coding plan until
+14:02 (…)"** — so a gift sitting idle
+beside a fat balance is explained, not silent. The why tells the two
+block kinds apart: a block page from the edge firewall says "the
+network's address is blocked" (every model the gift serves rests for the
+route's ten minutes — an address block outlasts a minute), ZCode's own
+3012 says "ZCode restricted this account", a spent answer rests its one
+model for a minute as "its quota said spent", and any other failure is
+named with its status; while a rest is on, the
+requests behind it go straight to the plan. The `-Trial` entries keep
+their own strict semantics —
asking one is still an explicit pick of the gift alone, never the plan;
-a spent one answers 429 with freeFirst on, as it does without it.
+a spent one answers 429 with freeFirst on, as it does without it, and
+one asked while the gift is blocked or restricted says that with the
+block's own hint ("…the provider's network firewall blocked requests
+from this IP…" / "…ZCode's risk control blocked this account…") and an
+error type that is not a rate limit, so the gateway rests the route by
+what happened; a model the gift never serves keeps its no-quota 400.
## A few at once
diff --git a/packages/zcode/dual.test.mjs b/packages/zcode/dual.test.mjs
index 725473df..69ec1011 100644
--- a/packages/zcode/dual.test.mjs
+++ b/packages/zcode/dual.test.mjs
@@ -36,6 +36,7 @@ const real = globalThis.fetch
beforeEach(async () => {
_internal.routes.clear()
_internal.blocked.clear()
+ _internal.refused.clear()
sent.length = 0
for (const k of Object.keys(answers)) delete answers[k]
globalThis.fetch = async (input, init = {}) => {
@@ -386,6 +387,174 @@ test("an abort while the gift is in flight stays an abort", async () => {
expect(tries.plan).toBe(0)
})
+test("a firewall block on the gift shows on the card and rests the gift's models", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response('
405errors.aliyun.com', { status: 405 })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", model: "GLM-5.3-Flash", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ expect(res.status).toBe(200)
+ expect(JSON.parse(sent.at(-1).body).model).toBe("GLM-5.3-Flash")
+ // while the block is fresh the gift is skipped entirely: no gift try,
+ // the plan answers straight
+ const before = sent.filter((c) => c.origin === "https://zcode.z.ai" && c.path.endsWith("/messages")).length
+ await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ expect(sent.filter((c) => c.origin === "https://zcode.z.ai" && c.path.endsWith("/messages")).length).toBe(before)
+ // the card says why the gift sits idle beside its balance
+ const u = await h.usage(async () => coding, { id: "zcode" })
+ const line = (u.windows ?? []).find((w) => w.aside && /refusing requests/.test(w.name))
+ expect(line.display).toMatch(/coding plan until \d\d:\d\d/)
+ expect(line.display).toContain("blocked")
+})
+
+test("a spent answer names itself on the card for its minute", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response(JSON.stringify({ error: { message: "Insufficient balance or no resource package", code: 1113 } }), { status: 429, headers: { "content-type": "application/json" } })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ expect(res.status).toBe(200)
+ const u = await h.usage(async () => coding, { id: "zcode" })
+ const line = (u.windows ?? []).find((w) => w.aside && /refusing requests/.test(w.name))
+ expect(line.display).toMatch(/coding plan until \d\d:\d\d/)
+ expect(line.display).toContain("spent")
+})
+
+test("a gift failure the plan answers shows on the card with the status", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response("boom", { status: 500 })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ expect(res.status).toBe(200)
+ const u = await h.usage(async () => coding, { id: "zcode" })
+ const line = (u.windows ?? []).find((w) => w.aside && /refusing requests/.test(w.name))
+ expect(line.display).toMatch(/coding plan until \d\d:\d\d/)
+ expect(line.display).toContain("500")
+})
+
+test("a 3012 says the account is restricted, not the address", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response(JSON.stringify({ error: { message: "request has been blocked due to unusual activity", code: 3012 } }), { status: 405, headers: { "content-type": "application/json" } })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", model: "GLM-5.3-Flash", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ expect(res.status).toBe(200)
+ const u = await h.usage(async () => coding, { id: "zcode" })
+ const line = (u.windows ?? []).find((w) => w.aside && /refusing requests/.test(w.name))
+ expect(line.display).toContain("restricted")
+ expect(line.display).not.toContain("blocked")
+})
+
+test("a blocked gift's -Trial entry says the network is blocked, not the quota", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response('405errors.aliyun.com', { status: 405 })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash-Trial") })
+ expect(res.status).toBe(429)
+ const said = await res.json()
+ // an address block is not a rate limit: magpie classifies by the type
+ expect(said.error.type).toBe("invalid_request_error")
+ expect(said.error.message).toContain("network firewall blocked requests from this IP")
+ expect(said.error.message).not.toContain("quota")
+})
+
+test("a restricted gift's -Trial entry says the account is restricted", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response(JSON.stringify({ error: { message: "request has been blocked due to unusual activity", code: 3012 } }), { status: 405, headers: { "content-type": "application/json" } })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash-Trial") })
+ expect(res.status).toBe(429)
+ const said = await res.json()
+ expect(said.error.type).toBe("invalid_request_error")
+ expect(said.error.message).toContain("risk control blocked this account")
+})
+
+test("a spent gift's -Trial entry keeps its quota reply", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ // the freeFirst plain request hears the gift say its quota is spent, and
+ // the trial entry after it is answered from that rest, locally
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response(JSON.stringify({ error: { message: "Insufficient balance or no resource package", code: 1113 } }), { status: 429, headers: { "content-type": "application/json" } })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash-Trial") })
+ expect(res.status).toBe(429)
+ const said = await res.json()
+ expect(said.error.type).toBe("rate_limit_error")
+ expect(said.error.message).toContain("limited quota")
+})
+
+test("a -Trial entry the gift never serves keeps its no-quota 400", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response('405errors.aliyun.com', { status: 405 })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.2-Trial") })
+ expect(res.status).toBe(400)
+ const said = await res.json()
+ expect(said.error.type).toBe("invalid_request_error")
+ expect(said.error.message).toContain("no active limited quota")
+ expect(said.error.message).not.toContain("blocked")
+})
+
+test("a gift success clears the card's refusal line", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response('405errors.aliyun.com', { status: 405 })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ // the block out, the gift answers: the line goes with it
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ _internal.blocked.set(KEY + "\0" + jwt + "\0glm-5.3-flash", Date.now() - 1)
+ await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ const u = await h.usage(async () => coding, { id: "zcode" })
+ expect((u.windows ?? []).find((w) => w.aside && /refusing requests/.test(w.name))).toBeUndefined()
+})
+
+test("the gift's own busy cap is not a refusal for the card", async () => {
+ planAndBalance()
+ answers["/api/monitor/usage/quota/limit"] = ok({ limits: [{ type: "CREDIT_LIMIT", unit: 6, number: 1, percentage: 20 }] })
+ const waits = _internal.BUSY_WAITS.splice(0)
+ _internal.BUSY_WAITS.push(1)
+ try {
+ answers["/api/v1/zcode-plan/anthropic/v1/messages"] = new Response(JSON.stringify({ code: "3008", msg: "user concurrency limit exceeded" }), { status: 429, headers: { "content-type": "application/json" } })
+ answers["/api/anthropic/v1/messages"] = ok({ type: "message", content: [] })
+ const h = await freePlugin({ freeFirst: true })
+ const opts = await h.loader(async () => coding, { id: "zcode" })
+ const res = await opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: body("GLM-5.3-Flash") })
+ expect(res.status).toBe(200)
+ const u = await h.usage(async () => coding, { id: "zcode" })
+ expect((u.windows ?? []).find((w) => w.aside && /refusing requests/.test(w.name))).toBeUndefined()
+ } finally {
+ _internal.BUSY_WAITS.length = 0
+ _internal.BUSY_WAITS.push(...waits)
+ }
+})
+
for (const [name, paid, gift] of [
["a full paid week leaves an available gift usable", 100, 25],
["a spent gift leaves the paid plan usable", 20, 100],
diff --git a/packages/zcode/index.mjs b/packages/zcode/index.mjs
index 79b54b03..51118c41 100644
--- a/packages/zcode/index.mjs
+++ b/packages/zcode/index.mjs
@@ -257,6 +257,23 @@ async function plansOf(s) {
// model for a minute, so one turn that is refused does not ask again.
const blocked = new Map()
+// refused remembers the account's gift answering a freeFirst request with
+// a refusal: the replay answered from the plan, and the usage card says so
+// until the rest is out — a gift sitting idle beside a fat balance needs
+// the why. A blocked address says so for the route's ten minutes, a spent
+// answer for its minute, any other answer with its status.
+const refused = new Map()
+const REFUSED_TTL = 600_000
+const refusedId = (s) => s.key + "\0" + s.jwt
+const refusedWhy = (r) => ({ blocked: "the network's address is blocked", restricted: "ZCode restricted this account", spent: "its quota said spent" }[r.why] ?? r.why)
+function refusedLine(s) {
+ const r = refused.get(refusedId(s))
+ if (!r || Date.now() >= r.until) return null
+ const d = new Date(r.until)
+ const hhmm = String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0")
+ return { name: "ZCode's gift is refusing requests", used: 0, aside: true, display: `freeFirst answers from the coding plan until ${hhmm} (${refusedWhy(r)})` }
+}
+
// giftServes: the gift's buckets name their models lowercased ("glm-5.3-
// flash"); what is asked for arrives as the plan lists it ("GLM-5.3-Flash").
const giftServes = (gift, m) => (gift.models ?? []).some((x) => x.toLowerCase() === m.toLowerCase())
@@ -2351,7 +2368,21 @@ export async function ZCodeAuthPlugin({ client }, options = {}) {
const raw = giftModel(gift, m)
const available = raw && giftUsable(s, gift, raw)
if (!available) {
- const error = { type: raw ? "rate_limit_error" : "invalid_request_error", message: raw ? `ZCode's limited quota for ${raw} is exhausted or temporarily unavailable` : `ZCode has no active limited quota for ${m}` }
+ // the refused record says why the model rests: a blocked
+ // address or a restricted account is not a rate limit and
+ // not the quota — the reply carries the hint magpie's
+ // gateway classifies the rest by. A model the gift never
+ // serves keeps its own no-quota 400.
+ const r = refused.get(refusedId(s))
+ const blocked = raw && r && Date.now() < r.until && (r.why === "blocked" || r.why === "restricted")
+ const said = blocked && r.why === "blocked"
+ ? `ZCode's gift is blocked from this network — ${START_EDGE}`
+ : blocked
+ ? `ZCode's gift is restricted — ${START_RESTRICTED}`
+ : raw
+ ? `ZCode's limited quota for ${raw} is exhausted or temporarily unavailable`
+ : `ZCode has no active limited quota for ${m}`
+ const error = { type: blocked || !raw ? "invalid_request_error" : "rate_limit_error", message: said }
return kept(new Response(JSON.stringify({ type: "error", error }), { status: raw ? 429 : 400, headers: { "content-type": "application/json" } }))
}
orig = opts.body = JSON.stringify({ ...JSON.parse(orig), model: raw })
@@ -2385,14 +2416,19 @@ export async function ZCodeAuthPlugin({ client }, options = {}) {
const askedUrl = url
const place = workPlace(h)
const sid = sessionOf(h.get("x-session-id"), s.device)
- // restModel is the gift saying this model has nothing left: the
- // model rests a minute (blocked) and the route is dropped, so
- // the requests behind it re-read the balance and go straight to
- // the plan.
- const restModel = () => {
+ // rest is the gift saying this model — or, with a blocked
+ // address, every model it serves — has nothing useful right
+ // now: each model rests for ms, the route read is dropped, and
+ // the refusal is recorded for the usage card to say why the
+ // gift sits idle.
+ const rest = (models, ms, why) => {
for (const [k, until] of blocked) if (Date.now() >= until) blocked.delete(k)
- blocked.set(s.key + "\0" + s.jwt + "\0" + asked.toLowerCase(), Date.now() + 60_000)
- routes.delete(s.key + "\0" + s.jwt)
+ for (const [k, r] of refused) if (Date.now() >= r.until) refused.delete(k)
+ const id = refusedId(s)
+ const until = Date.now() + ms
+ for (const m of models) blocked.set(id + "\0" + m.toLowerCase(), until)
+ routes.delete(id)
+ refused.set(id, { until, why })
}
// replayOnPlan is free first's answer to a gift that refused
// what its own balance read had promised: the model as the
@@ -2476,7 +2512,12 @@ export async function ZCodeAuthPlugin({ client }, options = {}) {
// none) reads its plans again on the next request, so a
// coding plan that is there after all is used (#369)
if (freeGift) {
- restModel()
+ // what the card says must say what happened: a block page
+ // is the network's address being refused (every model the
+ // gift serves rests for the route's ten minutes, so the
+ // fallback costs nothing while it holds); ZCode's own 3012
+ // is the account restricted, not the address
+ rest(giftNames(known), REFUSED_TTL, EDGE_PAGE.test(text) ? "blocked" : "restricted")
return done(kept(await replayOnPlan()))
}
if (s.key && !forcedGift) routes.delete(s.key + "\0" + s.jwt)
@@ -2527,15 +2568,22 @@ export async function ZCodeAuthPlugin({ client }, options = {}) {
const cap = busy(res.status, text)
const spent = !cap && spentUp(text, res.status)
if (spent) {
- restModel()
+ rest([asked], 60_000, "spent")
return done(kept(await replayOnPlan()))
}
if (res.status >= 400) {
if (!cap && (res.status === 401 || res.status === 403)) routes.delete(s.key + "\0" + s.jwt)
+ // the card's line must hold for the rest it declares: the
+ // asked model rests the minute with the record. The gift's
+ // own waited-out cap is a turn not come, not a refusal.
+ if (!cap) rest([asked], 60_000, "it answered " + res.status)
return done(kept(await replayOnPlan()))
}
+ refused.delete(refusedId(s))
return done(kept(res, text))
}
+ // a stream's 200 is the gift answering again
+ refused.delete(refusedId(s))
}
// the plan's answer goes on as it came, the account kept: the
// built-in never marked a ZCode account lapsed nor cleared one
@@ -2597,6 +2645,10 @@ export async function ZCodeAuthPlugin({ client }, options = {}) {
// -Trial models, said on the card rather than left out unsaid
const need = start ? null : giftSignIn(s, auth?.type === "api")
if (need) card.windows = [...(card.windows ?? []), need]
+ // a gift refusing freeFirst's requests while its balance looks
+ // fat: the card says the plan is answering, and until when
+ const refusal = refusedLine(s)
+ if (refusal) card.windows = [...(card.windows ?? []), refusal]
}
return card
} catch (e) {
@@ -2660,4 +2712,4 @@ export async function ZCodeAuthPlugin({ client }, options = {}) {
// config sets what zcodeConfig has read, for tests: null is ZCode's config
// out of reach, undefined forgets it
const config = (cfg) => (cfgCache = cfg === undefined ? null : { cfg, at: Date.now() })
-export const _internal = { config, entry, limitWindows, termOf, startUsage, routes, blocked, plansOf, giftOf, dualUsage, claimHint, claimDaily, spentUp, giftServes, modelOf, teamKeys, ownSignIn, stateOf, located, dress, PROMPT, marked, keyDevice, sessionOf, busy, BUSY_WAITS, DEFAULT_CONCURRENCY, DEFAULT_ACCOUNTS }
+export const _internal = { config, entry, limitWindows, termOf, startUsage, routes, blocked, refused, plansOf, giftOf, dualUsage, claimHint, claimDaily, spentUp, giftServes, modelOf, teamKeys, ownSignIn, stateOf, located, dress, PROMPT, marked, keyDevice, sessionOf, busy, BUSY_WAITS, DEFAULT_CONCURRENCY, DEFAULT_ACCOUNTS }
diff --git a/packages/zcode/package.json b/packages/zcode/package.json
index 0d190377..7652248c 100644
--- a/packages/zcode/package.json
+++ b/packages/zcode/package.json
@@ -1,6 +1,6 @@
{
"name": "@magpie-community/opencode-zcode-auth",
- "version": "0.1.40",
+ "version": "0.1.41",
"description": "OpenCode provider plugin: signs in to Z.ai's or BigModel's (智谱) GLM Coding Plan as ZCode does, including team seats and ZCode's Start Plan, and claims its gift plans daily",
"type": "module",
"main": "./index.mjs",