Skip to content

zcode: freeFirst, the plain model spends the account's gift first - #505

Merged
yetone merged 5 commits into
magpie-community:mainfrom
jinpy666:feat/zcode-account-model-aliases
Oct 30, 2026
Merged

yetone merged 5 commits into
magpie-community:mainfrom
jinpy666:feat/zcode-account-model-aliases

Conversation

@jinpy666

@jinpy666 jinpy666 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What

A boolean option, freeFirst: a plain model request spends the account's own live gift first, under the model name the gift lists, and falls back to the coding plan when the gift cannot serve it. Set it for the whole plugin in magpie's Plugins › Options

{ "freeFirst": true }

or per provider under provider.zcode.options; where both say something, the provider's word stands.

Why

The gift's allowance lapses unused behind an agent that only ever asks the plain model: spending the gift meant renaming the request to <model>-Trial by hand. Turning the preference around needs no new model names, no per-account rules, no uids: an account with a coding plan and a gift spends the gift until it is spent, then the plan; a gift-only account already goes to the gift by its plain names; and across the accounts magpie rotates, each spends its own gift before its own plan.

Semantics

  • The gift is judged before the send, from the balance read the plugin keeps for ten minutes (the same giftUsable the trial entries use): spent bucket, a model the gift does not serve, no ZCode session, or a model in its minute's rest after a refusal — the request stays on the coding plan.
  • A gift that runs out between the balance read and the send answers 429 once and rests the model a minute; the requests after it go to the plan.
  • The -Trial entries keep their strict semantics: an explicit pick of the gift alone, never the plan.
  • An account whose gift is spent pays nothing extra: one balance read, then the plan as before.

Tests

Five new cases in dual.test.mjs (written red first): the plain model goes to the Start Plan dressed while the gift has quota (and no coding-plan request is made); falls back to the coding plan once the gift is spent; honors the provider-level option and lets a blocked model wait on the plan; leaves a model the gift does not serve on the coding plan; and stays off unless asked. Full packages/zcode suite 156 pass on current main (HOME=$(mktemp -d) bun test packages/zcode); bun scripts/check.mjs clean.

This replaces the per-account alias option this PR carried before: the review's points 1–3 (the modelNamed promise chaining, the alias missing from provider.models, no-uid accounts sending the logical name upstream) were all about that mechanism and no longer apply. The README documents the option and its boundaries.

@yetone

yetone commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for this. I reviewed 371ce97 line by line and ran it. It isn't ready to merge yet; here is what I found.

What was run (on macOS, Bun 1.4.2, with a temp HOME): bun test packages/zcode gave 153 pass. bun scripts/check.mjs was clean. I broke the change by hand: with the alias lookup off, two of the new tests go red, and with modelNamed off, one goes red. So the new tests guard what they say they do. I also ran a few probes of my own against the PR head, using the same fetch fake as dual.test.mjs. Their results are below.

1. With tool search on, a JSON reply loses the ToolSearch rename (index.mjs:2304 with index.mjs:1703). For a JSON body, modelNamed returns a Promise from res.text().then(...). done() then passes that Promise to searchNamed. searchNamed sees !res.body on the Promise and hands it back unchanged. So when both happen at once (Claude Code's ToolSearch renamed by searchless, and the model aliased), a non-streaming reply comes back with "name":"ToolSearchTool". The agent asked for ToolSearch. Probe output:

PROBE1 reply {"type":"message","model":"GLM-5.3-Flash-Pool","content":[{"type":"tool_use","id":"t1","name":"ToolSearchTool","input":{}}]}
expect(j.content[0].name).toBe("ToolSearch")  -> Received: "ToolSearchTool"

done needs to await modelNamed before it calls searchNamed, and a test should cover both changes on one request.

2. The alias never reaches magpie's model list or the agents' pickers. The config hook adds GLM-5.3-Flash-Pool, but magpie replaces the provider's models with what provider.models returns. That is magpie's internal/plugin/host.js:1743: out.models = ...next. This plugin's provider.models (index.mjs:2223, list built at :2240) builds its own list from the account's plan and never adds the alias. Probe output:

PROBE4 config has alias: true
PROBE4 models hook keys: GLM-5.3,GLM-5.3-Flash,GLM-5.2,GLM-5-Turbo,GLM-5.3-Flash-Trial
PROBE4 hook has alias: false

So the README's "zcode/GLM-5.3-Flash-Pool in the picker" (README.md:174) doesn't hold in magpie. A user would have to type zcode/GLM-5.3-Flash-Pool into the agent's config by hand. Magpie does serve a provider/model it doesn't list, but none of its pickers will show this one. provider.models should add the alias entries the current account has a rule for, and a test should check what provider.models returns, not only what config produces.

3. Many ZCode accounts have no uid, and for them the logical name goes to the vendor. The plugin stores uid only on its own OAuth sign-ins, and on "as ZCode" when ZCode's user_info has a user_id. These shapes carry none:

  • API-key sign-ins (stateOf, index.mjs:1507, type: "api").
  • Accounts magpie's mover moved from the built-in: zcodeOut in magpie's internal/provider/migrate_zcode.go:102 writes accountId and no uid. That is every user who had ZCode before it was retired.

For those accounts, GLM-5.3-Flash-Pool goes upstream as written. Probe output:

PROBE2 (mover's shape, accountId only) sent model GLM-5.3-Flash-Pool status 400
PROBE3 (API-key) sent model GLM-5.3-Flash-Pool

That is a vendor 400 for a name the vendor never had. It also says nothing about why the alias didn't apply. Please also match on the account's accountId, which every shape has. And when a configured alias has no rule for the account serving the request, answer locally with a clear error that names the alias and the account. Sending the name through unchanged is the wrong fallback here. One more case: a team seat and the same person's own plan share one user_id, so a uid key can't map them apart.

4. What this adds over magpie's routing groups. A magpie routing group whose members are zcode/GLM-5.3-Flash and zcode/GLM-5.3-Flash-Trial already gives one picker entry. Each member is tried over the provider's accounts, and an account that can't serve one member falls through to the next. The per-account mapping is finer than that, but the README should say when to use this option and when a routing group does the job. The README's second place, provider.zcode.options, can only be set in magpie by hand-editing plugins.json's config, which has no UI or CLI. The plugin's own options (magpie plugin options ...) do reach the plugin (host.js passes them as the second argument), so that part is fine.

Smaller notes, not blocking:

  • Replies are renamed to the alias, so magpie's usage records the alias as the model, and the alias has no price or plan of its own. Please check that the ZCode card still counts these requests against the right plan.
  • modelNamed writes model on every SSE event and on message/response objects. That's fine for Anthropic and OpenAI shapes. The SSE line handling held up when I split events mid-line across chunks with CRLF endings.

I'd merge this once 1 to 3 are fixed and each has a test that is red on this head.

@jinpy666
jinpy666 force-pushed the feat/zcode-account-model-aliases branch from 371ce97 to d0f02d3 Compare October 30, 2026 03:17
@jinpy666 jinpy666 changed the title zcode: account model aliases, one model name across accounts zcode: freeFirst, the plain model spends the account's gift first Oct 30, 2026
@jinpy666

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review — I ran your probes against the reworked head before answering.

The per-account alias option is withdrawn, so points 1–3 were resolved by removal rather than patching: modelNamed and the promise chaining (1), the alias missing from provider.models (2), and no-uid accounts sending a logical name upstream (3) all belonged to that mechanism. The branch is rewritten on current main (b9cbb9d) as one commit.

What replaced it is freeFirst (the plain model spends the account's own live gift first, the plan when the gift cannot serve it) — it needs no model aliases and no account ids, so those failure modes have no counterpart. On your point 4: with freeFirst the routing-group interplay becomes simple — one plain model name everywhere, magpie rotates accounts, each spends its own gift first — and the README now says so, along with the one 429 a gift that runs out mid-race can still cost.

Five new tests (red first): gift-first while quota lasts with no coding-plan request; fallback once spent; provider-level option honored with a blocked model waiting on the plan; a model the gift does not serve stays on the plan; and off unless asked. 156 pass, check clean.

@jinpy666
jinpy666 force-pushed the feat/zcode-account-model-aliases branch from d0f02d3 to 829b150 Compare October 30, 2026 03:22
@yetone

yetone commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Thanks for the rework. freeFirst is a much smaller change than the alias option, and the earlier points 1 to 3 are gone with it. I reviewed 829b150 line by line and ran it. It isn't ready to merge yet. Two problems break requests that work today, and the 429 path doesn't do what the README says.

What was run. On macOS (Bun 1.4.2, temp HOME): bun test packages/zcode gave 156 pass, and bun scripts/check.mjs was clean. I broke the change by hand to check the new tests. With the giftUsable guard removed, "a blocked model waits on the plan" goes red. With the provider's options ignored, the same test goes red. So the tests check what they say. I also ran probes against this head with dual.test.mjs's fetch fake (output below), and ran the PR's plugin in magpie's real plugin host and gateway: a sandbox HOME, the plugin's zcode.z.ai and api.z.ai hosts pointed at an httptest server, freeFirst: true set through magpie's plugin options, and no network.

What holds: the option is off unless it is set (both off forms send to the plan exactly as before). The provider's options win over the plugin's. API-key accounts (no jwt) are untouched. -Trial is unchanged: a spent gift still gets a local 429 and the plan is never asked. A streaming request is dressed and sent to the gift with stream: true and passes through unread. Moved accounts have the same key+jwt state as a dual sign-in (zcodeOut in magpie's migrate_zcode.go), so they take the same path.

1. With no gift, or a failed balance read, every request throws (index.mjs:2258)

giftServes(gift, m) reads gift.models, and gift is null whenever startPlan returns null or fails. That happens for a coding-plan account with a ZCode session and no live gift plan (plans: []), and for any balance read that fails. giftModel on the -Trial path uses giftNames, which handles null; this branch doesn't.

PROBE A (balance lists no plan)  ["threw TypeError: null is not an object (evaluating 'gift.models')" ×3]  plan msgs 0  balance reads 4
PROBE B (balance read 500)       ["threw TypeError: ..." ×3]  plan msgs 0  balance reads 4
PROBE B0 (same, freeFirst off)   status 200, plan msgs 1

In magpie's real host, a single account with no gift gets this on every request:

502 {"error":{"message":"ZCode: null is not an object (evaluating 'gift.models')","type":"api_error"}}

So turning freeFirst on blacks out every account that has no gift right now, which is exactly when it should change nothing. A failed read has to count as unknown and stay on the plan. It doesn't today; it fails the request.

The same line also reads the balance again on every request when known is null: gift ??= known ?? (await startPlan(...)). gift already starts as known, so with no gift it calls startPlan per request (4 reads for 3 requests above), bypassing plansOf's 10-minute and 1-minute cache. Please use known only: plansOf already caches null, and retries a failed read after a minute.

2. A gift-only account's behaviour changes (index.mjs:2248)

The README says "a gift-only account already goes to the gift by its plain names, so the option changes nothing there". But the branch doesn't check alone:

PROBE C freeFirst off: balance reads 0, models sent ["GLM-5.3-Flash" ×3]
PROBE C freeFirst on : balance reads 3, models sent ["glm-5.3-flash" ×3]

Every request now pays a balance read, the model is renamed, and forcedGift is set. That changes how errors are handled for an account that has no plan to fall back to: a quota answer becomes a 429 and the model is blocked, and a Start Plan refusal no longer drops the cached route (if (s.key && !forcedGift) routes.delete(...)). The branch needs !alone.

3. A gift that runs out is not "429 once": the user or the rotation gets it, and the plan is never tried

The plugin hands the gift's 429 back to magpie and never tries the coding plan, though the body was read ahead and nothing was spent. Who sees it depends on how many accounts there are:

  • One account: the user gets the 429, and the plan, which has quota, never sees the request.
  • Several accounts: magpie's gateway reads the gift's 1113 余额不足或无可用资源包 as out of credit (failure() in internal/gateway/routing.go, brokeWords). It rests that whole account for creditRest, 30 minutes, with its coding plan. A 1005 reads as out of quota, a 15-minute rest.

Real host, two dual accounts, both gifts answering 429 1113 and both plans healthy:

request 1: 429 {"error":{"message":"ZCode: 余额不足或无可用资源包,请充值。","type":"rate_limit_error"}}
   upstream: [/api/v1/zcode-plan/anthropic/v1/messages /api/v1/zcode-plan/anthropic/v1/messages]   ← both gifts, no plan
request 2: 200 from the plan || upstream: [/api/anthropic/v1/messages(dual.secret)]
resting zcode@second for 30m0s                                                                   ← its plan benched too

It is also not once per gift:

  • Concurrent requests: every in-flight request that passed the pre-check gets the 429. Three at once gave [429, 429, 429].
  • The cache: the minute's blocked entry runs out while plansOf's route still says the gift is live, for up to 10 minutes. So each minute brings one more 429. Over four simulated minutes: [429, 200, 429, 200, 429, 200, 429, 200] with 1 balance read.

-Trial should keep its 429, because it is the user's explicit pick. A request that freeFirst moved onto the gift is a different case: magpie made that choice, not the user. Its spent answer (spentUp's 429/1113/1005, a 1113 inside a 200, and startRefused) should be replayed on the coding plan inside the same fetch, with the original body and the plan's headers. blocked should still be set, and the cached route should be dropped or its gift marked spent, so the requests after it don't go back to the gift every minute. That needs a flag apart from forcedGift, so -Trial's path stays as it is. With that, neither the user nor magpie's rotation ever sees the gift running out, and the README's boundary paragraph can say so.

4. Smaller

  • "freeFirst" in providerOptions lets { freeFirst: undefined } override the plugin's true (probe: plugin true, provider {freeFirst: undefined}, sent to the plan). providerOptions?.freeFirst !== undefined is the safer test.
  • provider.zcode.options can still only be set in magpie by hand-editing plugins.json's config, as the last review said. The README could say that Plugins › Options is the place to set it.

Tests I'd want, each red on this head

  1. A dual account with plans: [], and one whose balance read answers 500: the plain model gets 200 from the plan, with at most one balance read across several requests.
  2. A gift-only account with freeFirst on: no balance read, the model as sent, the same as with it off.
  3. The gift answers 429 1113, and in another case a 200 carrying 1005: the request gets 200 from the plan in the same call, with one gift try. The next request goes straight to the plan.
  4. The same after the blocked minute runs out, inside the route's 10 minutes: still the plan.
  5. Three concurrent requests when the gift runs out: all 200.
  6. -Trial with a spent gift is still a local 429 with freeFirst on (it is today; keep it pinned).

The probes I ran are plain bun:test cases on dual.test.mjs's harness. Here are the helpers, if they're useful:

const MSG = "https://api.z.ai/api/anthropic/v1/messages"
const free = async (options, prov = { id: "zcode" }, acct = coding) =>
  (await ZCodeAuthPlugin({ client: { auth: { set: async () => {} } } }, options)).auth.loader(async () => acct, prov)
const post = (opts, model, extra = {}) => opts.fetch(MSG, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model, max_tokens: 100, messages: [{ role: "user", content: "hi" }], ...extra }) })
const giftMsgs = () => sent.filter((c) => c.origin === "https://zcode.z.ai" && c.path.endsWith("/messages"))
const planMsgs = () => sent.filter((c) => c.origin === "https://api.z.ai" && c.path.endsWith("/messages"))
const balReads = () => sent.filter((c) => c.path.endsWith("/billing/balance"))
const r = async (p) => { try { const x = await p; return `status ${x.status}` } catch (e) { return `threw ${e.constructor.name}: ${e.message}` } }

@jinpy666

Copy link
Copy Markdown
Contributor Author

All four findings fixed on 4c56bc3, with your probe list as tests, red first.

1. No gift / failed balance read. The branch no longer calls startPlan at all: it uses the route's cache alone (known from plansOf, null-safe), so a gift that can't be read keeps the request on the plan with no read per request. New tests: plans: [] and a 500 balance each see one billing read across three requests, all answered by the plan.

2. Gift-only accounts. The branch now requires !alone, so they are untouched: new test shows no billing read, the model as sent, the same bytes as with the option off.

3. The race. A spent gift (spentUp's 429/1113/1005-in-200, and startRefused) is replayed on the coding plan inside the same fetch — replayOnPlan sends the model as the agent asked it (asked, captured before the rename) with the plan's headers (marked, x-api-key/Bearer key, the work headers stripped again), and returns that answer to the agent. The minute's block is still set and the route is dropped, so the requests behind it re-read the balance and go straight to the plan. New tests: one gift try then the plan's 200 in the same call with the next request skipping the gift; the 1005-in-200 case; after the minute's rest (block expired, balance still live) the gift is tried once more and the plan answers again — the agent never sees the gift's 429; three concurrent requests all get 200s.

4. Options merge. providerOptions?.freeFirst !== undefined now, so a bare object leaves the plugin's standing; pinned by a test. The README's option-location paragraph now names plugins.json's config as the provider-level place.

-Trial with freeFirst on is pinned too: a spent gift still answers its local 429, no plan request. Full packages/zcode suite 165 pass (9 new), check clean.

@yetone

yetone commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed 4c56bc3 line by line and ran it. The four points from the last review are fixed, and the new tests guard them. Two things in the new replay still break a request that works with freeFirst off, so it isn't ready to merge yet.

What was run (mote, macOS, Bun 1.4.2, temp HOME):

  • bun test packages/zcode at 4c56bc3: 165 pass, 0 fail. bun scripts/check.mjs was clean.
  • My earlier probes (A–J) at 4c56bc3: no gift / a balance 500 give 200 from the plan with 1 balance read for 3 requests. A gift-only account reads no balance and sends the model as written. A 429 1113 and a 1005-in-200 are answered from the plan in the same call. The 4-minute run is [200 ×8], three at once give [200, 200, 200], and -Trial still gives a local 429. A provider {} leaves the plugin's true standing.
  • Red first: I ran 4c56bc3's dual.test.mjs against 829b150's index.mjs. 8 of the 9 new tests go red. The ninth is the -Trial pin, which passes on both, as it should.
  • Cold and stale cache: on a cold route, plansOf reads the balance itself, so the first request goes to a live gift. If the plan-list read fails, the account takes the dual route and the gift is still spent. A stale "live" route is dropped on the first spent answer, so the next request reads the balance again. With a failed balance read, the account stays on the plan for the minute's TTL.
  • The replay's body and headers match the freeFirst-off path byte for byte: the same metadata.user_id, x-session-id, x-api-key/Bearer key and ?beta=true, with the work headers stripped. A streaming request stays a stream. The ToolSearch rename survives the replay.

1. The replay skips the plan's busy wait (replayOnPlan, index.mjs:2283)

replayOnPlan returns the plan's first answer. The plan path's ask loop is the one that waits out the in-flight cap (3008/3009, BUSY_WAITS) and words it with busyAnswer, and the replay never goes through it. So a replay that lands on a busy plan hands the vendor's raw 429 back to magpie:

P1 freeFirst on : 429 {"error":{"code":"3008","message":"concurrency limit exceeded"}}  plan tries 1
P1 freeFirst off: 200                                                                   plan tries 2

(gift answers 429 1113; the plan answers 3008 once, then 200)

This is most likely to happen in the case the replay was built for. When several requests race a gift that just ran out, they all replay onto the plan at once, beside every other request in flight. magpie's failure() reads a bare 429 as failRate and rests the account, which is point 3 again by another route. Please send the replay through the same loop as the plan path. Pulling ask plus the busy loop into a helper that takes (url, opts, headers) would do it. A test: the gift answers 1113, the plan answers 3008 once and then 200, and the request gets 200 with two plan tries.

2. Any other gift error goes to the agent, and the plan is never tried (index.mjs:2402)

The freeGift branch replays only spentUp. A gift answering 500, 401 or 400 goes back as it came:

P2 gift 500 -> 500   plan tries 0
P2 gift 401 -> 401   plan tries 0
P2 gift 400 -> 400   plan tries 0

With freeFirst off, each of these requests would have been served by the plan. The reasoning from last time applies here too: freeFirst moved the request, not the user, so a gift that fails it for any reason shouldn't cost the request. A Start Plan outage (5xx), or a ZCode session revoked on the server before its exp (401), would fail every request on a dual account whose plan is healthy, for as long as the route is cached (up to 10 minutes). It would also send magpie's rotation the gift's error as if it were the account's.

Please replay any gift answer of 400 or above on the plan, not only spent ones. The gift's busy answer after BUSY_WAITS counts too, since the plan has a cap of its own. A spent or refused gift should also keep the blocked minute and drop the route, as it does now. A 401/403 should drop the route, so the next balance read finds out whether the session is gone. -Trial keeps its own path. A test per status (500, 401) should check for a 200 from the plan in the same call, with one gift try.

Not blocking: count_tokens on a dual account now goes to the gift too, dressed with ZCode's prompt. That matches what a gift-only account already does, and it counts the same prompt the message will be sent with, so I'm fine with it.

I'll merge once 1 and 2 are fixed, each with a test that is red on 4c56bc3.

@jinpy666

Copy link
Copy Markdown
Contributor Author

Both fixed on 1a8be2e, your scenarios as tests, red first.

1. The replay now waits out the plan's cap. The plan path's ask-and-wait moved into a module-level askBusy(u, opts, headers) — the same loop, byte for byte — and the replay goes through it. Test: gift answers 1113, the plan answers 3008 once then 200 — the request gets 200 after two plan tries.

2. Any gift failure is answered from the plan. The freeGift branch now distinguishes: a spent answer (spentUp) or a block page rests the model a minute and drops the route read (restModel, split out of the replay); a 401/403 drops the route read alone, so the next request's balance read learns whether the session is gone; the gift's own waited-out busy cap replays with no rest and no drop — a cap is a turn not come, not spent — and the plan's cap being its own, the replay waits it out too. Tests: 500/401/400 each answered 200 by the plan with exactly one gift try; the gift's 3008 through shortened waits answered by the plan with no blocked entry; a 405 block page answered by the plan with the minute's rest set.

bun test packages/zcode at 1a8be2e: 169 pass (4 new), check clean. The README's boundary paragraph now says the replay waits out the plan's cap and names which failures rest the model.

@yetone

yetone commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed 1a8be2e line by line and ran it. Both points from the last review are fixed, and the new tests guard them. One gap of the same kind as point 2 is left, so this isn't merged yet.

What was run (mote, macOS, Bun 1.4.2, temp HOME):

  • bun test packages/zcode at 1a8be2e: 169 pass, 0 fail. bun scripts/check.mjs was clean.
  • Red first: I ran 1a8be2e's dual.test.mjs against 4c56bc3's index.mjs. Three of the four new tests go red: the replay's busy wait, the other gift failures, and the gift's own waited-out cap. The fourth is the 405 block page, which 4c56bc3 already replayed, so it passes on both, as it should.
  • My probes at 1a8be2e (gift GLM-5.3-Flash, a dual account):
    • Gift used up: a 429 1113 and a 1005 inside a JSON 200 each give 200 from the plan in the same call, with one gift try. The model rests a minute and the route is dropped, so the next request goes straight to the plan.
    • Busy 3008/3009 (point 1): with the gift spent and the plan answering 3008 (or 3009) once, then 200, the request gets 200 after 2 plan tries. With the plan busy the whole time, it gets busyAnswer's worded 429 after 7 tries (~9.6s), the same as with freeFirst off. askBusy is the old loop moved, byte for byte.
    • 500/502/401/403/400 on the gift path (point 2): each one gets 200 from the plan, with one gift try and one plan try. A non-JSON 502 HTML page does too. None of them rests the model. After a 401 or 403 the route is dropped, and the next request reads the balance again. After a 500, 502 or 400 the route is kept. The gift's own 3008, waited out (7 gift tries), is answered by the plan with no rest. If the plan then fails as well (503), the agent gets the plan's own answer.
    • Plain model, no gift: with plans: [], a spent bucket, or a balance 500, three requests each get 200 from the plan, with no gift try and one balance read. The model goes as written. A model the gift doesn't list goes to the plan.
    • Unchanged: a streaming 200 from the gift passes through unread. -Trial keeps the gift's 500 with no plan try. Three requests at once with the gift answering 500 get [200, 200, 200].

A gift request that throws is never answered from the plan (index.mjs:2369)

let res = await askBusy(url, opts, h) has no catch. When the request to zcode.z.ai fails on the network (connection reset, DNS, TLS, a timeout), the error goes up to magpie, and the plan is never tried:

S7 freeFirst on : threw fetch failed: ECONNRESET   plan tries 0
S7 freeFirst off: 200

This is point 2 again: freeFirst moved the request, so a gift that fails it shouldn't cost the request. zcode.z.ai is a different host from api.z.ai. If it can't be reached (an outage, or a network that blocks it), every request on a dual account fails while the plan is healthy. The cached route keeps the request on the gift for up to 10 minutes, and magpie gets an error for a request that would have worked.

Please catch a throw from the gift ask when freeGift is set and answer it with replayOnPlan(). Drop the route too, so the next balance read finds out whether the host is back. A caller's abort has to stay an abort. When opts.signal?.aborted is set (or the error is an AbortError), throw it again and don't replay. Today an aborted gift request throws AbortError with no plan try, which is right. A test for each: a gift fetch that throws TypeError("fetch failed") gets 200 from the plan in the same call, and an aborted signal throws with no plan request.

Not blocking: the PR doesn't bump packages/zcode/package.json, which is 0.1.36, the same as npm. Bumping it to 0.1.37 in this PR would let the merge publish it. Otherwise I'll bump it after the merge.

I'll merge once the throw is replayed, with tests that are red on 1a8be2e.

@jinpy666

Copy link
Copy Markdown
Contributor Author

Fixed on 6a48562, your two tests red first.

The gift's ask sits in a try whose catch answers from the plan when freeGift is set — replayOnPlan, with the route read dropped so the next request's balance read finds out whether the host is back. A caller's abort stays an abort: opts.signal?.aborted or an AbortError name rethrows before any replay, and the abort test pins that no plan request is made. The route-drop test also pins the re-read: after the throw-replay, a second request makes a second billing read.

Not left out: packages/zcode/package.json is at 0.1.37 so the merge publishes this.

bun test packages/zcode at 6a48562: 171 pass (2 new), check clean. The README's boundary paragraph names the unreachable-host case and the abort rule.

yetone added a commit that referenced this pull request Oct 30, 2026
…ent, not a failed check-in

hykql (Discord, magpie v0.1.2143, zcode 0.1.36) saw every ZCode account's
daily check-in fail with "gift plans to claim: no plan list in the reply",
a Start Plan account and one with no GLM Coding Plan alike. ZCode's
GET /billing/preview answers an account with nothing to claim with no
data at all, not data.plans []:

  {"code":0,"msg":"","logid":"202610300535585c4e3de75769b684da87"}

(the owner's account, read 10-30 with GETs only, with and without the
X-ZCode-App-Version / X-Platform headers; both the same). #503's capture
of 10-29 had "plans": [], so 0.1.35 took a missing list as unreadable.

previewPlans now reads the list for both the claim and the card's claim
line: no data, data null, or data without plans (or plans null) is a
list of none, so the check-in is "inactive" (magpie shows "No check-in
event now"). call has already judged the code, so only a code 0 reply
gets here. Data that isn't an object, or plans that aren't a list, still
don't read and stay failed, never claimed.

Run (wsl-run on magpie-desktop, HOME=$(mktemp -d); mote was at load
220-250): `bun test` in packages/zcode, 152 pass 0 fail; the whole
repo's `bun test`, 3720 pass 0 fail of 3727 across 298 files;
`bun scripts/check.mjs zcode` ok. The new checkin test (the real
no-data reply, data null, data without plans, plans null) is red on
0.1.36's index.mjs (1 fail). Broken by hand: plans that aren't a list
read as none turns "a preview that doesn't read is failed" red; put
back, green.

Not tried: a real claim (no account has a plan to claim now), and the
official ZCode client's own parse of this reply (no ZCode app on hand);
the fixture is the vendor's real bytes. Not changed: #505 (freeFirst)
touches other parts of index.mjs.
@yetone

yetone commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Re-reviewed 6a48562 line by line, against current main (7673cd0, zcode 0.1.37) as well as against 1a8be2e, and ran it. The fix itself is right: a gift that throws is answered from the plan, and the route is dropped. Two things still stop the merge.

What was run (mote, macOS, Bun 1.4.2, temp HOME, on 6a48562 merged into main 7673cd0, which merges clean):

  • bun test in packages/zcode: 172 pass, 0 fail. bun scripts/check.mjs zcode: ok. npm pack --dry-run: 4 files (README.md, index.mjs, package.json, prompt.mjs), nothing else.
  • Broken by hand: with the catch reduced to throw e, "a gift that cannot be asked at all is answered from the plan" goes red (1 fail). Good.
  • My probes on the fix: an abort before the request, and an abort while the gift is in flight, each throw AbortError with 1 gift try and 0 plan tries. A gift that answers 3008 and then throws gets 200 from the plan. When the gift and the plan both throw, the agent gets the plan's error and the route is dropped. With freeFirst off, zcode.z.ai is never asked. All as they should be.

1. The version collides with main's 0.1.37 (packages/zcode/package.json)

Main published zcode 0.1.37 today (7673cd0: an account with no gift plan to claim is no longer a failed check-in). npm has 0.1.37 already. This branch sets 0.1.37 too, and because both sides changed the line to the same value, git merges it silently. The squash would ship 0.1.37 again. The publish fails on an existing version, and nobody gets freeFirst.

Please rebase onto main (7673cd0 or later) and bump to 0.1.38. The rebase is clean here: 7673cd0 touches previewPlans/claimDaily, which the PR doesn't.

2. "an aborted gift request stays aborted" never reaches the gift (dual.test.mjs)

giftUnreachable() with no argument throws TypeError("fetch failed") for every URL. That includes the subscription list and the balance read, so plansOf never sees a gift, freeGift is never set, and the request goes straight to the plan, whose fetch throws too. The test passes for that reason, not because of the abort check. In my run of it, the only URLs asked were:

https://api.z.ai/api/biz/subscription/list
https://zcode.z.ai/api/v1/zcode-plan/billing/balance
https://api.z.ai/api/anthropic/v1/messages     <- the plan; the gift is never tried

Broken by hand: with the guard reduced to if (!freeGift) throw e (no abort check), the whole of dual.test.mjs still passes, 48 of 48. With the same break, my probes show what the test should catch. An abort while the gift is in flight is replayed on the plan (plan tries 1). An aborted signal whose fetch throws a TypeError gets 200 from the plan instead of an abort.

Please make the test reach the gift. Let the reads answer as usual, throw only on the gift's /messages, and throw the way a real fetch does: an AbortError (new DOMException("…", "AbortError")) when init.signal?.aborted. Then assert 1 gift try and 0 plan tries. Record the tries in the wrapper, because sent only sees requests that get past it. A second case where the signal is aborted while the gift is in flight (abort, then throw a plain TypeError) covers the opts.signal?.aborted half on its own. Both should be red with the guard reduced to if (!freeGift) throw e.

Nothing else is new. The rest of the diff against main is the code I reviewed at 1a8be2e. I'll merge once the branch is on main at 0.1.38 and the abort test goes red without the guard.

The gift's allowance would lapse unused behind an agent that only ever
asks the plain model: spending the gift took renaming the request to
<model>-Trial by hand. freeFirst turns the preference around: the plain
model spends the account's own live gift first — under the name the
gift lists — and falls back to the coding plan when the gift cannot
serve it. Set it for the whole plugin in magpie's Plugins › Options, or
per provider under provider.zcode.options; the provider's word stands.
The -Trial entries keep their strict semantics, and a gift that runs
out between the balance read and the send answers 429 once, rests the
model a minute, then the plan takes the traffic.
…g else

Review probes against the real plugin host found three ways freeFirst
broke requests that work today: a gift that could not be read made
every request throw (the branch now reads the route's cache alone, so a
failed read keeps the plan with no read per request); a gift-only
account was renamed and flagged where it was already on the gift (the
branch now skips it); and a gift that ran out between its balance read
and the send answered the agent 429 (its refusal is now replayed on the
coding plan inside the same fetch, the model as the agent sent it and
the plan's headers, with the minute's block and a dropped route read
sending the requests behind it straight to the plan). freeFirst in a
provider's options no longer overrides the plugin's until it says
something, and the README names plugins.json's config as the
provider-level place.
…gift failure

Review found the replay returning the plan's first answer: a replay that
landed on a busy plan handed magpie the raw 3008/3009 429. The plan
path's ask-and-wait is now a module-level askBusy the replay goes
through too. And the freeGift answer only replayed spent answers: a 500,
a 401, a 400, or the gift's own waited-out busy cap went to the agent
instead of the plan whose quota the request was never meant to spend.
The block page and a spent answer rest the model a minute and drop the
route read; a 401/403 drops the read alone, so the next request learns
whether the session is gone.
…plan

A throw from the gift's ask — the network, the host — went to magpie
with the plan never tried, though the two live on different hosts: with
freeFirst the request is replayed on the plan and the route read is
dropped, so the next request learns whether the host is back. A
caller's abort stays an abort, replay or no replay.
Rebased on main (7673cd0) and bumped past the 0.1.37 npm already has.
The abort tests threw on every URL, so the reads failed and the request
never reached the gift at all — the abort guard went untested. They now
throw on the gift's messages alone, an AbortError for a signal aborted
before the ask and an abort-then-TypeError for one aborted in flight,
counting their tries where the fetch fake cannot see them.
@jinpy666
jinpy666 force-pushed the feat/zcode-account-model-aliases branch from 6a48562 to 7ae2ea1 Compare October 30, 2026 06:09
@jinpy666

Copy link
Copy Markdown
Contributor Author

Both done on 7ae2ea1.

Version. Rebased onto main (7673cd0 — clean, as you said) and bumped to 0.1.38.

The abort tests now reach the gift. giftThrowing throws on the gift's /messages alone — the subscription list and the balance answer as usual, so freeGift is actually set — and counts the tries where the fetch fake cannot see them (a thrown request never reaches it). Two cases:

  • Aborted before the ask: the signal is pre-aborted and the gift throws AbortError — rethrown, 1 gift try, 0 plan tries.
  • Aborted in flight: the wrapper aborts the controller and throws a plain TypeError (as some runtimes surface it) — the opts.signal?.aborted half catches it, rethrown, 1 gift try, 0 plan tries.

With the guard reduced to if (!freeGift) throw e, both go red (the TypeError case replays and asks the plan); with the guard as merged-in-waiting, both pass. bun test packages/zcode at 7ae2ea1 on the rebase: 173 pass, check clean.

@yetone
yetone merged commit 5a9c776 into magpie-community:main Oct 30, 2026
@yetone

yetone commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Thanks @jinpy666! Merged at 7ae2ea1 after re-running it: 173/173 zcode tests pass, and both abort tests go red without the abort check. zcode 0.1.38 is on npm, and magpie v0.1.2160 raises the ZCode mover's min to it, so moved ZCode users get freeFirst on their next update. Not tried yet against a real account that has both a coding plan and a gift; if you see anything off there, please open an issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants