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",