diff --git a/README.md b/README.md index 733b30a6..00113fdf 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ subscription and makes its requests. The packages work in OpenCode and in | [minimax](packages/minimax) | MiniMax Code (China): account credits and M Plan, with MiniMax Code's device sign-in | `minimax-code` | | [minimax](packages/minimax) | MiniMax Code (international): account credits and M Plan, with MiniMax Code's device sign-in | `minimax-code-global` | | [mimo](packages/mimo) | Xiaomi MiMo, with a Xiaomi account | `mimo-app` | +| [mistral-vibe](packages/mistral-vibe) | Mistral Vibe (Vibe Code, unofficial): the CLI's model catalog plus DeepSeek, each model on both wallets — the plan's Vibe budget, and the API budget as `--api` — with both budgets as quota windows | `mistral-vibe` | | [nous](packages/nous) | Nous Portal models, with Nous Portal's device sign-in | `nous-portal` | | [opendesign](packages/opendesign) | Open Design Cloud (open-design.ai), with the Open Design CLI's device sign-in | `opendesign` | | [qoder](packages/qoder) | Qoder subscription (qoder.com), with Qoder's device sign-in | `qoder` | diff --git a/packages/mistral-vibe/LICENSE b/packages/mistral-vibe/LICENSE new file mode 100644 index 00000000..e6bc674f --- /dev/null +++ b/packages/mistral-vibe/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 magpie-community contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mistral-vibe/README.md b/packages/mistral-vibe/README.md new file mode 100644 index 00000000..6cc5c907 --- /dev/null +++ b/packages/mistral-vibe/README.md @@ -0,0 +1,175 @@ +# Mistral Vibe + +[简体中文](README.zh-CN.md) | English + +Mistral Vibe's coding plan in [magpie](https://usemagpie.ai) and +[OpenCode](https://opencode.ai). **Unofficial:** not affiliated with, +endorsed by, or supported by Mistral AI. + +One provider, one model group, two wallets. The Vibe key signs in and the +model list is the Vibe plan's own catalog plus DeepSeek; every model is +offered on the Vibe budget and, if a Studio key is given too, on the API +budget as `--api`. The plan's monthly Vibe Code and API budgets are read +into magpie's usage card, `magpie quota`, the quota alert and +`GET /v1/magpie/quotas`. + +**Provider id:** `mistral-vibe` + +| | | +|---|---| +| API | `https://api.mistral.ai/v1` (`@ai-sdk/openai-compatible`) | +| Sign-in | one credential holding both keys: `vibe=…;api=…` (the Vibe key alone works) | +| Models | the Vibe Code catalog (4 models on 2026-11-08) plus DeepSeek | +| Quota | `GET /v1/billing/budget` → `vibe_budget`, `api_budget`, `reset_at` | + +## Sign-in + +```sh +magpie plugin login mistral-vibe +``` + +Paste one line: + +``` +vibe=;api= +``` + +- **Vibe key** — the key the official CLI writes to `~/.vibe/.env` after + `vibe --setup`, or chat.mistral.ai/code/extensions → **Vibe CLI** card → + **Advanced** → **Vibe API Key**. Its requests spend the plan's Vibe budget. +- **Studio key** (optional) — console → API › API keys. It adds the same + models on the API budget. + +The Vibe key alone is enough for the Vibe wallet. Neither key expires. JSON +(`{"vibe":"…","api":"…"}`) and two bare keys separated by `;` are accepted +too. + +The line holds both keys: treat it like two secrets, and don't paste it into +issues, screenshots or logs. + +## Models + +On 2026-11-08 the Vibe Code catalog held four models: + +| id | label | context | effort levels | vision | +|---|---|---|---|---| +| `mistral-large-4-0` | Mistral Large 4 | 1M | low / medium / high | yes | +| `mistral-medium-latest` | Mistral Medium 3.5 | 262k | none / high | yes | +| `zai-glm-5-3` | GLM 5.3 (Mistral Hosted) | 1M | low / high / max | no | +| `zai-glm-5-3-flash` | GLM 5.3 Flash (Mistral Hosted) | 1M | low / high / max | yes | + +DeepSeek is added because the Vibe key serves it (measured), though the CLI's +picker does not list it. With both keys every model is offered twice; the +alias picks the wallet: + +| Model id | Name | Spends | +|---|---|---| +| `deepseek-v4-1-flash-1017` | `deepseek-v4-1-flash-1017 (Vibe budget)` | the plan's Vibe budget first, then PAYG | +| `deepseek-v4-1-flash-1017--api` | `deepseek-v4-1-flash-1017 (API budget)` | the API budget / PAYG | + +The `--api` suffix is stripped before the request goes upstream; the fetch +picks the key from it. With one key only, only that wallet's ids are listed. + +Measured on 2026-11-08 on an Education-plan account (150 USD Vibe Code +budget, 15 USD API budget), same model through magpie's gateway: + +| Alias | Request | `vibe_budget` | Charged to the Vibe budget | +|---|---|---|---| +| `…--api` | 248 tokens, 200 | 0.000784 % → 0.000784 % | **$0.000000** | +| plain | 266 tokens, 200 | 0.000784 % → 0.000986 % | **$0.000303** | + +If the catalog can't be read, the account's `/v1/models` is used instead, +deduped and filtered: chats only (`capabilities.completion_chat`) and one id +per family (64 entries → 14 for the account above). Families come from +`billing_model_name` and the `aliases` links; the id kept is, in order, the +one billing names, the one whose display name is the id, else `-latest`. A +request naming an aliased id from an older config still works. + +### Show the models in magpie + +magpie shows only the models ticked for a provider, and a plugin's new ids +start unticked. Tick them once — the app's provider page, or: + +```sh +magpie provider models mistral-vibe \ + +mistral-large-4-0 +mistral-large-4-0--api \ + +mistral-medium-latest +mistral-medium-latest--api \ + +zai-glm-5-3 +zai-glm-5-3--api \ + +zai-glm-5-3-flash +zai-glm-5-3-flash--api \ + +deepseek-v4-1-flash-1017 +deepseek-v4-1-flash-1017--api +``` + +## Quota + +`auth.usage` reads the three public endpoints the official CLI reads: +`/v1/billing/budget` (both budgets, their reset), `/v1/billing/subscription` +(plan name) and `console.mistral.ai/api/vibe/whoami` (the key's scope). Both +windows are shown; the key's own budget comes first. A refused key (401) +marks the account expired; a 403 on the budget or a plan without one says so +instead of showing an empty card. + +```sh +magpie quota mistral-vibe +``` + +## Request and reply fixes + +Written from the behaviour measured against Mistral's API — the fields it +answers with 422, GLM's 4096-token cap without `max_tokens`, and its typed +content parts, in `src/request.mjs`, `src/reply.mjs`, `src/effort.mjs` and +`src/transport.mjs`. They are not a translation of another implementation. The +alias table in `src/constants.mjs` is plain facts about Mistral's ids (the same +ids mistral-gateway lists). + +- aliases are rewritten to the id Mistral serves; +- `max_completion_tokens` → `max_tokens`, `seed` → `random_seed`, `user` + dropped (Mistral answers those with 422); +- `max_tokens` is filled in for GLM only (131072): without it Mistral caps a + GLM completion at 4096 tokens, which its thinking alone can spend; +- `reasoning_content` and `index` are stripped from messages a client sends + back as they came; +- a GLM tool-result turn without an effort gets `reasoning_effort: "high"`, + which avoids GLM ending such a turn with no visible text; +- `stream_options: {include_usage: true}` is added to a stream that names none; +- a rejected `reasoning_effort` is retried once at the nearest level the model + lists (nested `reasoning.effort` on `/v1/responses`); +- a 429 is returned as it came: magpie rests the account and falls back on it, + and `Retry-After` says when to try again; +- GLM's typed content parts (`[{type:"thinking"…},{type:"text"…}]`) become + `content` (a string) and `reasoning_content`, streamed or buffered, and a + stream that delta'd tool calls but ends `stop` ends `tool_calls`; +- a rebuilt reply drops the upstream's `content-encoding` and `content-length`, + which no longer describe its bytes. + +One account holds both wallets, so a refusal that rests the account rests the +`--api` ids with it. + +## Privacy + +The keys stay in magpie's own store (`plugin-auth.json`, or OpenCode's +`auth.json`). The plugin sends no telemetry and reads nothing else; its only +network calls are the three quota reads above and the requests magpie makes +for a model. + +## Disclaimer + +This is an unofficial community project, not affiliated with, endorsed by or +supported by Mistral AI. It uses the credentials and endpoints the official +Mistral Vibe CLI uses; Mistral's terms still apply, and the plan's limits stay +in force. Mistral's consumer terms forbid sharing an account or its keys, and +creating accounts to get around limits — keep this to your own account. Terms +and endpoints can change at any time; Mistral decides how it enforces its own +terms. The software is provided "as is", without warranty of any kind, and the +authors accept no liability for any loss, including account suspension, +restricted keys or lost subscription fees. Check the current terms before +relying on it. + +## License + +MIT. See [LICENSE](LICENSE). + +## Test + +```sh +bun test # local stand-in for Mistral's endpoints; no network, no key +``` diff --git a/packages/mistral-vibe/README.zh-CN.md b/packages/mistral-vibe/README.zh-CN.md new file mode 100644 index 00000000..c73e69da --- /dev/null +++ b/packages/mistral-vibe/README.zh-CN.md @@ -0,0 +1,158 @@ +# Mistral Vibe + +简体中文 | [English](README.md) + +把 **Mistral Vibe** 的编程套餐接入 [magpie](https://usemagpie.ai) 与 +[OpenCode](https://opencode.ai) 的供应商插件。**非官方**:与 Mistral AI 无隶属、 +无背书、无支持关系。 + +一个供应商、一个模型组、两个钱包。Vibe key 登录后,模型来自 Vibe 套餐自己的 +目录并追加 DeepSeek;每个模型都出现在 Vibe 钱包里,若同时提供 Studio key,还会 +以 `--api` 的形式出现在 API 钱包里。套餐每月的 Vibe Code 与 API 额度会显示 +在 magpie 的用量卡片、`magpie quota`、额度提醒和 `GET /v1/magpie/quotas` 中。 + +**供应商 id:** `mistral-vibe` + +| | | +|---|---| +| API | `https://api.mistral.ai/v1`(`@ai-sdk/openai-compatible`) | +| 登录 | 一条凭据装两把 key:`vibe=…;api=…`(只填 Vibe key 也可以) | +| 模型 | Vibe Code 目录(2026-11-08 为 4 个)加上 DeepSeek | +| 额度 | `GET /v1/billing/budget` → `vibe_budget`、`api_budget`、`reset_at` | + +## 登录 + +```sh +magpie plugin login mistral-vibe +``` + +粘贴一行: + +``` +vibe=;api= +``` + +- **Vibe key**:官方 CLI 执行 `vibe --setup` 后写入 `~/.vibe/.env` 的那把, + 或 chat.mistral.ai/code/extensions → **Vibe CLI** 卡片 → **Advanced** → + **Vibe API Key**。它的请求先花套餐的 Vibe 额度。 +- **Studio key**(可选):console 的 **API › API keys**。它让同一批模型也出现在 + API 额度上。 + +只填 Vibe key 就能用 Vibe 钱包。两把 key 都不过期。也接受 JSON +(`{"vibe":"…","api":"…"}`)或两把裸 key 以 `;` 分隔。 + +这一行同时包含两把 key,等同于两个密码:不要贴到 issue、截图或日志里。 + +## 模型 + +2026-11-08 的 Vibe Code 目录有 4 个模型: + +| id | 名称 | 上下文 | 思考档位 | 视觉 | +|---|---|---|---|---| +| `mistral-large-4-0` | Mistral Large 4 | 1M | low / medium / high | 是 | +| `mistral-medium-latest` | Mistral Medium 3.5 | 262k | none / high | 是 | +| `zai-glm-5-3` | GLM 5.3 (Mistral Hosted) | 1M | low / high / max | 否 | +| `zai-glm-5-3-flash` | GLM 5.3 Flash (Mistral Hosted) | 1M | low / high / max | 是 | + +额外加入 DeepSeek:CLI 选择器里没有它,但实测 Vibe key 可以调用。两把 key 都在 +时,每个模型出现两次,靠别名区分钱包: + +| 模型 id | 显示名 | 花哪个 | +|---|---|---| +| `deepseek-v4-1-flash-1017` | `deepseek-v4-1-flash-1017 (Vibe budget)` | 先花套餐的 Vibe 额度,再按量付费 | +| `deepseek-v4-1-flash-1017--api` | `deepseek-v4-1-flash-1017 (API budget)` | API 额度 / 按量付费 | + +`--api` 后缀在请求发出前去掉,fetch 用它挑 key。只有一把 key 时,只列出该钱包的 id。 + +2026-11-08 在 Education 套餐账号上实测(Vibe Code 额度 150 USD、API 额度 +15 USD),同一个模型经 magpie 网关: + +| 别名 | 请求 | `vibe_budget` | 扣在 Vibe 额度上 | +|---|---|---|---| +| `…--api` | 248 tokens,200 | 0.000784 % → 0.000784 % | **$0.000000** | +| 裸 id | 266 tokens,200 | 0.000784 % → 0.000986 % | **$0.000303** | + +目录读不到时,退回账号的 `/v1/models` 并去重:只留对话模型 +(`capabilities.completion_chat`),同一底层模型只留一个 id(上面的账号是 +64 条 → 14 个)。归并依据 `billing_model_name` 与 `aliases` 互链;保留的 id +依次优先:billing 名、显示名与 id 相同的那个、`-latest`。老配置里用别名的请求 +仍然可用。 + +### 在 magpie 里显示模型 + +magpie 只显示已勾选的模型,插件新增的 id 默认未勾选。勾一次即可——在应用里的 +供应商页面,或: + +```sh +magpie provider models mistral-vibe \ + +mistral-large-4-0 +mistral-large-4-0--api \ + +mistral-medium-latest +mistral-medium-latest--api \ + +zai-glm-5-3 +zai-glm-5-3--api \ + +zai-glm-5-3-flash +zai-glm-5-3-flash--api \ + +deepseek-v4-1-flash-1017 +deepseek-v4-1-flash-1017--api +``` + +## 额度 + +`auth.usage` 读取官方 CLI 用的三个公开接口:`/v1/billing/budget`(两个额度和 +重置时间)、`/v1/billing/subscription`(套餐名)、 +`console.mistral.ai/api/vibe/whoami`(key 的作用域)。两个窗口都会显示,key +自己的那个排在前面。key 被拒(401)会标记账号过期;额度接口 403 或套餐没有该 +额度时会说明原因,而不是显示空卡片。 + +```sh +magpie quota mistral-vibe +``` + +## 请求与回复修正 + +按对 Mistral 接口的实测行为重写(它对哪些字段返回 422、GLM 不传 +`max_tokens` 时 4096 的输出上限、以及类型化 content),代码在 +`src/request.mjs`、`src/reply.mjs`、`src/effort.mjs`、`src/transport.mjs`, +不是对其它实现的翻译。`src/constants.mjs` 的别名表是 Mistral id 的客观事实 +(mistral-gateway 也列出同一批 id)。 + +- 别名改写为 Mistral 实际提供的 id; +- `max_completion_tokens` → `max_tokens`、`seed` → `random_seed`、丢弃 `user` + (Mistral 对这些返回 422); +- 只给 GLM 补 `max_tokens`(131072):不补时 Mistral 把 GLM 输出限制在 + 4096,思考就可能耗尽; +- 客户端原样回传的消息里去掉 `reasoning_content` 和 `index`; +- GLM 的 tool 结果轮次若未指定档位,补 `reasoning_effort: "high"`,避免它 + 无可见输出就结束; +- 流式请求缺少 `stream_options: {include_usage: true}` 时补上; +- 被拒的 `reasoning_effort` 会按模型支持的最接近档位重试一次(`/v1/responses` + 下是嵌套的 `reasoning.effort`); +- 429 原样返回:由 magpie 停用账号并切换下一个,`Retry-After` 说明何时重试; +- GLM 的类型化 content(`[{type:"thinking"…},{type:"text"…}]`)在流式与非流式 + 下都换算为字符串 `content` 和 `reasoning_content`;已产生 tool_calls 却以 + `stop` 结束的流改为 `tool_calls`; +- 重建的回复会去掉上游的 `content-encoding` 与 `content-length`,它们已不再 + 描述新的字节。 + +一个账号同时装两个钱包,所以一次拒绝对账号的停用也会连带 `--api` 那些 id。 + +## 隐私 + +key 只保存在 magpie 自己的存储(`plugin-auth.json`,或 OpenCode 的 +`auth.json`)里。插件不上报遥测、不读取其他文件;网络请求只有上面三个额度 +接口,以及 magpie 为模型发出的请求。 + +## 免责声明 + +这是非官方社区项目,与 Mistral AI 无隶属、无背书、无支持关系。它使用官方 +Mistral Vibe CLI 相同的凭据与接口;Mistral 的条款仍然适用,套餐额度限制照常 +生效。Mistral 的消费者条款禁止共享账号或 key、禁止通过多账号绕过限制——请只在 +自己的账号上使用。条款与接口可能随时变化,如何执行由 Mistral 决定。本软件按 +"现状"提供,不附带任何担保;作者不对任何损失负责,包括账号被限制、key 被停用 +或订阅费用损失。依赖前请自行核对最新条款。 + +## 许可证 + +MIT,见 [LICENSE](LICENSE)。 + +## 测试 + +```sh +bun test # 本地模拟 Mistral 接口;不联网、不需要 key +``` diff --git a/packages/mistral-vibe/index.mjs b/packages/mistral-vibe/index.mjs new file mode 100644 index 00000000..0fb38d6e --- /dev/null +++ b/packages/mistral-vibe/index.mjs @@ -0,0 +1,254 @@ +// Magpie provider plugin for a Mistral Vibe plan. +// +// The Vibe key signs in as a provider, the request and reply fixes live in the +// provider's fetch, and the plan's monthly Vibe Code allowance is read through +// magpie's usage hook — the card on the Usage page, `magpie quota`, the quota +// alert and GET /v1/magpie/quotas all show it. +// +// One provider, one model list. Its models are offered on both wallets: +// the bare id spends the Vibe budget, `--api` spends the API budget. Both +// keys live in the one sign-in (`vibe=…;api=…`), so the fetch can pick by the +// model id; magpie's account selection is not involved. + +import { CONSOLE_ROOT, NPM, PROVIDER_ID, V1 } from "./src/constants.mjs" +import { WALLET_SUFFIX } from "./src/request.mjs" +import { sendChat } from "./src/transport.mjs" +import { readQuota } from "./src/quota.mjs" + +// modelOf is a live /v1/models or catalog entry in the shape magpie's +// provider hook wants. +function modelOf(m, provider, efforts = []) { + const caps = m?.capabilities ?? {} + const context = Number(m?.max_context_length) || 0 + return { + id: m.id, + providerID: provider.id, + name: m.label || m.name || m.id, + api: { id: m.id, url: V1, npm: NPM }, + status: "active", + headers: {}, + options: {}, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context, output: 0 }, + capabilities: { + temperature: true, + reasoning: efforts.length > 0 || !!caps.reasoning, + attachment: !!caps.vision, + toolcall: caps.function_calling !== false, + input: { text: true, image: !!caps.vision, audio: false, video: false, pdf: false }, + output: { text: true, image: false, audio: false, video: false, pdf: false }, + interleaved: false, + }, + release_date: "", + // A model's reasoning levels, as the catalog names them. + variants: Object.fromEntries(efforts.map((level) => [level, { reasoningEffort: level }])), + } +} + +// parseKeys reads the one sign-in into its keys. Accepted forms: +// "mstr…" the Vibe key alone +// "vibe=mstr…;api=mst…" both, in one line +// {"vibe":"mstr…","api":"mst…"} the same as JSON +// "mstr…;mst…" both, bare, in that order +export function parseKeys(raw) { + const text = String(raw ?? "").trim() + if (!text) return {} + // A Mistral key is ~45 characters; anything this short is a placeholder + // (a failed read wrote "None" once) and must not become a credential. + const clean = (value) => { + const trimmed = typeof value === "string" ? value.trim() : "" + return trimmed.length >= 16 ? trimmed : undefined + } + const keys = {} + if (text.startsWith("{")) { + try { + const parsed = JSON.parse(text) + for (const name of ["vibe", "api"]) { + const value = clean(parsed?.[name]) + if (value) keys[name] = value + } + if (keys.vibe || keys.api) return keys + } catch {} + } + const parts = text.split(/[;\n]+/).map((part) => part.trim()).filter(Boolean) + for (const part of parts) { + const pair = /^(vibe|api)\s*=\s*(.+)$/i.exec(part) + if (pair) { + const value = clean(pair[2]) + if (value) keys[pair[1].toLowerCase()] = value + } + } + if (keys.vibe || keys.api) return keys + const bare = parts.filter((part) => !part.includes("=")).map(clean).filter(Boolean) + if (bare[0]) keys.vibe = bare[0] + if (bare[1]) keys.api = bare[1] + return keys +} + +// isChat keeps the chat models; /v1/models lists embeddings, OCR, TTS and +// transcription too. +function isChat(m) { + return !!m?.id && m.capabilities?.completion_chat === true +} + +function representative(ids, byId) { + const score = (id) => { + const m = byId.get(id) + return (id === m.billing_model_name ? 8 : 0) + (id === m.name ? 4 : 0) + (id.endsWith("-latest") ? 2 : 0) + } + return [...ids].sort((a, b) => score(b) - score(a) || a.length - b.length || a.localeCompare(b))[0] +} + +// listed dedupes /v1/models: chats only, one id per family (billing_model_name +// and the aliases links name the family). +export function listed(data) { + const chat = (Array.isArray(data) ? data : []).filter(isChat) + const byId = new Map(chat.map((m) => [m.id, m])) + const parent = new Map(chat.map((m) => [m.id, m.id])) + const find = (id) => { + let root = id + while (parent.get(root) !== root) root = parent.get(root) + let at = id + while (parent.get(at) !== at) { + const next = parent.get(at) + parent.set(at, root) + at = next + } + return root + } + const union = (a, b) => { + const [ra, rb] = [find(a), find(b)] + if (ra !== rb) parent.set(ra, rb) + } + for (const m of chat) { + for (const alias of Array.isArray(m.aliases) ? m.aliases : []) if (byId.has(alias)) union(m.id, alias) + if (m.billing_model_name && byId.has(m.billing_model_name)) union(m.id, m.billing_model_name) + } + const families = new Map() + for (const m of chat) { + const root = find(m.id) + if (!families.has(root)) families.set(root, []) + families.get(root).push(m.id) + } + return [...families.values()].map((ids) => byId.get(representative(ids, byId))) +} + +// DeepSeek answers but is not on the CLI's catalog; the Vibe key can spend its +// allowance on it (measured), so it belongs in the group. +const DEEPSEEK = /^deepseek/i + +// variantsOf is one model as each wallet's id: the bare id for the Vibe +// budget, `--api` for the API budget. +function variantsOf(entry, provider, keys) { + const efforts = Array.isArray(entry.parameters?.reasoning) ? entry.parameters.reasoning : [] + const base = modelOf(entry, provider, efforts) + const label = entry.label || entry.name || entry.id + const out = [] + if (keys.vibe) out.push({ ...base, name: `${label} (Vibe budget)` }) + if (keys.api) { + out.push({ + ...base, + id: `${entry.id}${WALLET_SUFFIX}`, + name: `${label} (API budget)`, + api: { ...base.api, id: `${entry.id}${WALLET_SUFFIX}` }, + }) + } + return out +} + +export const MistralVibeAuthPlugin = async () => { + return { + config: async (config) => { + config.provider ??= {} + config.provider[PROVIDER_ID] ??= {} + const provider = config.provider[PROVIDER_ID] + provider.name ??= "Mistral Vibe" + provider.npm ??= NPM + provider.api ??= V1 + // No static defaults: the list depends on which keys the sign-in holds, + // so only the hook below knows it. User-set model entries stay as they + // came. + }, + + provider: { + id: PROVIDER_ID, + // The Vibe plan's catalog plus DeepSeek, each as both wallets' ids (the + // ids the account's keys can serve). The account's deduped chat models + // if the catalog can't be read; the declared list before sign-in. + async models(provider, { auth }) { + const keys = parseKeys(auth?.key) + const token = keys.vibe ?? keys.api + if (!token) return provider.models ?? {} + const read = async (url) => { + try { + const response = await fetch(url, { + headers: { authorization: `Bearer ${token}`, accept: "application/json" }, + signal: AbortSignal.timeout(8_000), + }) + return response.ok ? await response.json() : null + } catch { + return null + } + } + const [catalog, account] = await Promise.all([ + read(`${V1}/model-catalog?surface=vibe-code`), + read(`${V1}/models`), + ]) + const accountModels = listed(account?.data) + const catalogModels = (Array.isArray(catalog?.models) ? catalog.models : []).filter((m) => m?.id) + const base = catalogModels.length ? [...catalogModels] : [...accountModels] + if (!base.some((m) => DEEPSEEK.test(m.id))) base.push(...accountModels.filter((m) => DEEPSEEK.test(m.id))) + if (!base.length) return provider.models ?? {} + const seen = new Set() + const models = [] + for (const entry of base) { + if (seen.has(entry.id)) continue + seen.add(entry.id) + models.push(...variantsOf(entry, provider, keys)) + } + return Object.fromEntries(models.map((m) => [m.id, m])) + }, + }, + + auth: { + provider: PROVIDER_ID, + methods: [ + { + // One sign-in holds both keys. The Vibe key is the one the official + // CLI writes to ~/.vibe/.env (or chat.mistral.ai/code/extensions → + // Vibe CLI → Advanced); the Studio key is from the console's API › + // API keys. Nothing is renewed: neither key expires. + type: "api", + label: "Mistral keys (Vibe, and API if you want both wallets)", + placeholder: "vibe=…;api=… (or the Vibe key alone)", + }, + ], + + async loader(getAuth) { + const auth = await getAuth() + const keys = parseKeys(auth?.key) + if (!keys.vibe && !keys.api) return {} + return { + baseURL: V1, + apiKey: "", + // The key, the rewrites and the GLM reply normalization all live in + // sendChat; apiKey is empty because it sets Authorization itself. + fetch: (input, init) => sendChat(input, init, keys), + } + }, + + // magpie's usage card: both budgets, with the key's own one first. + async usage(getAuth) { + const auth = await getAuth() + const keys = parseKeys(auth?.key) + const token = keys.vibe ?? keys.api + if (!token) return { windows: [], signIn: "kept", error: "not signed in" } + try { + return await readQuota(token) + } catch (error) { + return { windows: [], signIn: "kept", error: `Vibe quota unreadable: ${error?.message ?? error}` } + } + }, + }, + } +} diff --git a/packages/mistral-vibe/mistral-vibe.test.mjs b/packages/mistral-vibe/mistral-vibe.test.mjs new file mode 100644 index 00000000..d77af64f --- /dev/null +++ b/packages/mistral-vibe/mistral-vibe.test.mjs @@ -0,0 +1,288 @@ +// Runs against a local stand-in for Mistral's API: the same paths and the +// shapes measured against it. `bun test` — no network, no key. +import { afterAll, test } from "bun:test" +import assert from "node:assert/strict" +import http from "node:http" + +const seen = { bodies: [], keys: [] } +let effortRefused = false +let throttled = 0 + +// Real Mistral keys are ~45 characters; parseKeys ignores shorter values. +const VIBE_KEY = "vibe-key-0000000000000000000000000001" +const API_KEY = "api-key-00000000000000000000000000002" +const BAD_KEY = "bad-key-0000000000000000000000000000" + +// /v1/models: alias families and a non-chat entry, as Mistral serves them. +const API_MODELS = [ + { id: "mistral-medium-latest", name: "mistral-medium-latest", billing_model_name: "mistral-medium-3-5", max_context_length: 128000, aliases: ["mistral-medium-3-5", "mistral-medium"], capabilities: { completion_chat: true, function_calling: true } }, + { id: "mistral-medium-3-5", name: "mistral-medium-latest", billing_model_name: "mistral-medium-3-5", max_context_length: 128000, aliases: ["mistral-medium-latest", "mistral-medium"], capabilities: { completion_chat: true, function_calling: true } }, + { id: "mistral-medium", name: "mistral-medium-latest", billing_model_name: "mistral-medium-3-5", max_context_length: 128000, aliases: ["mistral-medium-latest", "mistral-medium-3-5"], capabilities: { completion_chat: true, function_calling: true } }, + { id: "mistral-embed", name: "mistral-embed", max_context_length: 8192, aliases: [], capabilities: { completion_chat: false } }, + { id: "zai-glm-5-3", name: "zai-glm-5-3", billing_model_name: "zai-glm-5-3", max_context_length: 200000, aliases: ["zai-glm-5", "zai-glm-latest"], capabilities: { completion_chat: true, function_calling: true, reasoning: true } }, + { id: "zai-glm-5", name: "zai-glm-5-3", billing_model_name: "zai-glm-5-3", max_context_length: 200000, aliases: ["zai-glm-5-3", "zai-glm-latest"], capabilities: { completion_chat: true, function_calling: true, reasoning: true } }, + { id: "zai-glm-latest", name: "zai-glm-5-3", billing_model_name: "zai-glm-5-3", max_context_length: 200000, aliases: ["zai-glm-5-3", "zai-glm-5"], capabilities: { completion_chat: true, function_calling: true, reasoning: true } }, + { id: "deepseek-v4-1-flash-1017", name: "deepseek-v4-1-flash-1017", billing_model_name: "deepseek-v4-1-flash-1017", max_context_length: 1048576, aliases: [], capabilities: { completion_chat: true, function_calling: true, reasoning: true, vision: true } }, +] + +// /v1/model-catalog?surface=vibe-code: the Vibe plan's own list. +const CATALOG = { + models: [ + { id: "mistral-large-4-0", label: "Mistral Large 4", aliases: ["mistral-large-4-0-3", "mistral-large-4"], max_context_length: 1048576, parameters: { temperature: 1, reasoning: ["low", "medium", "high"] }, capabilities: { completion_chat: true, function_calling: true, vision: true } }, + { id: "mistral-medium-latest", label: "Mistral Medium", aliases: ["mistral-medium", "mistral-medium-3.5", "mistral-vibe-cli-latest"], max_context_length: 262144, parameters: { reasoning: ["none", "high"] }, capabilities: { completion_chat: true, function_calling: true, vision: true } }, + { id: "zai-glm-5-3", label: "GLM 5.3", aliases: ["glm-5-3", "zai-glm-5", "zai-glm-latest"], max_context_length: 1048576, parameters: { reasoning: ["low", "high", "max"] }, capabilities: { completion_chat: true, function_calling: true } }, + { id: "zai-glm-5-3-flash", label: "GLM 5.3 Flash", aliases: [], max_context_length: 1048576, parameters: { reasoning: ["low", "high", "max"] }, capabilities: { completion_chat: true, function_calling: true, vision: true } }, + ], +} + +const server = http.createServer((req, res) => { + let raw = "" + req.on("data", (chunk) => (raw += chunk)) + req.on("end", () => { + const key = String(req.headers.authorization ?? "").replace(/^Bearer /, "") + seen.keys.push(key) + if (key.startsWith("bad")) { + res.writeHead(401, { "content-type": "application/json" }) + return res.end('{"message":"Invalid API Key"}') + } + const url = new URL(req.url, "http://127.0.0.1") + if (req.method === "GET" && url.pathname === "/v1/model-catalog") { + res.writeHead(200, { "content-type": "application/json" }) + return res.end(JSON.stringify(CATALOG)) + } + if (req.method === "GET" && url.pathname === "/v1/models") { + res.writeHead(200, { "content-type": "application/json" }) + return res.end(JSON.stringify({ data: API_MODELS })) + } + if (req.method === "GET" && url.pathname === "/v1/billing/budget") { + res.writeHead(200, { "content-type": "application/json" }) + return res.end( + JSON.stringify({ + vibe_budget: { usage_percentage: 42.5, initial_budget: 50, currency: "USD", reset_at: "2026-12-01T00:00:00Z", payg_enabled: false }, + api_budget: { usage_percentage: 10, initial_budget: 15, currency: "USD", reset_at: "2026-12-01T00:00:00Z", payg_enabled: false }, + }), + ) + } + if (req.method === "GET" && url.pathname === "/v1/billing/subscription") { + res.writeHead(200, { "content-type": "application/json" }) + return res.end(JSON.stringify({ plan_name: "Pro", vibe_payg_enabled: false })) + } + if (req.method === "GET" && url.pathname === "/api/vibe/whoami") { + res.writeHead(200, { "content-type": "application/json" }) + return res.end(JSON.stringify({ plan_type: key.startsWith("api-key") ? "API" : "CHAT", plan_name: "Pro" })) + } + if (req.method === "POST" && url.pathname === "/v1/chat/completions") { + const body = JSON.parse(raw) + seen.bodies.push(body) + if (body.model === "throttle-me") { + throttled += 1 + res.writeHead(429, { "content-type": "application/json", "retry-after": "0" }) + return res.end('{"message":"rate limited"}') + } + if (body.model === "tie-model" && body.reasoning_effort === "medium") { + res.writeHead(400, { "content-type": "application/json" }) + return res.end('{"message":"reasoning_effort \'medium\' is not supported for this model, supported: \'low\', \'high\'"}') + } + if (body.reasoning_effort === "xhigh" && !effortRefused) { + effortRefused = true + res.writeHead(400, { "content-type": "application/json" }) + return res.end('{"message":"reasoning_effort \'xhigh\' is not supported for this model, supported: \'low\', \'high\'"}') + } + if (body.stream) { + res.writeHead(200, { "content-type": "text/event-stream" }) + res.write('data: {"id":"x","model":"zai-glm-5-3","choices":[{"index":0,"delta":{"role":"assistant","content":[{"type":"thinking","thinking":[{"text":"pondering"}]}]}}]}\n\n') + res.write('data: {"id":"x","model":"zai-glm-5-3","choices":[{"index":0,"delta":{"content":[{"type":"text","text":[{"text":"Hello"}]}],"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"t","arguments":"{}"}}]}}]}\n\n') + res.write('data: {"id":"x","model":"zai-glm-5-3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\n') + return res.end("data: [DONE]\n\n") + } + res.writeHead(200, { "content-type": "application/json" }) + return res.end( + JSON.stringify({ + id: "x", + model: body.model, + choices: [ + { + index: 0, + finish_reason: "stop", + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: [{ text: "thinking hard" }] }, + { type: "text", text: [{ text: "plain answer" }] }, + ], + }, + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 2 }, + }), + ) + } + res.writeHead(404, { "content-type": "application/json" }) + res.end('{"message":"not found"}') + }) +}) + +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) +const root = `http://127.0.0.1:${server.address().port}` +process.env.MISTRAL_VIBE_API_ROOT = root +process.env.MISTRAL_VIBE_CONSOLE_ROOT = root + +const { MistralVibeAuthPlugin, parseKeys, listed } = await import("./index.mjs") +const plugin = await MistralVibeAuthPlugin() +const auth = async () => ({ type: "api", key: VIBE_KEY }) + +test("config declares the provider and no static model ids", async () => { + const config = {} + await plugin.config(config) + const p = config.provider["mistral-vibe"] + assert.equal(p.name, "Mistral Vibe") + assert.equal(p.api, `${root}/v1`) + assert.equal(p.npm, "@ai-sdk/openai-compatible") + assert.equal(p.models, undefined) + const kept = { provider: { "mistral-vibe": { name: "Mine", models: { x: { name: "X" } } } } } + await plugin.config(kept) + assert.equal(kept.provider["mistral-vibe"].name, "Mine") + assert.equal(kept.provider["mistral-vibe"].models.x.name, "X") +}) + +test("one Vibe key lists the catalog plus DeepSeek on the Vibe wallet", async () => { + const models = await plugin.provider.models({ id: "mistral-vibe", models: {} }, { auth: { type: "api", key: VIBE_KEY } }) + assert.deepEqual(Object.keys(models).sort(), ["deepseek-v4-1-flash-1017", "mistral-large-4-0", "mistral-medium-latest", "zai-glm-5-3", "zai-glm-5-3-flash"]) + assert.equal(models["mistral-large-4-0"].name, "Mistral Large 4 (Vibe budget)") + assert.equal(models["mistral-large-4-0"].limit.context, 1048576) + assert.deepEqual(Object.keys(models["mistral-large-4-0"].variants).sort(), ["high", "low", "medium"]) + assert.equal(models["zai-glm-5-3-flash"].capabilities.attachment, true) + assert.equal(models["deepseek-v4-1-flash-1017"].name, "deepseek-v4-1-flash-1017 (Vibe budget)") + assert.equal(models["deepseek-v4-1-flash-1017"].capabilities.reasoning, true) + const fallback = listed(API_MODELS) + assert.deepEqual(fallback.map((m) => m.id).sort(), ["deepseek-v4-1-flash-1017", "mistral-medium-3-5", "zai-glm-5-3"]) +}) + +test("both keys list every model twice, the alias naming the wallet", async () => { + assert.deepEqual(parseKeys(`vibe=${VIBE_KEY};api=${API_KEY}`), { vibe: VIBE_KEY, api: API_KEY }) + assert.deepEqual(parseKeys(`${VIBE_KEY};${API_KEY}`), { vibe: VIBE_KEY, api: API_KEY }) + assert.deepEqual(parseKeys(JSON.stringify({ vibe: VIBE_KEY, api: API_KEY })), { vibe: VIBE_KEY, api: API_KEY }) + assert.deepEqual(parseKeys(VIBE_KEY), { vibe: VIBE_KEY }) + assert.deepEqual(parseKeys(`vibe=None;api=${API_KEY}`), { api: API_KEY }) + + const models = await plugin.provider.models({ id: "mistral-vibe", models: {} }, { auth: { type: "api", key: `${VIBE_KEY};${API_KEY}` } }) + const ids = Object.keys(models).sort() + assert.equal(ids.length, 10) + assert.ok(ids.includes("deepseek-v4-1-flash-1017")) + assert.ok(ids.includes("deepseek-v4-1-flash-1017--api")) + assert.equal(models["deepseek-v4-1-flash-1017"].name, "deepseek-v4-1-flash-1017 (Vibe budget)") + assert.equal(models["deepseek-v4-1-flash-1017--api"].name, "deepseek-v4-1-flash-1017 (API budget)") + assert.equal(models["deepseek-v4-1-flash-1017--api"].api.id, "deepseek-v4-1-flash-1017--api") +}) + +test("the bare alias spends the Vibe key, the --api alias the API key", async () => { + const loader = await plugin.auth.loader(async () => ({ type: "api", key: `${VIBE_KEY};${API_KEY}` })) + assert.equal(loader.baseURL, `${root}/v1`) + + const vibeCall = await loader.fetch(`${root}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer stale" }, + body: JSON.stringify({ model: "glm-5.3", messages: [{ role: "assistant", content: "hi", reasoning_content: "old", index: 3 }] }), + }) + assert.equal(vibeCall.status, 200) + assert.equal(seen.keys.at(-1), VIBE_KEY) + const sent = seen.bodies.at(-1) + assert.equal(sent.model, "zai-glm-5-3") + assert.equal(sent.max_tokens, 131072) + assert.equal(sent.messages[0].reasoning_content, undefined) + assert.equal(sent.messages[0].index, undefined) + const body = await vibeCall.json() + assert.equal(body.choices[0].message.content, "plain answer") + assert.equal(body.choices[0].message.reasoning_content, "thinking hard") + + const apiCall = await loader.fetch(`${root}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-v4-1-flash-1017--api", messages: [{ role: "user", content: "hi" }] }), + }) + assert.equal(apiCall.status, 200) + assert.equal(seen.keys.at(-1), API_KEY) + assert.equal(seen.bodies.at(-1).model, "deepseek-v4-1-flash-1017") +}) + +test("a streamed GLM reply keeps its deltas and ends tool_calls", async () => { + const loader = await plugin.auth.loader(auth) + const response = await loader.fetch(`${root}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "zai-glm-5-3", stream: true, messages: [{ role: "tool", content: "r" }], tools: [{ type: "function", function: { name: "t", parameters: {} } }] }), + }) + const text = await response.text() + const events = text.split("\n\n").filter(Boolean).map((e) => e.replace(/^data: /, "")) + assert.equal(events.at(-1), "[DONE]") + const chunks = events.slice(0, -1).map((e) => JSON.parse(e)) + assert.equal(chunks[0].choices[0].delta.content, "") + assert.equal(chunks[0].choices[0].delta.reasoning_content, "pondering") + assert.equal(chunks[1].choices[0].delta.content, "Hello") + assert.equal(chunks[2].choices[0].finish_reason, "tool_calls") + assert.equal(seen.bodies.at(-1).stream_options.include_usage, true) + assert.equal(seen.bodies.at(-1).reasoning_effort, "high") +}) + +test("a refused reasoning_effort is retried once at a level the model lists", async () => { + const loader = await plugin.auth.loader(auth) + const response = await loader.fetch(`${root}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mistral-large-4-0", reasoning_effort: "xhigh", messages: [{ role: "user", content: "hi" }] }), + }) + assert.equal(response.status, 200) + assert.equal(seen.bodies.at(-1).reasoning_effort, "high") +}) + +test("a tie at medium leans towards more reasoning", async () => { + const loader = await plugin.auth.loader(auth) + const response = await loader.fetch(`${root}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "tie-model", reasoning_effort: "medium", messages: [{ role: "user", content: "hi" }] }), + }) + assert.equal(response.status, 200) + assert.equal(seen.bodies.at(-1).reasoning_effort, "high") +}) + +test("a 429 is returned as it came, once", async () => { + const loader = await plugin.auth.loader(auth) + const before = throttled + const response = await loader.fetch(`${root}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "throttle-me", messages: [{ role: "user", content: "hi" }] }), + }) + assert.equal(response.status, 429) + assert.equal(throttled - before, 1) + assert.equal(response.headers.get("retry-after"), "0") +}) + +test("usage shows the Vibe window first and the API one set aside", async () => { + const usage = await plugin.auth.usage(auth) + assert.equal(usage.plan, "Pro") + assert.equal(usage.windows.length, 2) + assert.equal(usage.windows[0].name, "Vibe Code budget") + assert.equal(usage.windows[0].used, 42.5) + assert.equal(usage.windows[0].display, "21.25 / 50.00 USD") + assert.equal(usage.windows[0].resetsAt, "2026-12-01T00:00:00.000Z") + assert.equal(usage.windows[0].aside, undefined) + assert.equal(usage.windows[1].name, "API budget") + assert.equal(usage.windows[1].aside, true) + assert.equal(usage.signIn, "kept") + + const apiUsage = await plugin.auth.usage(async () => ({ type: "api", key: API_KEY })) + assert.equal(apiUsage.windows[0].aside, true) + assert.equal(apiUsage.windows[1].name, "API budget") + assert.equal(apiUsage.windows[1].aside, undefined) +}) + +test("a refused key marks the account expired", async () => { + const usage = await plugin.auth.usage(async () => ({ type: "api", key: BAD_KEY })) + assert.equal(usage.signIn, "expired") + assert.equal(usage.windows.length, 0) + assert.match(usage.error, /refused this Vibe key/) +}) + +afterAll(() => server.close()) diff --git a/packages/mistral-vibe/opencode.mjs b/packages/mistral-vibe/opencode.mjs new file mode 100644 index 00000000..9bc2a52e --- /dev/null +++ b/packages/mistral-vibe/opencode.mjs @@ -0,0 +1,5 @@ +// The package's OpenCode 1.x plugins, as OpenCode 1.x and magpie's host load +// them: its plugin function and nothing else (1.x refuses a package whose +// main exports include anything but plugins). OpenCode 2.x loads +// opencode2.mjs, which runs this. +export { MistralVibeAuthPlugin } from "./index.mjs" diff --git a/packages/mistral-vibe/opencode2.mjs b/packages/mistral-vibe/opencode2.mjs new file mode 100644 index 00000000..364ea8ae --- /dev/null +++ b/packages/mistral-vibe/opencode2.mjs @@ -0,0 +1,596 @@ +// The OpenCode 2.x face of a package whose plugins are written for +// OpenCode 1.x (and magpie's plugin host). +// +// OpenCode 2.x loads a package's `exports["."]` and wants its default to be +// {id, setup(ctx)}; it has no fallback for a 1.x plugin's named functions +// (plugins#657). This file is that default. It runs each 1.x plugin the +// package's opencode.mjs exports and hands 2.x what the plugin's hooks say: +// +// auth.methods → the integration's sign-in methods +// config() → the provider, its package, base URL and models +// provider.models → the signed-in account's own model list +// auth.loader → each request's base URL, key, headers and fetch +// +// What 2.x has no place for is left out: chat.headers and chat.params +// (2.x's sdk hook doesn't carry the session), magpie's usage and checkin, +// tools and events. +// +// scripts/opencode2.mjs is the one copy that is edited. Every package that +// has it carries the same bytes as packages//opencode2.mjs, and +// scripts/check.mjs fails when a copy differs. + +import { readFileSync } from "node:fs" +import * as v1 from "./opencode.mjs" + +const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) + +// The key the SDK is made with: the account's own goes on each request in +// its place (see wrapFetch), so a key read when the SDK was made can't go +// out after the account changed. +const PLACEHOLDER = "opencode2-v1-key" + +// The SDK package a 1.x npm name runs as on 2.x. A version keeps 2.x from +// swapping @ai-sdk/* for its native providers, which never call the sdk +// hook below; openai-compatible's is the one 2.x ships, the others are AI +// SDK v6's dist-tag, the line 2.x's bridge runs. +export function packageOf(npm) { + const name = npm || "@ai-sdk/openai-compatible" + if (name === "@ai-sdk/openai-compatible") return "aisdk:@ai-sdk/openai-compatible@2.0.41" + if (/^@ai-sdk\/[^/@]+$/.test(name)) return `aisdk:${name}@ai-v6` + return `aisdk:${name}` +} + +// The plugins a 1.x module exports, as magpie's host and OpenCode 1.x read +// them: a default {server} is the one plugin, else every function export +// and every {server} object. +export function pluginsOf(mod) { + const d = mod?.default + if (d && typeof d === "object" && ("server" in d || "id" in d || "tui" in d)) + return typeof d.server === "function" ? [d.server] : [] + const out = [] + for (const value of Object.values(mod ?? {})) { + const fn = typeof value === "function" ? value : value && typeof value === "object" && typeof value.server === "function" ? value.server : null + if (fn && !out.includes(fn)) out.push(fn) + } + return out +} + +const int = (n, fallback = 0) => { + const v = Math.trunc(Number(n)) + return Number.isFinite(v) && v >= 0 ? v : fallback +} +const money = (n) => (Number.isFinite(Number(n)) && Number(n) >= 0 ? Number(n) : 0) +const record = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {}) +const json = (v) => { + try { + return JSON.parse(JSON.stringify(v ?? null)) + } catch { + return null + } +} +const strings = (v) => Object.fromEntries(Object.entries(record(v)).filter(([, s]) => typeof s === "string")) +const nonEmpty = (o) => (Object.keys(o).length ? o : undefined) + +// connKey names a connection the way 2.x does. +const connKey = (c) => (!c ? "" : c.type === "credential" ? `credential:${c.id}` : `env:${c.name}`) + +// ---- sign-in ------------------------------------------------------------ + +// fields is a 1.x method's prompts as a 2.x form. +export function fieldsOf(prompts) { + const out = [] + for (const p of prompts ?? []) { + if (!p?.key) continue + const f = { key: String(p.key), type: "string", title: String(p.message ?? p.key) } + if (p.placeholder) f.placeholder = String(p.placeholder) + if (p.type === "select" && Array.isArray(p.options)) + f.options = p.options.map((o) => ({ value: String(o.value), label: String(o.label ?? o.value), ...(o.hint ? { description: String(o.hint) } : {}) })) + // a prompt shown only when another answer says so can't be required, and + // neither can one whose own validate() accepts an empty answer (the + // browser sign-in's optional API key) + if (!p.condition && p.required !== false) f.required = true + out.push(f) + } + return out.length ? out : undefined +} + +// v1Saved is what a 1.x sign-in's success is saved as, the way 1.x writes +// auth.json: the result without its type and provider. +function v1Saved(r) { + const { type: _t, provider: _p, ...rest } = r + if ("key" in r) return { type: "api", ...rest } + return { type: "oauth", ...rest } +} + +// credentialOf is a 1.x sign-in's success as a 2.x credential: OAuth for +// both kinds, the 1.x auth kept whole in its metadata. No refresh is +// registered, so 2.x hands the credential over as it is and the plugin's +// own loader renews it, as it does on 1.x. +export function credentialOf(methodID, r) { + if (!r || r.type !== "success") throw new Error(r?.error || r?.message || "the sign-in failed") + const saved = json(v1Saved(r)) + if (saved.type === "api") + return { type: "oauth", methodID, access: String(saved.key ?? ""), refresh: "", expires: 0, metadata: { v1: saved } } + return { + type: "oauth", + methodID, + access: String(saved.access ?? ""), + refresh: String(saved.refresh ?? ""), + expires: int(saved.expires), + metadata: { v1: saved }, + } +} + +// authOfValue is the 1.x auth a 2.x credential stands for. +export function authOfValue(value) { + if (!value) return null + if (value.type === "oauth") { + const v1 = record(value.metadata).v1 + if (v1 && typeof v1 === "object") return v1 + return { type: "oauth", access: value.access, refresh: value.refresh, expires: value.expires } + } + if (value.type === "key") { + const meta = value.metadata && Object.keys(value.metadata).length ? { metadata: value.metadata } : {} + return { type: "api", key: value.key, ...meta } + } + return null +} + +const fingerprint = (a) => JSON.stringify([a?.type, a?.access ?? a?.key ?? "", a?.refresh ?? ""]) + +// ---- models ------------------------------------------------------------- + +const MODALITIES = ["text", "audio", "image", "video", "pdf"] + +// internalOf is a config model as 1.x hands it to provider.models: what +// fromModelsDevModel makes of it, with the config's own variants. +export function internalOf(providerID, conf, key, m) { + const mods = record(m.modalities) + const has = (list, k) => Array.isArray(list) && list.includes(k) + const cost = record(m.cost) + return { + ...m, + id: key, + providerID, + name: m.name ?? key, + api: { id: m.id ?? key, url: m.provider?.api ?? conf.api ?? conf.options?.baseURL ?? "", npm: m.provider?.npm ?? conf.npm ?? "@ai-sdk/openai-compatible" }, + status: m.status ?? "active", + headers: m.headers ?? {}, + options: m.options ?? {}, + cost: { input: money(cost.input), output: money(cost.output), cache: { read: money(cost.cache_read), write: money(cost.cache_write) } }, + limit: { context: int(m.limit?.context), input: m.limit?.input, output: int(m.limit?.output) }, + capabilities: { + temperature: m.temperature ?? false, + reasoning: m.reasoning ?? false, + attachment: m.attachment ?? false, + toolcall: m.tool_call ?? true, + input: Object.fromEntries(MODALITIES.map((k) => [k, mods.input ? has(mods.input, k) : k === "text"])), + output: Object.fromEntries(MODALITIES.map((k) => [k, mods.output ? has(mods.output, k) : k === "text"])), + interleaved: m.interleaved ?? false, + }, + release_date: m.release_date ?? "", + variants: record(m.variants), + } +} + +// modelOf is a 1.x model, in config or internal form, as a 2.x Model.Info. +export function modelOf(providerID, provider, key, m) { + const caps = record(m.capabilities) + const listFrom = (obj, list) => { + if (obj && typeof obj === "object") return MODALITIES.filter((k) => obj[k]) + if (Array.isArray(list)) return list.map(String) + return ["text"] + } + const input = listFrom(caps.input, m.modalities?.input) + const output = listFrom(caps.output, m.modalities?.output) + const c = record(m.cost) + const cache = record(c.cache) + const npm = m.api?.npm ?? m.provider?.npm + const url = m.api?.url ?? m.provider?.api + const settings = { ...record(json(m.options)) } + if (url && url !== provider.baseURL) settings.baseURL = url + const variants = Object.entries(record(m.variants)) + .filter(([, v]) => !record(v).disabled) + .map(([id, v]) => { + const { disabled: _d, ...rest } = record(json(v)) + return nonEmpty(rest) ? { id, settings: rest } : { id } + }) + const status = ["alpha", "beta", "deprecated", "active"].includes(m.status) ? m.status : "active" + const out = { + id: key, + modelID: String(m.api?.id ?? m.id ?? key), + providerID, + name: String(m.name ?? key), + capabilities: { + tools: caps.toolcall ?? m.tool_call ?? true, + input: input.length ? input : ["text"], + output: output.length ? output : ["text"], + ...(typeof (caps.temperature ?? m.temperature) === "boolean" ? { temperature: caps.temperature ?? m.temperature } : {}), + }, + variants, + time: { released: Date.parse(m.release_date ?? "") || 0 }, + cost: [{ input: money(c.input), output: money(c.output), cache: { read: money(cache.read ?? c.cache_read), write: money(cache.write ?? c.cache_write) } }], + status, + enabled: true, + limit: { context: int(m.limit?.context, 128000) || 128000, output: int(m.limit?.output, 8192) || 8192 }, + } + if (m.limit?.input) out.limit.input = int(m.limit.input) + if (npm && npm !== provider.npm) out.package = packageOf(npm) + if (nonEmpty(settings)) out.settings = settings + const headers = strings(m.headers) + if (nonEmpty(headers)) out.headers = headers + return out +} + +// ---- requests ----------------------------------------------------------- + +const urlOf = (input) => (typeof input === "string" ? input : input instanceof URL ? input.href : input?.url ?? String(input)) + +// wrapFetch sends a request the SDK made from the provider's settings the +// way 1.x would have sent it with the loader's options: the loader's base +// URL in place of the configured one, the account's key in place of the +// placeholder, the loader's headers added, through the loader's fetch. +export function wrapFetch(next, base, options) { + return async (input, init = {}) => { + const o = await options() + let url = urlOf(input) + const from = String(base ?? "").replace(/\/+$/, "") + const to = o.baseURL ? String(o.baseURL).replace(/\/+$/, "") : "" + if (from && to && from !== to && url.startsWith(from)) url = to + url.slice(from.length) + const req = input instanceof Request ? input : null + const headers = new Headers(init.headers ?? req?.headers ?? undefined) + for (const [k, v] of [...headers.entries()]) { + if (!v.includes(PLACEHOLDER)) continue + if (o.key) headers.set(k, v.replaceAll(PLACEHOLDER, o.key)) + else headers.delete(k) + } + for (const [k, v] of Object.entries(strings(o.headers))) headers.set(k, v) + const send = typeof o.fetch === "function" ? o.fetch : next ?? fetch + if (req && init.body === undefined) { + const body = req.method === "GET" || req.method === "HEAD" ? undefined : await req.text() + return send(url, { method: req.method, headers, body, signal: init.signal ?? req.signal }) + } + return send(url, { ...init, headers }) + } +} + +// ---- one plugin --------------------------------------------------------- + +async function setupPlugin(ctx, fn, live) { + const directory = ctx?.location?.directory ?? process.cwd() + // the active account each provider was last read for, and the renewed + // auth a plugin saved for it (2.x has no way for a plugin to write a + // credential back, so it is kept in the plugin's storage, under the + // credential it renews and the token it was renewed from) + const current = new Map() + const saved = new Map() + const signingIn = new Map() + const storeKey = (id, key) => `v1auth/${id}/${key}` + + const client = { + auth: { + async set(arg) { + const id = arg?.path?.id + const body = json(arg?.body) + const cur = id && current.get(id) + // a sign-in's own saves belong to the credential it is about to + // make, not the one that was active when it started + if (!cur || !body || signingIn.get(id) > 0) return { data: true } + const entry = { fp: cur.fp, auth: body } + saved.set(storeKey(id, cur.key), entry) + await Promise.resolve() + .then(() => ctx.storage?.set?.(storeKey(id, cur.key), entry)) + .catch(() => {}) + return { data: true } + }, + async get() { + return { data: undefined } + }, + }, + app: { log: async () => ({ data: true }) }, + } + + const hooks = (await fn({ client, directory, worktree: directory, project: {}, serverUrl: undefined, $: undefined }, {})) ?? {} + const cfg = { provider: {} } + try { + await hooks.config?.(cfg) + } catch {} + const auth = hooks.auth + const authID = auth?.provider ? String(auth.provider) : undefined + const providers = record(cfg.provider) + + // getAuth reads the active account: the credential 2.x holds, or what the + // plugin last saved renewing it. + const getAuth = async (id) => { + const connection = await ctx.integration.connection.active(id).catch(() => undefined) + if (!connection) { + current.delete(id) + return undefined + } + const value = await ctx.integration.connection.resolve(connection).catch(() => undefined) + const base = authOfValue(value) + if (!base) { + current.delete(id) + return undefined + } + const key = connKey(connection) + const fp = fingerprint(base) + // ours: a credential one of this plugin's own sign-ins made + const ours = value?.type === "oauth" && !!record(value.metadata).v1 + current.set(id, { key, fp, ours }) + const sk = storeKey(id, key) + let entry = saved.get(sk) + if (!entry) { + entry = await Promise.resolve() + .then(() => ctx.storage?.get?.(sk)) + .catch(() => undefined) + if (entry) saved.set(sk, entry) + } + return entry && entry.fp === fp && entry.auth ? entry.auth : base + } + + // the sign-in methods + if (authID && Array.isArray(auth.methods) && auth.methods.length) { + const label = (c) => { + const a = authOfValue(c) + const name = a?.name ?? a?.email ?? a?.metadata?.name ?? a?.metadata?.email ?? a?.account + return typeof name === "string" && name ? name : undefined + } + const begin = () => signingIn.set(authID, (signingIn.get(authID) ?? 0) + 1) + const end = () => signingIn.set(authID, Math.max(0, (signingIn.get(authID) ?? 1) - 1)) + const title = record(providers[authID]).name + // 2.x runs a transform again on every reload, from nothing + await ctx.integration.transform((editor) => { + let keyed = false + // the integration 2.x makes for an id it doesn't know is named by the + // id; the provider's own name reads better in the sign-in list + if (typeof title === "string" && title) + try { + editor.update?.(authID, (i) => { + if (i && (!i.name || i.name === authID)) i.name = title + }) + } catch {} + auth.methods.forEach((m, i) => { + if (!m) return + const methodID = `v1-${i}` + const form = fieldsOf(m.prompts) + if (m.type === "api" && typeof m.authorize !== "function") { + if (keyed) return + keyed = true + editor.method.update({ integrationID: authID, method: { type: "key", label: String(m.label ?? "API key"), ...(form ? { form } : {}) } }) + return + } + if (typeof m.authorize !== "function") return + editor.method.update({ + integrationID: authID, + method: { id: methodID, type: "oauth", label: String(m.label ?? authID), ...(form ? { form } : {}) }, + async authorize(answer) { + begin() + let ended = false + const done = () => { + if (!ended) end() + ended = true + } + try { + const inputs = Object.fromEntries(Object.entries(record(answer)).map(([k, v]) => [k, String(v)])) + const r = await m.authorize(inputs) + if (m.type === "api") { + done() + return { url: "", instructions: "", mode: "auto", callback: Promise.resolve().then(() => credentialOf(methodID, r)) } + } + if (r?.method === "code") + return { + url: String(r.url ?? ""), + instructions: String(r.instructions ?? ""), + mode: "code", + callback: async (code) => { + try { + return credentialOf(methodID, await r.callback(code)) + } finally { + done() + } + }, + } + const callback = Promise.resolve() + .then(() => r.callback()) + .then((res) => credentialOf(methodID, res)) + .finally(done) + callback.catch(() => {}) + return { url: String(r?.url ?? ""), instructions: String(r?.instructions ?? ""), mode: "auto", callback } + } catch (e) { + done() + throw e + } + }, + label, + }) + }) + }) + } + + // the loader's options for the account signed in now, read again when + // the account changes + let loaded = null + const loaderOptions = async (id) => { + if (id !== authID || typeof auth?.loader !== "function") { + const a = authID === id ? await getAuth(id) : undefined + return { key: a?.type === "api" ? a.key : undefined } + } + const a = await getAuth(id) + if (!a) return {} + const fp = current.get(id)?.key + "\0" + fingerprint(a) + if (loaded?.fp !== fp) { + const p = Promise.resolve() + .then(() => auth.loader(() => getAuth(id), providerOf(id))) + .then((o) => record(o)) + loaded = { fp, p } + p.catch(() => { + if (loaded?.fp === fp) loaded = null + }) + } + const o = await loaded.p + const key = typeof o.apiKey === "string" && o.apiKey ? o.apiKey : a.type === "api" ? a.key : undefined + return { baseURL: o.baseURL, key, headers: o.headers, fetch: o.fetch } + } + + // the providers, with the list the account's own read last gave each + const source = new Map() + // providerOf is a provider as 1.x hands it to provider.models and + // auth.loader + const providerOf = (id) => { + const conf = record(providers[id]) + const models = Object.fromEntries(Object.entries(record(conf.models)).map(([k, m]) => [k, internalOf(id, conf, k, record(m))])) + return { id, name: conf.name ?? id, source: "config", env: [], options: record(conf.options), models } + } + const listed = new Set() + for (const [id, conf0] of Object.entries(providers)) { + const conf = record(conf0) + // a provider entry that only names the sign-in (no package, no models) + // is not a provider of models + if (!conf.npm && !Object.keys(record(conf.models)).length) continue + const options = record(conf.options) + const { apiKey: _k, baseURL: _b, fetch: _f, headers: optHeaders, ...rest } = options + // with no base URL the config leaves it to the loader: a name that + // can't resolve stands in for it, and each request swaps in the + // loader's (wrapFetch) + const baseURL = options.baseURL ?? conf.api ?? `https://${id.replace(/[^a-z0-9-]/gi, "-")}.opencode2-v1.invalid` + const npm = conf.npm ?? "@ai-sdk/openai-compatible" + const info = { + id, + ...(id === authID ? { integrationID: id } : {}), + name: String(conf.name ?? id), + activation: "auto", + package: packageOf(npm), + settings: { ...record(json(rest)), ...(baseURL ? { baseURL: String(baseURL) } : {}) }, + } + const headers = { ...strings(conf.headers), ...strings(optHeaders) } + if (nonEmpty(headers)) info.headers = headers + const prov = { baseURL, npm } + const fromConfig = () => + Object.entries(record(conf.models)).map(([key, m]) => modelOf(id, prov, key, internalOf(id, conf, key, record(m)))) + listed.add(id) + await ctx.provider.transform((editor) => { + const s = source.get(id) + editor.add({ info, models: s?.models ?? fromConfig(), sourceConnection: s?.connection }) + }) + source.set(id, { conf, prov, models: null, connection: undefined }) + } + + // each request: the placeholder key, and the fetch that swaps in the + // account's. A provider the package doesn't list (one 2.x has built in + // that the plugin only signs in to) is left to 2.x unless the plugin's + // loader serves it and one of the plugin's own sign-ins is active. + const own = authID && !listed.has(authID) && typeof auth?.loader === "function" ? [authID] : [] + for (const id of new Set([...listed, ...own])) { + const base = source.get(id)?.prov.baseURL + await ctx.aisdk.hook( + "sdk", + async (evt) => { + const opts = evt.options + if (!opts || opts.__opencode2) return + if (!listed.has(id)) { + await getAuth(id) + if (!current.get(id)?.ours) return + } + opts.__opencode2 = true + opts.apiKey = PLACEHOLDER + opts.fetch = wrapFetch(opts.fetch, opts.baseURL ?? base, () => loaderOptions(id)) + }, + { providerID: id }, + ) + } + + // refresh reads the account's own list after a sign-in or a switch + const listID = hooks.provider?.id ? String(hooks.provider.id) : undefined + let refreshing = null + let again = false + // the account the list was last read for: a credential.updated that + // leaves it as it was reads nothing again + let readFor = null + const stateOf = async () => { + const c = await ctx.integration.connection.active(authID).catch(() => undefined) + const a = c ? await getAuth(authID) : undefined + return connKey(c) + "\0" + (a ? fingerprint(a) : "") + } + const refresh = () => { + if (refreshing) { + again = true + return refreshing + } + refreshing = (async () => { + try { + if (!authID || !source.has(authID)) return + const s = source.get(authID) + const connection = await ctx.integration.connection.active(authID).catch(() => undefined) + const a = connection ? await getAuth(authID) : undefined + readFor = connKey(connection) + "\0" + (a ? fingerprint(a) : "") + if (!a || listID !== authID || typeof hooks.provider?.models !== "function") { + if (s.connection || s.models) { + s.connection = undefined + s.models = null + await ctx.provider.reload() + } + return + } + const provider = providerOf(authID) + let got + try { + got = await hooks.provider.models(provider, { auth: a }) + } catch { + got = null + } + if (connKey(await ctx.integration.connection.active(authID).catch(() => undefined)) !== connKey(connection)) { + again = true + return + } + s.connection = connection + s.models = got && typeof got === "object" ? Object.entries(got).map(([k, m]) => modelOf(authID, s.prov, k, record(m))) : null + await ctx.provider.reload() + await ctx.model?.reload?.() + } finally { + refreshing = null + if (again && live.on) { + again = false + void refresh() + } + } + })() + return refreshing + } + + if (authID && ctx.event?.subscribe) + void (async () => { + try { + // a switch names the integration; an update (a sign-in, a removal) + // names nothing, so it reads again only when this account changed + const types = ["credential.switched", "credential.updated"] + for await (const event of ctx.event.subscribe({ types })) { + if (!live.on) return + if (event?.type === "credential.switched" && event?.data?.integrationID === authID) { + loaded = null + await refresh() + } else if (event?.type === "credential.updated" && (await stateOf()) !== readFor) { + await refresh() + } + } + } catch {} + })() + void refresh() +} + +export async function setup(ctx, mod = v1) { + const live = { on: true } + for (const fn of pluginsOf(mod)) { + try { + await setupPlugin(ctx, fn, live) + } catch (e) { + // one plugin failing to start leaves the package's others working + console.error(`${pkg.name}: ${e?.message ?? e}`) + } + } + return () => { + live.on = false + } +} + +export default { id: pkg.name, setup: (ctx) => setup(ctx) } diff --git a/packages/mistral-vibe/package.json b/packages/mistral-vibe/package.json new file mode 100644 index 00000000..a86183c2 --- /dev/null +++ b/packages/mistral-vibe/package.json @@ -0,0 +1,34 @@ +{ + "name": "@magpie-community/opencode-mistral-vibe-auth", + "version": "0.1.0", + "description": "Mistral Vibe for Magpie and OpenCode: the Vibe plan's model catalog plus DeepSeek, each model on both wallets (Vibe budget, and API budget as --api), with the plan's budgets as quota windows", + "type": "module", + "main": "./opencode.mjs", + "exports": { ".": "./opencode2.mjs" }, + "files": [ + "index.mjs", + "opencode.mjs", + "opencode2.mjs", + "src", + "README.md", + "README.zh-CN.md", + "LICENSE" + ], + "keywords": [ + "opencode", + "opencode-plugin", + "magpie", + "mistral", + "mistral-vibe", + "quota" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/magpie-community/plugins", + "directory": "packages/mistral-vibe" + }, + "scripts": { + "test": "bun test" + } +} diff --git a/packages/mistral-vibe/src/constants.mjs b/packages/mistral-vibe/src/constants.mjs new file mode 100644 index 00000000..b9bc8962 --- /dev/null +++ b/packages/mistral-vibe/src/constants.mjs @@ -0,0 +1,33 @@ +// Mistral Vibe's public endpoints. The API root is overridable so the test +// suite can point the same code at a local server; nothing else uses it. +export const API_ROOT = (process.env.MISTRAL_VIBE_API_ROOT || "https://api.mistral.ai").replace(/\/+$/, "") +export const CONSOLE_ROOT = (process.env.MISTRAL_VIBE_CONSOLE_ROOT || "https://console.mistral.ai").replace(/\/+$/, "") +export const V1 = `${API_ROOT}/v1` + +export const PROVIDER_ID = "mistral-vibe" +export const NPM = "@ai-sdk/openai-compatible" + +// The Vibe plan grants its own monthly allowance ("Vibe Code budget"), and the +// key it provisions is a plain Mistral API key: every request through it is +// billed to that allowance first. Same key the official CLI writes to +// ~/.vibe/.env; https://chat.mistral.ai/code/extensions shows and rotates it. +export const KEY_HINT = "Vibe API Key (chat.mistral.ai/code/extensions → Vibe CLI → Advanced)" + +// Friendly names -> the id Mistral serves them as. Plain facts about the +// ids; the same table mistral-gateway lists. +export const MODEL_ALIASES = { + "mistral-large": "mistral-large-4", + "mistral-large-latest": "mistral-large-4", + "deepseek-v4.1-flash": "deepseek-v4-1-flash-1017", + "deepseek-v4.1-flash-1017": "deepseek-v4-1-flash-1017", + "deepseek-v4-1-flash": "deepseek-v4-1-flash-1017", + "deepseek-v4-flash-1017": "deepseek-v4-1-flash-1017", + "zai-glm-5.3": "zai-glm-5-3", + "zai-glm-5.3-flash": "zai-glm-5-3-flash", + "glm-5.3": "zai-glm-5-3", + "glm-5.3-flash": "zai-glm-5-3-flash", + "zai-glm-5.2": "zai-glm-5-2", + "glm-5.2": "zai-glm-5-2", +} + +export const REASONING_EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] diff --git a/packages/mistral-vibe/src/effort.mjs b/packages/mistral-vibe/src/effort.mjs new file mode 100644 index 00000000..9a50afc8 --- /dev/null +++ b/packages/mistral-vibe/src/effort.mjs @@ -0,0 +1,58 @@ +// A model that refuses a reasoning effort names the levels it takes. This +// reads that refusal and moves the request to the nearest one; a model with no +// such switch gets the field removed. + +import { REASONING_EFFORT_LEVELS } from "./constants.mjs" + +const MIDDLE = REASONING_EFFORT_LEVELS.indexOf("medium") +const isLevel = (name) => REASONING_EFFORT_LEVELS.includes(name) + +// levelsNamed reads the quoted level names in a refusal's list. +function levelsNamed(tail) { + const names = [] + for (const match of tail.matchAll(/['"]([a-z]+)['"]/g)) if (isLevel(match[1]) && !names.includes(match[1])) names.push(match[1]) + return names +} + +// adjustEffort answers a refusal about the reasoning effort: the body to send +// again, or null when the refusal is about something else (or already fits). +export function adjustEffort(text, refusal) { + const message = String(refusal ?? "") + if (!/reasoning_effort/i.test(message)) return null + + let body + try { + body = JSON.parse(text) + } catch { + return null + } + if (!body || typeof body !== "object" || Array.isArray(body)) return null + + // /v1/responses nests the level as reasoning.effort. + const nested = body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning) + const target = nested ? body.reasoning : body + const field = nested ? "effort" : "reasoning_effort" + const asked = target[field] + + if (/not enabled/i.test(message)) { + if (asked == null) return null + delete target[field] + return JSON.stringify(body) + } + + const listed = message.search(/not supported/i) + if (listed < 0) return null + const allowed = levelsNamed(message.slice(listed)) + if (!isLevel(asked) || allowed.length === 0 || allowed.includes(asked)) return null + + const rank = (name) => REASONING_EFFORT_LEVELS.indexOf(name) + const aimLow = rank(asked) < MIDDLE + const nearest = [...allowed].sort((a, b) => { + const byDistance = Math.abs(rank(a) - rank(asked)) - Math.abs(rank(b) - rank(asked)) + if (byDistance !== 0) return byDistance + // A tie leans towards less reasoning below medium, more at and above it. + return aimLow ? rank(a) - rank(b) : rank(b) - rank(a) + })[0] + target[field] = nearest + return JSON.stringify(body) +} diff --git a/packages/mistral-vibe/src/quota.mjs b/packages/mistral-vibe/src/quota.mjs new file mode 100644 index 00000000..d5bee27e --- /dev/null +++ b/packages/mistral-vibe/src/quota.mjs @@ -0,0 +1,113 @@ +// The quota side: what is left of the plan's monthly Vibe Code allowance. +// The official CLI reads the same public billing API +// (vibe/app_server/_billing.py): /v1/billing/budget names the percentage of +// both budgets spent and when they reset, /v1/billing/subscription names the +// plan, and console.mistral.ai/api/vibe/whoami names the key's scope. The +// gateway only ever called whoami; the budgets are what magpie's usage card +// needs. +// +// The key's scope decides which budget stops the account: a Vibe key's +// requests spend the Vibe Code allowance first, a Studio/API key's spend the +// API budget first. The other budget is shown set aside (aside), never routed +// on, so a spent API budget can't rest an account whose Vibe allowance has +// room. + +import { CONSOLE_ROOT, V1 } from "./constants.mjs" + +const TIMEOUT_MS = 8_000 + +async function getJson(url, key) { + const response = await fetch(url, { + headers: { authorization: `Bearer ${key}`, accept: "application/json" }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }) + if (!response.ok) { + const error = new Error(`${url} answered ${response.status}`) + error.status = response.status + throw error + } + return await response.json() +} + +const settled = async (promise) => { + try { + return { value: await promise } + } catch (error) { + return { error } + } +} + +function resetOf(value) { + if (typeof value !== "string" || !value.trim()) return null + const at = Date.parse(value) + return Number.isFinite(at) ? new Date(at).toISOString() : null +} + +function budgetWindow(name, detail) { + const used = Math.max(0, Number(detail.usage_percentage) || 0) + const limit = Number(detail.initial_budget) || 0 + const currency = typeof detail.currency === "string" ? detail.currency.trim() : "" + const window = { name, used } + if (limit > 0) { + const spent = (limit * used) / 100 + Object.assign(window, { + amount: spent, + limit, + unit: currency || "%", + display: `${spent.toFixed(2)} / ${limit.toFixed(2)}${currency ? ` ${currency}` : ""}`, + }) + } else { + Object.assign(window, { amount: used, limit: 100, unit: "%", display: `${used.toFixed(1)}%` }) + } + const resetsAt = resetOf(detail.reset_at) + if (resetsAt) window.resetsAt = resetsAt + return window +} + +export async function readQuota(key) { + const [who, subscription, budget] = await Promise.all([ + settled(getJson(`${CONSOLE_ROOT}/api/vibe/whoami`, key)), + settled(getJson(`${V1}/billing/subscription`, key)), + settled(getJson(`${V1}/billing/budget`, key)), + ]) + + const plan = subscription.value?.plan_name || who.value?.plan_name || "Mistral Vibe" + + // A 401 anywhere means Mistral refused the key itself: magpie tells the + // account to sign in again, rather than showing an empty card. + for (const result of [who, subscription, budget]) { + if (result.error?.status === 401) { + return { plan, windows: [], signIn: "expired", error: "Mistral refused this Vibe key (401); copy a fresh one from chat.mistral.ai/code/extensions" } + } + } + + if (budget.error) { + const status = budget.error.status + const why = + status === 403 + ? "this key may not read the organization's usage (see_org_inference_usage)" + : status === 404 + ? "this account has no billing endpoint" + : budget.error.message + return { plan, windows: [], signIn: "kept", error: `Vibe Code budget unreadable: ${why}` } + } + + const vibe = budget.value?.vibe_budget + const api = budget.value?.api_budget + if (!vibe && !api) { + const kind = who.value?.plan_type ? ` (key scope: ${who.value.plan_type})` : "" + return { plan, windows: [], signIn: "kept", error: `no Vibe Code budget on this plan${kind}` } + } + + // whoami's plan_type is the key's scope: "CHAT" is a Vibe key, "API" a + // workspace one. Unknown reads as a Vibe key, the case this plugin is for. + const scope = String(who.value?.plan_type ?? "").toLowerCase() + const primary = scope === "api" || scope === "workspace" ? "api" : "vibe" + const windows = [] + if (vibe) windows.push(budgetWindow("Vibe Code budget", vibe)) + if (api) windows.push(budgetWindow("API budget", api)) + for (const window of windows) { + if (!window.name.toLowerCase().startsWith(primary === "api" ? "api" : "vibe")) window.aside = true + } + return { plan, windows, signIn: "kept" } +} diff --git a/packages/mistral-vibe/src/reply.mjs b/packages/mistral-vibe/src/reply.mjs new file mode 100644 index 00000000..cc385fbf --- /dev/null +++ b/packages/mistral-vibe/src/reply.mjs @@ -0,0 +1,128 @@ +// What a reply has to look like for an OpenAI-shaped client, written from the +// shape Mistral sends: GLM's typed content parts, and tool calls whose stream +// still ends "stop". + +const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) + +// readParts splits typed parts into visible text and the model's thinking. +// A part's payload is a string or a list of `{text}` items. +function readParts(parts) { + return parts.reduce( + (out, part) => { + if (!isPlainObject(part)) return out + const payload = part.type === "thinking" ? part.thinking : part.type === "text" ? part.text : null + if (payload == null) return out + const text = typeof payload === "string" ? payload : Array.isArray(payload) ? payload.map((item) => (isPlainObject(item) && typeof item.text === "string" ? item.text : "")).join("") : "" + if (part.type === "thinking") out.thinking += text + else out.text += text + return out + }, + { text: "", thinking: "" }, + ) +} + +// ReplyFixer rewrites one reply. It remembers whether tool calls were seen, so +// the turn's finish reason can say so. +export class ReplyFixer { + #toolCalls = false + + #fixTurn(turn) { + if (!isPlainObject(turn)) return + if (Array.isArray(turn.content)) { + const { text, thinking } = readParts(turn.content) + turn.content = text + if (thinking) turn.reasoning_content = thinking + } + if (Array.isArray(turn.tool_calls) && turn.tool_calls.length > 0) this.#toolCalls = true + } + + // fixChoice rewrites one choice's message or delta. + fixChoice(choice) { + if (!isPlainObject(choice)) return choice + this.#fixTurn(choice.delta ?? choice.message) + if (this.#toolCalls && choice.finish_reason === "stop") choice.finish_reason = "tool_calls" + return choice + } + + // fixBody rewrites a whole JSON reply; a body with no choices (a + // /v1/responses reply) is left alone. + fixBody(body) { + if (isPlainObject(body) && Array.isArray(body.choices)) for (const choice of body.choices) this.fixChoice(choice) + return body + } + + // fixEvent rewrites one SSE event, framing kept. A payload that doesn't + // parse (a cut event) or `[DONE]` is returned exactly as it came. + fixEvent(event) { + const framed = /^([\s\S]*?)(\r?\n\r?\n)$/.exec(event) + const block = framed ? framed[1] : event + const gap = framed ? framed[2] : "" + const lines = block.split(/\r?\n/) + let changed = false + lines.forEach((line, index) => { + if (!line.startsWith("data:")) return + const payload = line.slice(5).trimStart() + if (payload === "" || payload === "[DONE]") return + let parsed + try { + parsed = JSON.parse(payload) + } catch { + return + } + if (!isPlainObject(parsed)) return + const before = JSON.stringify(parsed) + this.fixBody(parsed) + if (JSON.stringify(parsed) !== before) { + lines[index] = `data: ${JSON.stringify(parsed)}` + changed = true + } + }) + return changed ? lines.join(block.includes("\r\n") ? "\r\n" : "\n") + gap : event + } + + // fixStream passes a streamed reply through, rewriting each event as it + // arrives. + fixStream(response) { + if (!response.body) return response + const decoder = new TextDecoder() + const encoder = new TextEncoder() + const reader = response.body.getReader() + let buffered = "" + + const drain = (controller) => { + for (;;) { + const gap = /\r?\n\r?\n/.exec(buffered) + if (!gap) return + const end = gap.index + gap[0].length + controller.enqueue(encoder.encode(this.fixEvent(buffered.slice(0, end)))) + buffered = buffered.slice(end) + } + } + + const body = new ReadableStream({ + pull: async (controller) => { + const { done, value } = await reader.read() + if (done) { + if (buffered) controller.enqueue(encoder.encode(this.fixEvent(buffered))) + controller.close() + return + } + buffered += decoder.decode(value, { stream: true }) + drain(controller) + }, + cancel: (reason) => reader.cancel(reason), + }) + + // The bytes are no longer the upstream's, so its encoding and length + // would describe the wrong body. + const headers = new Headers(response.headers) + headers.delete("content-encoding") + headers.delete("content-length") + return new Response(body, { status: response.status, statusText: response.statusText, headers }) + } +} + +// fixReply rewrites one buffered reply. +export function fixReply(body) { + return new ReplyFixer().fixBody(body) +} diff --git a/packages/mistral-vibe/src/request.mjs b/packages/mistral-vibe/src/request.mjs new file mode 100644 index 00000000..37ca323e --- /dev/null +++ b/packages/mistral-vibe/src/request.mjs @@ -0,0 +1,76 @@ +// What a request has to look like for Mistral's API, written from what the +// API does with it: the fields it refuses, GLM's output cap without +// max_tokens, and the tool-result turn GLM ends too early. + +import { MODEL_ALIASES } from "./constants.mjs" + +export const WALLET_SUFFIX = "--api" +const GLM_OUTPUT = 131072 +const RENAMED = [ + ["max_completion_tokens", "max_tokens"], + ["seed", "random_seed"], +] +const DROPPED = new Set(["user"]) +const NOT_SENT_BACK = new Set(["reasoning_content", "index"]) + +const usesGlm = (model) => String(model).includes("glm") +const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value) + +// bareModel is the model Mistral serves for an id a client may have picked: +// the wallet suffix is ours, and the alias table maps the friendly names. +export function bareModel(id) { + const withoutWallet = typeof id === "string" && id.endsWith(WALLET_SUFFIX) ? id.slice(0, -WALLET_SUFFIX.length) : id + return MODEL_ALIASES[withoutWallet] ?? withoutWallet +} + +// withoutReplyFields is a message a client sent back: the two fields Mistral +// refuses on the way in are dropped, everything else is kept as it was. +function withoutReplyFields(message) { + if (!isPlainObject(message)) return message + return Object.fromEntries(Object.entries(message).filter(([name]) => !NOT_SENT_BACK.has(name))) +} + +// shapeRequest returns the body to send. A body it can't read, or a +// /v1/responses body (which Mistral takes as it is), goes through untouched +// apart from the model id. +export function shapeRequest(text, { responses = false } = {}) { + let body + try { + body = JSON.parse(text) + } catch { + return text + } + if (!isPlainObject(body)) return text + + const model = bareModel(body.model) + if (responses) return JSON.stringify({ ...body, model }) + + const shaped = { ...body, model } + + // A stream that names no usage option gets no usage chunk, and clients that + // bill tokens then see zero. + if (shaped.stream === true && shaped.stream_options === undefined) shaped.stream_options = { include_usage: true } + + // Fields Mistral answers with 422; each moves to the name it knows, or goes. + for (const [from, to] of RENAMED) { + if (!(from in shaped)) continue + if (shaped[from] != null) shaped[to] = shaped[from] + delete shaped[from] + } + for (const name of DROPPED) delete shaped[name] + + // Without max_tokens Mistral stops a GLM completion at 4096 tokens, which + // its thinking alone can spend. + if (shaped.max_tokens === undefined && usesGlm(model)) shaped.max_tokens = GLM_OUTPUT + + if (Array.isArray(shaped.messages)) { + shaped.messages = shaped.messages.map(withoutReplyFields) + const last = shaped.messages[shaped.messages.length - 1] + const composingAfterTool = shaped.reasoning_effort === undefined && usesGlm(model) && Array.isArray(shaped.tools) && shaped.tools.length > 0 && last?.role === "tool" + // GLM can end such a turn with no visible text; a higher effort makes it + // finish deliberating and compose. + if (composingAfterTool) shaped.reasoning_effort = "high" + } + + return JSON.stringify(shaped) +} diff --git a/packages/mistral-vibe/src/transport.mjs b/packages/mistral-vibe/src/transport.mjs new file mode 100644 index 00000000..99183679 --- /dev/null +++ b/packages/mistral-vibe/src/transport.mjs @@ -0,0 +1,106 @@ +// One request on its way to Mistral: the key its wallet names, the body +// shaped for the API, one retry when an effort is refused, and the reply put +// back in OpenAI's shape. + +import { adjustEffort } from "./effort.mjs" +import { ReplyFixer, fixReply } from "./reply.mjs" +import { WALLET_SUFFIX, shapeRequest } from "./request.mjs" + +const CHAT_PATH = /\/(chat\/completions|responses)\/?$/ +const RESPONSES_PATH = /\/responses\/?$/ + +// rebuild answers with a body we read ourselves: the upstream's encoding and +// length describe bytes we no longer send. +function rebuild(response, body) { + const headers = new Headers(response.headers) + headers.delete("content-encoding") + headers.delete("content-length") + return new Response(body, { status: response.status, statusText: response.statusText, headers }) +} + +function modelOf(text) { + try { + return JSON.parse(text)?.model ?? "" + } catch { + return "" + } +} + +// sendChat is the provider's fetch. A 429 is returned as it came: magpie +// rests the account and falls back on it, guided by Retry-After. +export async function sendChat(input, init, keys) { + const request = input instanceof Request ? input : new Request(input, init) + const url = request.url + const method = request.method + const path = new URL(url).pathname + const chat = CHAT_PATH.test(path) + + let sent = "" + if (method !== "GET" && method !== "HEAD") { + sent = init?.body != null && typeof init.body === "string" ? init.body : await request.clone().text() + } + + // The model id says which wallet: `--api` spends the API budget, the + // bare id the Vibe budget. + const asked = modelOf(sent) + const onApiWallet = typeof asked === "string" && asked.endsWith(WALLET_SUFFIX) + const key = onApiWallet ? keys?.api : (keys?.vibe ?? keys?.api) + + if (!key) { + return new Response( + JSON.stringify({ + error: { + message: `${asked || "this request"} needs the ${onApiWallet ? "API" : "Vibe"} key, which this sign-in doesn't hold`, + type: "invalid_request_error", + }, + }), + { status: 401, headers: { "content-type": "application/json" } }, + ) + } + + const headers = new Headers(init?.headers ?? request.headers) + headers.delete("authorization") + headers.delete("x-api-key") + headers.delete("content-length") + headers.set("authorization", `Bearer ${key}`) + + let body = chat ? shapeRequest(sent, { responses: RESPONSES_PATH.test(path) }) : sent + let streaming = false + try { + streaming = JSON.parse(body)?.stream === true + } catch {} + + const send = (payload) => + fetch(url, { + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : payload, + signal: request.signal, + redirect: "follow", + }) + + let response = await send(body) + + if (chat && (response.status === 400 || response.status === 422)) { + const refusal = await response.text() + const retryBody = adjustEffort(body, refusal) + if (retryBody == null) return rebuild(response, refusal) + const retried = await send(retryBody) + if (!retried.ok) return rebuild(retried, await retried.text()) + response = retried + body = retryBody + } + + if (!response.ok || !chat) return response + + if (streaming && /(^|\n)text\/event-stream/i.test(response.headers.get("content-type") ?? "")) { + return new ReplyFixer().fixStream(response) + } + + const text = await response.text() + try { + return rebuild(response, JSON.stringify(fixReply(JSON.parse(text)))) + } catch { + return rebuild(response, text) + } +} diff --git a/registry.json b/registry.json index f7f61fb9..125c6a01 100644 --- a/registry.json +++ b/registry.json @@ -1,6 +1,8 @@ { "version": 1, "plugins": [ + { "package": "@magpie-community/opencode-mistral-vibe-auth", "name": "Mistral Vibe", "providers": ["mistral-vibe"], "community": true, + "summary": { "en": "Unofficial Mistral Vibe plan access: the Vibe Code model catalog plus DeepSeek, each model on both wallets — the plan's Vibe budget, and the API budget as --api — with both budgets as quota windows.", "zh": "非官方接入 Mistral Vibe 套餐:Vibe Code 模型目录加上 DeepSeek,同一个模型可在两个钱包下选择——套餐的 Vibe 额度,以及用 --api 使用 API 额度——并把两个额度显示为用量窗口。" } }, { "package": "@magpie-community/opencode-agnes-auth", "name": "Agnes AI", "icon": "https://www.agnes-ai.com/images/biglogo.png", "providers": ["agnes"], "community": true, "summary": { "en": "Agnes AI international with Chrome browser sign-in: live OpenAI-compatible text models, Token Plan and Agent Plan usage, and the USD wallet balance.", "zh": "通过 Chrome 浏览器登录 Agnes AI 国际版:动态发现 OpenAI 兼容文本模型,显示 Token Plan、Agent Plan 用量与美元余额,无需手动复制会话 token。" } }, { "package": "@magpie-community/opencode-atomcode-auth", "name": "AtomCode", "icon": "https://atomgit.com/favicon.ico", "providers": ["atomcode"], "community": true,