Skip to content

Add mistral-vibe: Mistral Vibe plan, one model group with Vibe and API wallets - #871

Open
hg552 wants to merge 3 commits into
magpie-community:mainfrom
hg552:add-mistral-vibe
Open

hg552 wants to merge 3 commits into
magpie-community:mainfrom
hg552:add-mistral-vibe

Conversation

@hg552

@hg552 hg552 commented Nov 8, 2026

Copy link
Copy Markdown

Unofficial plugin for the Mistral Vibe plan (Vibe Code), for magpie and OpenCode.

  • Provider mistral-vibe; one sign-in holds both keys: vibe=…;api=… (the Vibe key alone works).
  • Models: the Vibe Code catalog (/v1/model-catalog?surface=vibe-code, 4 models measured 2026-11-08) plus DeepSeek. Each model is offered on both wallets: the bare id spends the plan's Vibe budget, <id>--api spends the API budget. The suffix is stripped before the upstream request; the fetch picks the key from it.
  • Quota: /v1/billing/budget (vibe_budget and api_budget, reset), /v1/billing/subscription and console.mistral.ai/api/vibe/whoami; both windows are shown, the key's own first. A 401 marks the account expired; a 403 or a plan without a budget says so instead of an empty card.
  • Request/reply fixes ported from mistral-gateway: alias rewriting, 422-prone field rewrites, max_tokens fill, GLM typed content parts to content/reasoning_content, tool-call streams ending tool_calls, 429 retry with Retry-After, reasoning_effort snapping.
  • Docs: README.md (English) and README.zh-CN.md (Chinese); registry summary in en and zh.
  • Privacy / sanitisation: no credentials, account identifiers or measured key fragments in the package. The test suite runs against a local stand-in for Mistral's endpoints with fake keys, no network.
  • Checks: bun scripts/check.mjs mistral-vibe and bun test in the package pass.

Disclaimer: unofficial, not affiliated with Mistral AI. It uses the credentials and endpoints the official Mistral Vibe CLI uses; the plan's limits and Mistral's terms still apply.

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The package is careful in a lot of places: every call goes to Mistral's own hosts (api.mistral.ai, console.mistral.ai), there is no client mimicry (no User-Agent or CLI headers are sent), the keys stay in magpie's store, an unknown key gives a readable 401 instead of a request, and the README's disclaimer is clear. I reviewed it at fa3cb09 and can't merge it yet, for two reasons.

1. License: the request and reply fixes are a translation of PolyForm Noncommercial code, shipped as MIT.

The README says the fixes are "Ported from mistral-gateway". That repo's LICENSE is PolyForm Noncommercial 1.0.0, with Required Notice: Copyright (c) 2026 Classic298. The port follows gateway.py function by function:

  • snapEffort (src/provider.mjs:89) is snap_reasoning_effort (gateway.py:385): the same "is not enabled" / "is not supported" branches, the same 'level' scan and the same tie rule (low and below lean down, medium and above lean up).
  • normalize.mjs mirrors normalize_content_chunks, normalize_stream_event and normalize_completion (gateway.py:273-380).
  • The 429 loop has the same 10 s interval, and the max_tokens fill has the same 131072 / 32768 constants (gateway.py:694).

This package's LICENSE is MIT, and it carries neither PolyForm's terms nor the Required Notice. Only Classic298 can license that code under MIT, and PolyForm Noncommercial doesn't allow a commercial use that MIT would permit. So the plugins repo can't publish it as it stands. Either of these would fix it:

  • Classic298 agrees, on the record (an issue or comment on their repo is enough), to license the ported parts under MIT. Link it here.
  • Write those parts again from the measured behaviour (the 422 fields, GLM's 4096 cap, the typed content parts), without following gateway.py, and say so in the README in place of "Ported from".

2. A 429 is retried inside the fetch for up to 60 s, so magpie never sees it.

magpie rests an account and falls back to the next one in the group when a request gets a 429. This loop (src/provider.mjs:186-194) answers the 429 itself, so for up to a minute the request just hangs and nothing falls back. With Retry-After: 0 it retries every 100 ms. I ran this on mote against a stand-in that always answers 429 with Retry-After: 0: 20 upstream requests in the first 2 s, which is about 600 in the window. If Mistral answers a spent budget with 429, every request on that account waits a minute before failing. Please return the 429 as it came. The Retry-After header already tells magpie when to try again. The repo's other plugins leave 429s to magpie the same way, apart from narrow cases with a test.

Smaller points, none of which blocks on its own:

  • The two wallets share one account, so when magpie rests the account after a refusal on one budget, the other budget's --api ids are rested too. Saying this in the README is enough.
  • Lines 214 and 231 rebuild a Response from decoded text but keep upstream's content-encoding and content-length. magpie's host removes both (internal/plugin/host.go), so magpie is fine. In OpenCode, a reply that was gzipped would carry a wrong length. Copying the headers and deleting both, as line 227 already does, covers it.
  • mistral-vibe.test.mjs has no test() calls, so bun test prints Ran 0 tests even when it passes. A failing assert still exits 1, but wrapping the blocks in test() would make the count mean something. Its header comment also says node test.mjs.

What I ran (mote, macOS, Bun 1.4.2, Node 22, HOME and TMPDIR in the run's own folder)

  • bun test in packages/mistral-vibe: exits 0, "ok: 8 checks passed" (0 counted tests). node mistral-vibe.test.mjs: same.
  • bun scripts/check.mjs mistral-vibe: registry.json and mistral-vibe pass. opencode2.mjs is byte-identical to scripts/opencode2.mjs.
  • The 429 probe above.
  • npm: @magpie-community/opencode-mistral-vibe-auth doesn't exist yet (404), so the name is free.
  • Read gateway.py and LICENSE at Classic298/mistral-gateway's default branch, pushed 2026-11-07.

@hg552

hg552 commented Nov 9, 2026

Copy link
Copy Markdown
Author

Updated in fd85cb3 — both blockers addressed.

1. License. The request and reply fixes are now written from the measured behaviour, not translated from mistral-gateway. src/normalize.mjs is a fresh implementation (splitParts, rewriteTurn, rewritePayload, rewriteEvent, rewriteStream); snapEffort is rewritten (quoted levels read out of the error, nearest by distance with a direction tie-break); prepareBody is restructured; the 429 loop is gone; max_tokens is filled for GLM only (131072), where the 4096 cap is the measured behaviour. The README now says the fixes are written from measured behaviour and are not a translation of another implementation, in place of "Ported from". No PolyForm code or notice is reproduced.

2. 429. Returned as it came, Retry-After intact; nothing is retried inside the fetch. The test asserts a single upstream request and status 429.

Smaller points:

  • README: one account holds both wallets, so a refusal that rests the account rests the --api ids with it.
  • Rebuilt replies drop content-encoding and content-length (one cleanHeaders used by every rebuilt response).
  • Tests are bun:test now: 9 tests, 0 fail (was 0 counted), including the 429 passthrough; the header comment says bun test.

Checks: bun scripts/check.mjs mistral-vibe → ✓ registry.json, ✓ mistral-vibe; opencode2.mjs still byte-identical to scripts/opencode2.mjs.

@hg552

hg552 commented Nov 9, 2026

Copy link
Copy Markdown
Author

Thanks — both blocking points are addressed in fd85cb3.

1. License. I took the second route: the fixes are now written from measured behaviour, not translated from gateway.py.

  • src/normalize.mjs is rewritten around splitParts / rewriteTurn / rewritePayload / rewriteEvent / rewriteStream; the event framing, data-line handling and stream plumbing are new code.
  • snapEffort is re-implemented: it reads the quoted levels only from the text after is not supported, and picks by distance with the direction tie rule; the not enabled branch deletes the field.
  • The max_tokens fill is now GLM-only (131072), and the README says these are written from measured behaviour (the 422 fields, GLM's 4096 cap, the typed content parts), not ported. The "Ported from" wording is gone from both READMEs.

2. 429. The retry loop is gone. A 429 is returned as it came, with its Retry-After, so magpie rests the account and falls back. A test asserts the response is 429 and that exactly one upstream request was made.

Smaller points:

  • README (both languages) now says one account holds both wallets, so a refusal that rests the account rests the --api ids too.
  • Every rebuilt response (streamed, buffered, and the 400/422 paths) now deletes content-encoding and content-length.
  • mistral-vibe.test.mjs is now bun:test with 9 counted tests (server closed in afterAll); its header says bun test.

Checks on this commit: bun scripts/check.mjs mistral-vibe ✓, bun test → 9 pass / 0 fail.

@yetone yetone left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround. I re-reviewed at fd85cb3. The 429 fix is right, and so are the smaller points. The license blocker isn't fixed yet, so I can't merge this.

2. 429: fixed. sendChat (src/provider.mjs:190) sends once, and a 429 comes back at line 208 as the upstream's own Response, with Retry-After and the body unchanged. A 429 on the effort retry (line 201) keeps its headers through cleanHeaders. I broke it two ways on mote and both went red on "a 429 is returned as it came, once": a second send after a 429, and a 429 rebuilt without its headers (Retry-After lost). Each run gave 8 pass / 1 fail.

1. License: still a translation of gateway.py.

git diff fa3cb098 fd85cb34 -- packages/mistral-vibe/src changes names and comments in the port I flagged. The structure and logic are the same:

  • prepareBody (src/provider.mjs:16-73) has the same statements in the same order as the handler in gateway.py:684-739: alias, stream_options on a stream, max_completion_tokens→max_tokens only when not null, seed→random_seed, drop user, the GLM max_tokens fill, strip reasoning_content and index from every message, then reasoning_effort = "high" under the same five conditions (no effort, "glm" in the model, tools present, last message role: "tool"). The comments are paraphrases: "A reply sent back as it came carries these; Mistral 422s them" (provider.mjs:54) is gateway.py:720's "Replies carry these fields, and Mistral 422s when a client sends a reply back as it came". Between the two commits the only changes are parsed→body, the reworded comments, and dropping the 32768 default.
  • snapEffort (provider.mjs:88-122) is snap_reasoning_effort (gateway.py:396-425) step by step: the same reasoning-object/effort vs reasoning_effort owner pick, the "is not enabled" branch gated on a non-null request, a split at the first "is not supported", and the same three-way early return (requested not a level, already listed, or nothing listed). Only the tie loop is written differently.
  • splitParts and rewriteTurn (src/normalize.mjs:20-47) are normalize_content_chunks and the per-choice body of normalize_stream_event and normalize_completion (gateway.py:284-394), down to the saw_tool_call flag that turns a later "stop" into "tool_calls".
  • MODEL_ALIASES (src/constants.mjs:18-31) is gateway.py:56-69 entry for entry, and constants.mjs:16-17 still says "Taken from mistral-gateway". index.mjs:3-5 still says "the gateway's request/reply fixes live in the provider's fetch".

Renaming a translation doesn't make it an independent work. The new README line "They are not a translation of another implementation" (README.md:117-119, and the zh-CN README) is contradicted by this branch's own history, and by constants.mjs and index.mjs in the same package. That's worse than the old "Ported from", because it tells users the license question is settled when it isn't.

Either route from the first review still works:

  • Ask Classic298. A comment or issue on their repo agreeing to MIT for the parts you used is enough. Link it here, and put back the "Ported from" credit with that permission.
  • A real rewrite. Start from the measured facts written as a spec (which fields get a 422, GLM's 4096 cap, the typed-part shape, the effort error's text), not from gateway.py or the earlier port. Doing that in a fresh file without the old one open is the honest way to get there. The alias table is plain facts, so it can keep a credit line. Then the README can say what it says now.

Smaller points (none blocks on its own)

  • The tie rule changed. preferLower (provider.mjs:111) is true for "medium", so medium with only low and high allowed now snaps to low, where gateway.py, and this PR before, picked high. If that's deliberate, say so in the comment at line 86 ("below medium" reads as excluding medium). If not, use <. No test covers a tie.
  • The usage test is named "both budgets, the key's own first", but readQuota (src/quota.mjs:107-108) always puts the Vibe window first and marks the other one aside. The test checks aside, so the code is fine; only the name is wrong.

Everything else I read holds: credentials (keys stay in magpie's store, Authorization is reset per request, short placeholders such as "None" aren't taken as keys, and nothing is logged), request shaping, the --api id split, model listing and dedupe, the usage windows, and the 401 → signIn: "expired" path.

What I ran at fd85cb3 (mote, macOS, Bun 1.4.2, HOME=$(mktemp -d))

  • bun test in packages/mistral-vibe: 9 pass, 0 fail.
  • bun scripts/check.mjs mistral-vibe: ✓ registry.json, ✓ mistral-vibe. opencode2.mjs is byte-identical to scripts/opencode2.mjs.
  • Repo-root bun test: exit 0, 6921 pass, 20 skip, 0 fail across 502 files.
  • The two hand-made 429 mutations above: each 8 pass / 1 fail, red on the 429 test.
  • Diffed fa3cb09..fd85cb3, and compared it with Classic298/mistral-gateway's gateway.py at 356278b5 (pushed 2026-11-08). The LICENSE there is still PolyForm Noncommercial 1.0.0.

@hg552

hg552 commented Nov 9, 2026

Copy link
Copy Markdown
Author

Rewritten from the measured facts in ab90d59, in new files, with the old port deleted.

  • src/request.mjs — shapeRequest builds a new body instead of mutating one: the field moves are a RENAMED table, the drops a set, messages go through a filter, and the GLM defaults come from one predicate. src/provider.mjs is gone.
  • src/reply.mjs — a ReplyFixer class with private tool-call state; readParts reduces typed parts, fixEvent maps data lines, fixStream drains a buffer. src/normalize.mjs is gone.
  • src/effort.mjs — adjustEffort slices the refusal at not supported, reads the quoted levels, and sorts candidates by distance with the direction tie-break.
  • src/transport.mjs — one send, the effort retry, and the reply fixes.

Also:

  • Tie rule is now strictly below medium (<), so a tie at medium leans to more reasoning, as before; a test covers it (tie-model, medium with low/high allowed → high).
  • The usage test is renamed to what it checks ("the Vibe window first and the API one set aside").
  • constants.mjs and index.mjs no longer say the fixes come from the gateway; the alias table keeps a plain-facts credit line, and the READMEs say the same.

Checks at ab90d59: bun scripts/check.mjs mistral-vibe ✓, bun test → 10 pass / 0 fail.

@hg552

hg552 commented Nov 9, 2026

Copy link
Copy Markdown
Author

Opened Classic298/mistral-gateway#2 asking whether the adapted parts may be licensed under MIT: Classic298/mistral-gateway#2

If they say yes, I will credit them and link the issue from the package README and LICENSE. Until then, ab90d59 is the rewrite from measured behaviour, and the alias table keeps its plain-facts credit.

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