dsh-connect-minimaxcode 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jiafeng Xu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,242 @@
1
- # Temporary Holding Version
1
+ # DSH Connect MiniMax Code (`dsh-connect-minimaxcode`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把本机已登录的 **MiniMax Code 桌面 App(国内版)** 的模型接入 **DeepSeek Harness(DSH)**,装好即用,无需配置 API Key。
4
+
5
+ ![license](https://img.shields.io/badge/license-MIT-blue.svg)
6
+ ![version](https://img.shields.io/badge/version-0.2.1-cornflowerblue.svg)
7
+
8
+ ## 灵感来源
9
+
10
+ 本插件的思路直接来自 **[dsh-connect-workbuddy](https://github.com/dingminhua/dsh-connect-workbuddy)**(作者 dingminhua)。
11
+
12
+ 它证明了这样一件事:**桌面 App 已经登录的模型,可以零配置地搬进 DSH**——不去碰账号密码,不引入新的鉴权体系,只是把本机已有的登录态接上一个 provider。本插件把同一套做法用在了 MiniMax Code 上:
13
+
14
+ | | dsh-connect-workbuddy | 本插件 |
15
+ |---|---|---|
16
+ | 数据来源 | WorkBuddy 桌面 App 的本地登录态 | MiniMax Code 桌面 App 的本地登录态 |
17
+ | 接入方式 | 注册 DSH provider,暴露已登录模型 | 同左 |
18
+ | 目录来源 | 上游实时模型列表 | 同左 |
19
+
20
+ 该项目的架构(插件结构、provider 注册、只读复用登录态)也是本插件的主要参照,致谢见文末。
21
+
22
+ ---
23
+
24
+ ## ⚠️ 重要声明(请先阅读)
25
+
26
+ - **本项目仅供学习与研究使用**,不面向生产环境,也不提供任何商业支持。
27
+ - **本项目为非官方插件**,与 MiniMax、DeepSeek 均无关联,未获其认可或授权。
28
+ - 本插件仅**读取你自己本机上 MiniMax Code 已登录的凭证**,仅驱动**你自己的账号在你自己的电脑上**调用。
29
+ - **禁止**将本插件用于商业用途、超出个人合理使用范围的场景,或任何批量/多账号/规避限额的目的。
30
+ - 本插件依赖 MiniMax 客户端的**非公开接口**。MiniMax 更新客户端后,接口可能变更导致插件失效。
31
+ - 使用者需自行遵守 [MiniMax 的服务条款](https://agent.minimax.cn/doc/zh/terms-of-service.html)。因使用本插件产生的一切后果(账号受限、额度清空、服务中断等)由使用者自行承担。
32
+ - 本项目作者不对任何因使用或不当使用本插件造成的直接或间接损失负责。
33
+
34
+ ---
35
+
36
+ ## 特点
37
+
38
+ - **零配置**:自动读取 MiniMax Code 桌面 App 的登录态,不在 DSH 中存储任何明文 Token
39
+ - **动态模型目录**:从上游接口实时拉取,模型增减无需更新插件
40
+ - **复用官方适配器**:上游为标准 Anthropic Messages 协议,直接复用 DSH 自带 provider,工具调用、推理块、上下文压缩均由 DSH 托管
41
+ - **只读**:插件不写入、不修改 MiniMax 的任何文件,也不执行任何登录或续期动作
42
+ - **不含客户端 UI**:不注册浏览器侧插件,因此不会影响 DSH 的启动(见「已知限制」)
43
+
44
+ ## 前置条件
45
+
46
+ 1. 已安装并**登录 MiniMax Code 桌面 App(国内版)**
47
+ 2. DSH 桌面版(内置内核 `0.2.0` 系列)或 Web profile
48
+
49
+ ## 安装
50
+
51
+ **方式一:从 Git 仓库安装**(推荐,拿到最新代码)
52
+
53
+ ```bash
54
+ dsh plugin --profile desktop add github:JeffTsuiCUG/dsh-connect-minimaxcode
55
+ ```
56
+
57
+ **方式二:npm 安装**(发布后可用)
58
+
59
+ ```bash
60
+ dsh plugin --profile desktop add dsh-connect-minimaxcode
61
+ ```
62
+
63
+ Web profile 把 `--profile desktop` 换成 `--profile web`。
64
+
65
+ > 桌面版的 profile 由 Electron 应用独占,若 `dsh` 命令不在 PATH 中,请使用 DSH 应用自带的载体 CLI(路径随安装位置而变,例如 `<DSH安装目录>\resources\runtime\cli\bin\dsh.cmd`)。
66
+
67
+ 安装后**无需重启**,插件会热加载;模型随后出现在模型选择器的 **MiniMax Code** 分组中。
68
+
69
+ ### 若安装被 `allowBuilds` 拦截
70
+
71
+ 本仓库把构建产物 `lib/` 一并提交,因此正常情况下安装不会触发构建。若你看到:
72
+
73
+ ```text
74
+ [ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED] Failed to prepare git-hosted package
75
+ ```
76
+
77
+ 说明你使用的是**某个早于 `0.2.1` 的提交**(那些版本尚未提交 `lib/`)。解决办法二选一:
78
+
79
+ - 改用上面写法指定版本:`github:JeffTsuiCUG/dsh-connect-minimaxcode#v0.2.1`
80
+ - 或在该 profile 的 `pnpm-workspace.yaml` 中加入 pnpm 提示的 `allowBuilds` 条目后重试
81
+
82
+ ## 使用流程
83
+
84
+ 1. **确认桌面 App 已登录**
85
+ 打开 MiniMax Code 桌面 App,确认处于已登录状态。插件读取的是 App 写入的本地登录态。
86
+
87
+ 2. **安装插件**(见上一步的命令),等待热加载完成。
88
+
89
+ 3. **选择模型**
90
+ 打开 DSH 的模型选择器,在 **MiniMax Code** 分组下选择模型。可用模型来自上游实时目录,例如 `M2.7`、`M2.7-highspeed`、`M3`、`M3.1-Flash-Preview`。
91
+
92
+ 4. **正常对话**
93
+ 直接使用即可。工具调用由 DSH 本地工具执行,插件只负责提供模型通路。
94
+
95
+ 5. **令牌过期时重新登录**
96
+ MiniMax Code 的登录令牌为短期 JWT(通常约 15 天),本插件**不会自动续期**。过期后:
97
+
98
+ | 现象 | 含义 | 处理方式 |
99
+ |---|---|---|
100
+ | 正常使用 | 令牌有效 | — |
101
+ | 剩余不足 24 小时 | 令牌即将失效 | 打开一次 MiniMax Code 桌面 App 刷新 |
102
+ | 请求返回鉴权错误 | 令牌已失效 | 在 MiniMax Code 中**重新登录**,然后重启 DSH |
103
+
104
+ > 令牌过期不会丢失数据。在 MiniMax Code 桌面 App 重新登录后,它会自动写入新的令牌,插件下次启动即可读到。
105
+
106
+ ### 关于响应速度
107
+
108
+ MiniMax 的这些模型**强制要求先推理再输出**,思考无法关闭。因此:
109
+
110
+ - 首个字符出现前会有一段等待(实测 M3.1-Flash-Preview 约 3 秒)
111
+ - 推理过程会以 `thinking` 块实时返回,可以在界面中看到模型"在想什么"
112
+ - 这是模型本身的行为,**不是插件造成的延迟**
113
+
114
+ 若更看重流式体感的即时反馈,建议同时保留 DSH 官方模型或其他 provider 作为备选。
115
+
116
+ ## 工作原理
117
+
118
+ 1. 读取 `~/.minimax/local-runtime.auth.json`(MiniMax Code 写入的明文登录信息),**仅在内存中**解析其 JWT
119
+ 2. 调用 `GET /mavis/api/v1/models` 获取实时模型目录(含上下文长度、多模态、工具调用等能力声明)
120
+ 3. 注册 `minimaxcode` provider,指向 `POST /mavis/api/v1/llm/v1/messages`(标准 Anthropic Messages 协议)
121
+
122
+ ### 与网关对接时的三个兼容处理
123
+
124
+ 这些是实测中必须处理的差异,也是本插件与"直接改个 URL 就能用"的主要差异所在:
125
+
126
+ | # | 现象 | 处理 |
127
+ |---|---|---|
128
+ | 1 | 网关只认 `Authorization: Bearer`,而 pi-ai 默认发 `x-api-key`,后者单独出现会返回 `401 token is required` | 在模型描述符上附带 `Authorization` 头(网关同时接受两个头) |
129
+ | 2 | Anthropic SDK 会自己在 `baseUrl` 后追加 `/v1/messages`;若 `baseUrl` 已以 `/v1` 结尾,请求路径会变成 `/v1/v1/messages`,网关返回 `503 direct_route_not_configured` | `baseUrl` 收到版本段之前,由 SDK 补齐 |
130
+ | 3 | 所有模型强制要求推理,而"关闭推理"会让 pi-ai 发出 `thinking: {type: "disabled"}`,网关返回 `400 ... requires adaptive thinking` | 将"关闭"级别标记为不支持,使该字段被省略(网关接受) |
131
+
132
+ 插件**不**包含任何硬编码的 Token、账号或接口密钥。
133
+
134
+ ## 为什么没有额度显示
135
+
136
+ 「显示剩余额度」这个功能被实测排除过,结论写在这里以免后来者重复调查。
137
+
138
+ **额度数据是真实存在的**。逆向 MiniMax Code 客户端可确认它使用了这样一组字段:
139
+
140
+ ```
141
+ op_credit_summary: {
142
+ total_remaining_amount, // 总剩余额度
143
+ purchased_remaining_amount, // 购买额度剩余
144
+ free_remaining_amount // 免费额度剩余
145
+ }
146
+ ```
147
+
148
+ **但找不到为它服务的接口**。已验证的部分:
149
+
150
+ | 尝试 | 结果 |
151
+ |---|---|
152
+ | `GET /v1/api/user/info` + 模型网关令牌 | `401`,两类凭证互不通用 |
153
+ | `GET /v1/api/user/info` + 客户端 OAuth 令牌(`mmoat_`) | `200`,但响应只有昵称、头像、`vipInfo` 等账户资料,**不含任何额度字段** |
154
+ | 14 个候选额度路径(`user/credits`、`user/quota`、`workspace/list`、`credit/summary` 等) | 全部 `404` |
155
+ | `account.minimax.cn` 同一批路径 | 同样无额度字段 |
156
+
157
+ 上述额度字段只出现在客户端**已混淆的前端 bundle** 中,属于渲染逻辑;真正提供数据的接口路径未能定位。实测所用的 OAuth 凭证有效期约 1 小时,由客户端自动续期,不适合作为插件的长期依赖。
158
+
159
+ 因此本插件不显示额度。若你已知可用路径,欢迎提Issue 或 PR;额度请直接在 MiniMax Code 客户端中查看。
160
+
161
+ ## 已知限制
162
+
163
+ - **仅支持国内版(`agent.minimax.cn`)**。国际版网关是否可用**未经验证**,请勿据此推断其他区域可用。
164
+ - **必须开启推理**。MiniMax 的全部模型都声明需要推理,关不掉。首字延迟因此高于非推理模型。
165
+ - **未登录时模型列表是内置兜底**。此时选择器中仍会显示 `M2.7`、`M3` 两个内置条目,但它们无法真正调用;登录后才会切换为上游实时目录。判断是否可用,请以实际调用结果为准。
166
+ - **不提供余额/用量显示**。这一项经过实测后确认无法实现,原因记录在下方「为什么没有额度显示」中。
167
+ - **不自动续期令牌**,请参照上方「令牌有效期」。
168
+ - **不提供设置界面卡片**。本插件为纯宿主侧插件,不在 DSH 设置页注册任何卡片,因此不会参与浏览器端的启动流程(这同时也是它不会导致 DSH 启动失败的原因)。
169
+ - **上游接口非公开**,MiniMax 客户端更新后可能失效。
170
+
171
+ ## 卸载
172
+
173
+ ```bash
174
+ dsh plugin --profile desktop remove dsh-connect-minimaxcode
175
+ ```
176
+
177
+ ## 开发
178
+
179
+ ```bash
180
+ pnpm install
181
+ pnpm test # 单元测试 + 宿主集成测试
182
+ pnpm typecheck # 类型检查
183
+ pnpm build # 构建
184
+ ```
185
+
186
+ 构建依赖 `@earendil-works/pi-ai`,需与宿主中 `dsh-llm-pi-ai` 解析出的版本一致(当前为 `0.85.x`),否则类型不兼容。
187
+
188
+ **`lib/` 是纳入版本管理的构建产物**,不是生成物。这是刻意为之:git 源安装拿到的是仓库原样内容,若不含 `lib/`,pnpm 会判定该包"需要构建"并触发 `prepare`,随即被构建脚本白名单拦下。改动源码后请重新构建并提交:
189
+
190
+ ```bash
191
+ pnpm build && git add lib
192
+ ```
193
+
194
+ ## 同类插件
195
+
196
+ 这些插件与本插件的思路一致——**复用你本机桌面 App 已有的登录态,把模型接进 DSH**,无需另配 API Key。它们由不同作者独立开发,与本项目无隶属关系。
197
+
198
+ | 插件 | 数据来源 | 每日签到领免费额度 | 额度 / 用量显示 |
199
+ |---|---|---|---|
200
+ | **dsh-connect-trae** | Trae(国内版 + 国际版) | ✅ 国内版 | ✅ Work / 通用积分概览 |
201
+ | **dsh-qoder-connect** | Qoder(国内版 + 国际版,PAT) | ✅ 每日 100 Credits | ✅ 侧栏额度小卡 + 明细 |
202
+ | **dsh-workbuddy-connect** | WorkBuddy(国内版 + 国际版) | — | ✅ 剩余积分 + 明细 |
203
+
204
+ > 各插件的签到规则、额度口径与限制以各自文档为准。本表只列出经其 README 明确写出的功能,未逐项实测。
205
+
206
+ ### [dsh-connect-trae](https://github.com/dingminhua/dsh-connect-trae)
207
+
208
+ 把本机已登录的 Trae 接入 DSH,国内版与国际版可同时使用。国内版支持每日签到领积分,并提供 Work / 通用积分的只读概览。注意 Trae 的签到按「每台设备每天一次」限制。
209
+
210
+ ```bash
211
+ dsh plugin --profile desktop add dsh-connect-trae
212
+ ```
213
+
214
+ ### [dsh-qoder-connect](https://github.com/masknull/dsh-qoder-connect)
215
+
216
+ 以个人访问令牌(PAT)接入 Qoder,双区域独立配置。每日自动签到领取 100 Credits 算力额度,签到时刻可自定义(默认 10:00,UTC+8),并带开机防漏补签;另有侧栏额度展示与逐包明细。
217
+
218
+ ```bash
219
+ dsh plugin --profile desktop add dsh-qoder-connect
220
+ ```
221
+
222
+ ### [dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect)
223
+
224
+ 接入 WorkBuddy 桌面 App,国内版与国际版可并存,各自使用自己的账号与积分。提供账号信息、令牌有效期与剩余积分查看,并带 CLI:
225
+
226
+ ```bash
227
+ dsh plugin --profile web exec dsh-workbuddy-connect status
228
+ ```
229
+
230
+ > 该插件的版本需与你的 DSH 内核严格对应(不同内核线用不同版本),安装前请见其 README 的版本对应表。
231
+
232
+ ## 致谢
233
+
234
+ - **[dsh-connect-workbuddy](https://github.com/dingminhua/dsh-connect-workbuddy)** — 本插件的灵感来源,架构与"复用本机登录态接入 DSH"的整体思路均源自该项目
235
+ - [dsh-connect-trae](https://github.com/dingminhua/dsh-connect-trae) — 同作者的 DSH 插件,可作为 DSH 插件结构的参照
236
+ - [dsh-workbuddy-connect](https://github.com/corrinehu/dsh-workbuddy-connect) — 客户端 bundle 规范与凭据复用的设计参照
237
+
238
+ > 上方「同类插件」中的项目均为独立开发者的作品,与本项目无隶属或背书关系,各自遵循其自身的许可与免责声明。
239
+
240
+ ## 许可证
241
+
242
+ [MIT](./LICENSE)
@@ -0,0 +1,6 @@
1
+ # Register the MiniMax Code provider without changing the profile's default
2
+ # model. The plugin reuses the MiniMax Code desktop app's existing sign-in; no
3
+ # separate login flow is started.
4
+ - insert:
5
+ - id: llm-minimaxcode
6
+ name: dsh-connect-minimaxcode
package/lib/index.d.ts ADDED
@@ -0,0 +1,297 @@
1
+ import "@earendil-works/pi-ai";
2
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
3
+ import { Context } from "@deepseek-ai/cordis";
4
+ //#region src/auth.d.ts
5
+ /** File the desktop app writes its runtime auth context into. */
6
+ declare const MINIMAX_AUTH_FILENAME = "local-runtime.auth.json";
7
+ /** Directory holding it, relative to the user's home. */
8
+ declare const MINIMAX_DATA_DIR = ".minimax";
9
+ /** Region-specific gateways the desktop app ships with. */
10
+ declare const MINIMAX_GATEWAYS: {
11
+ readonly cn: "https://agent.minimax.cn";
12
+ readonly io: "https://agent.minimax.io";
13
+ readonly com: "https://agent.minimaxi.com";
14
+ };
15
+ /** A gateway this plugin knows how to talk to. */
16
+ type MinimaxRegion = keyof typeof MINIMAX_GATEWAYS;
17
+ /** Every region, for a fallback sweep when the configured one fails. */
18
+ declare const MINIMAX_REGIONS: readonly MinimaxRegion[];
19
+ /**
20
+ * Base URL for pi-ai's anthropic client on a region gateway.
21
+ *
22
+ * This must stop *before* the API version segment: the Anthropic SDK appends
23
+ * `/v1/messages` itself, so a base ending in `/v1` produced the doubled path
24
+ * `/mavis/api/v1/llm/v1/v1/messages`, which the gateway answers with
25
+ * `503 direct_route_not_configured`. The correct request URL is therefore
26
+ * `<this>/v1/messages`.
27
+ */
28
+ declare function chatBaseUrl(region: MinimaxRegion): string;
29
+ /** Base URL of the authoritative model catalog on a region gateway. */
30
+ declare function catalogUrl(region: MinimaxRegion): string;
31
+ /** Default data directory, honouring the app's own override variables. */
32
+ declare function dataDir(env?: NodeJS.ProcessEnv): string;
33
+ /** Absolute path of the auth file this plugin reads. */
34
+ declare function authPath(env?: NodeJS.ProcessEnv): string;
35
+ /** The JWT claims this plugin acts on. */
36
+ interface TokenClaims {
37
+ /** Expiry, seconds since the epoch. */
38
+ exp: number;
39
+ /** Account id the issuer embedded, when present. */
40
+ userId?: string;
41
+ /** Display name the issuer embedded, when present. */
42
+ name?: string;
43
+ }
44
+ /** A decoded, still-valid-at-read-time credential. */
45
+ interface Credential {
46
+ token: string;
47
+ claims: TokenClaims;
48
+ /** Milliseconds since the epoch, from the file's own `updatedAtMs`. */
49
+ updatedAtMs?: number;
50
+ }
51
+ /** Why a credential could not be used, in a form the UI can render. */
52
+ type AuthFailure = {
53
+ state: 'signed-out';
54
+ reason: 'app-missing' | 'not-signed-in' | 'unreadable' | 'malformed';
55
+ } | {
56
+ state: 'expired';
57
+ expiresAtMs: number;
58
+ };
59
+ type AuthState = {
60
+ state: 'signed-in';
61
+ credential: Credential;
62
+ } | AuthFailure;
63
+ /**
64
+ * Decode a JWT payload without verifying its signature.
65
+ *
66
+ * Only the expiry is trusted, and only to decide how loudly to warn: a token we
67
+ * cannot parse is reported as malformed rather than silently accepted, and a
68
+ * token that verifies as expired is reported as expired rather than sent. The
69
+ * issuer remains the sole authority on both questions.
70
+ */
71
+ declare function decodeClaims(token: string): TokenClaims | undefined;
72
+ /** Milliseconds until the token expires; negative once it has. */
73
+ declare function expiresInMs(claims: TokenClaims, now?: number): number;
74
+ /** How a remaining lifetime should be presented to the user. */
75
+ type ExpiryLevel = 'healthy' | 'expiring' | 'expired';
76
+ /** Classify a token's remaining lifetime for display. */
77
+ declare function expiryLevel(claims: TokenClaims, now?: number): ExpiryLevel;
78
+ /** Whole days left, rounded up so 1 hour reads as "1 day", not "0 days". */
79
+ declare function daysRemaining(claims: TokenClaims, now?: number): number;
80
+ /**
81
+ * Parse the app's auth document into a state.
82
+ *
83
+ * Shared by the async and sync readers so the two can never disagree about what
84
+ * counts as signed in.
85
+ *
86
+ * @param text - Raw file contents.
87
+ * @param now - Clock, injected so expiry is testable.
88
+ * @returns The usable credential, or why there is none.
89
+ */
90
+ declare function parseAuthDocument(text: string, now?: number): AuthState;
91
+ /**
92
+ * Read the desktop app's credential.
93
+ *
94
+ * Every failure mode is a state rather than an exception: a user who has not
95
+ * installed or signed in yet is a normal condition the card renders, not an
96
+ * error the host logs on every start.
97
+ *
98
+ * @param env - Environment used to locate the data directory.
99
+ * @param now - Clock, injected so expiry is testable.
100
+ * @returns The usable credential, or why there is none.
101
+ */
102
+ declare function readCredential(env?: NodeJS.ProcessEnv, now?: number): Promise<AuthState>;
103
+ /**
104
+ * The bearer token for the current request, or `undefined`.
105
+ *
106
+ * pi-ai's anthropic-messages client sends the resolved `apiKey` as `x-api-key`,
107
+ * but MiniMax's gateway answers `{"code":401,"message":"token is required"}`
108
+ * unless an `Authorization: Bearer` header is also present. That header has to
109
+ * ride on the model descriptor ({@link Model.headers}), which is built inside
110
+ * the provider's synchronous `getModels()` — hence the synchronous read here.
111
+ * pi-ai calls `getModels()` once per operation, so the token is re-read per
112
+ * request and a re-login takes effect without a restart.
113
+ *
114
+ * @param env - Environment used to locate the data directory.
115
+ * @param now - Clock, injected so expiry is testable.
116
+ * @returns The token, or `undefined` when there is no usable one.
117
+ */
118
+ declare function bearerToken(env?: NodeJS.ProcessEnv, now?: number): string | undefined;
119
+ //#endregion
120
+ //#region src/catalog.d.ts
121
+ /** One model as the upstream describes it. */
122
+ interface CatalogModel {
123
+ id: string;
124
+ name: string;
125
+ contextWindow: number;
126
+ maxOutputTokens?: number;
127
+ /** Whether the model accepts tool definitions and emits `tool_use`. */
128
+ toolCall: boolean;
129
+ /** Whether the model emits reasoning blocks. */
130
+ reasoning: boolean;
131
+ /** Input modalities, e.g. `text`, `image`, `video`. */
132
+ inputModalities: string[];
133
+ /** Whether the model accepts images at all. */
134
+ attachment: boolean;
135
+ }
136
+ /** Provider id registered with DSH, distinct from the upstream one. */
137
+ declare const MINIMAXCODE_PROVIDER_ID = "minimaxcode";
138
+ /**
139
+ * Used when the catalog cannot be fetched, so the picker still offers the
140
+ * models the desktop app is known to ship.
141
+ *
142
+ * Deliberately small and conservative: a fallback entry overstates nothing we
143
+ * could not confirm.
144
+ */
145
+ declare const FALLBACK_MODELS: readonly CatalogModel[];
146
+ /** Where the currently served list came from. */
147
+ type CatalogSource = 'live' | 'fallback';
148
+ /**
149
+ * Parse the upstream catalog document.
150
+ *
151
+ * Unknown providers are ignored rather than rejected: the endpoint serves every
152
+ * provider the desktop app is configured for, and only MiniMax models belong in
153
+ * this plugin's picker.
154
+ *
155
+ * @param document - Decoded response body.
156
+ * @returns Models in upstream order, or an empty list when none qualify.
157
+ */
158
+ declare function parseCatalog(document: unknown): CatalogModel[];
159
+ /**
160
+ * Holds the catalog and answers the picker's questions about it.
161
+ *
162
+ * Refreshes are explicit: the plugin refreshes at startup and on demand, and
163
+ * keeps serving the last good snapshot while a refresh is in flight, so a
164
+ * network blip never empties the model list.
165
+ */
166
+ declare class MinimaxCatalog {
167
+ #private;
168
+ /** The models to serve right now. */
169
+ current(): CatalogModel[];
170
+ /** Provenance of {@link current}. */
171
+ get source(): CatalogSource;
172
+ /** When {@link current} was fetched, when it came from upstream. */
173
+ get fetchedAtMs(): number | undefined;
174
+ /** Why the last refresh failed, if one did. */
175
+ get error(): string | undefined;
176
+ /**
177
+ * Replace the served list, adopting upstream's own ordering.
178
+ *
179
+ * @returns Whether anything changed, so callers can skip a needless reload.
180
+ */
181
+ set(models: readonly CatalogModel[], source: CatalogSource, fetchedAtMs?: number): boolean;
182
+ /** Drop back to the built-in list, e.g. when the account signs out. */
183
+ invalidate(): void;
184
+ /**
185
+ * Fetch the live catalog for a credential.
186
+ *
187
+ * Serialised through a single in-flight promise: a second caller joins the
188
+ * first run rather than starting a duplicate request.
189
+ *
190
+ * @returns Whether the served list changed.
191
+ */
192
+ refresh(region: MinimaxRegion, credential: Credential, fetchImpl?: typeof fetch): Promise<boolean>;
193
+ /** Identity the served catalog belongs to, if any. */
194
+ get identity(): string | undefined;
195
+ }
196
+ //#endregion
197
+ //#region src/status-route.d.ts
198
+ /** Route this plugin mounts, matching its settings card. */
199
+ declare const MINIMAX_STATUS_PATH = "/plugins/dsh-connect-minimaxcode/status";
200
+ /** What the card renders. */
201
+ interface StatusDocument {
202
+ state: 'signed-in' | 'signed-out' | 'expired';
203
+ /** Human-readable next step, present when not signed in. */
204
+ hint?: string;
205
+ /** Whole days until expiry; absent when unknown. */
206
+ daysRemaining?: number;
207
+ /** Traffic-light state for the expiry warning. */
208
+ expiry?: 'healthy' | 'expiring' | 'expired';
209
+ region?: string;
210
+ /** Provenance of the model list. */
211
+ catalogSource?: 'live' | 'fallback';
212
+ catalogError?: string;
213
+ models?: {
214
+ id: string;
215
+ name: string;
216
+ contextWindow: number;
217
+ }[];
218
+ /** Whether a catalog refresh is in flight. */
219
+ refreshing?: boolean;
220
+ }
221
+ /** Inputs the document is derived from. */
222
+ interface StatusDeps {
223
+ auth: {
224
+ state: 'signed-in' | 'signed-out' | 'expired';
225
+ daysRemaining?: number;
226
+ expiry?: 'healthy' | 'expiring' | 'expired';
227
+ reason?: string;
228
+ };
229
+ region: string;
230
+ catalogSource: 'live' | 'fallback';
231
+ catalogError?: string;
232
+ models: {
233
+ id: string;
234
+ name: string;
235
+ contextWindow: number;
236
+ }[];
237
+ refreshing: boolean;
238
+ }
239
+ /**
240
+ * Build the card's status document.
241
+ *
242
+ * The three non-signed-in states carry different remedies, and the difference
243
+ * is the whole point of the card: a user who has never installed the app needs
244
+ * a different next step than one whose token simply aged out.
245
+ */
246
+ declare function statusDocument(deps: StatusDeps): StatusDocument;
247
+ //#endregion
248
+ //#region src/adapter.d.ts
249
+ /** How long a silent stream may last before the transport gives up. */
250
+ declare const MINIMAX_STREAM_IDLE_TIMEOUT_MS: number;
251
+ /** Options for building the provider. */
252
+ interface AdapterOptions {
253
+ catalog: MinimaxCatalog;
254
+ /** Region whose gateway the models are served from. */
255
+ region: () => MinimaxRegion;
256
+ /** Live token, read fresh per call so a re-login takes effect at once. */
257
+ resolveApiKey: () => Promise<string | undefined>;
258
+ /**
259
+ * Live token for the `Authorization` header, read synchronously because the
260
+ * model descriptors that carry it are built inside `getModels()`.
261
+ */
262
+ bearerToken: () => string | undefined;
263
+ }
264
+ /** What {@link createMinimaxCodeAdapter} hands back. */
265
+ interface AdapterBundle {
266
+ adapter: PiAiAdapter;
267
+ /** Drop cached profiles so the next pick rebuilds them. */
268
+ invalidate: () => void;
269
+ }
270
+ /**
271
+ * Build the DSH adapter that serves the catalog.
272
+ *
273
+ * The model list is rebuilt on demand rather than snapshotted, which is what
274
+ * lets a refresh upstream appear without restarting the host.
275
+ *
276
+ * @param options - Catalog, region and token resolution.
277
+ * @returns The adapter plus a way to drop its cached profiles.
278
+ */
279
+ declare function createMinimaxCodeAdapter(options: AdapterOptions): AdapterBundle;
280
+ //#endregion
281
+ //#region src/index.d.ts
282
+ /** Loader entry name; also the id the bundle patch inserts. */
283
+ declare const name = "dsh-connect-minimaxcode";
284
+ /** Services this plugin needs before it can serve anything. */
285
+ declare const inject: string[];
286
+ /**
287
+ * Compose the plugin.
288
+ *
289
+ * The credential is re-read on every request rather than cached at start: the
290
+ * desktop app refreshes its token independently, and a long-lived host would
291
+ * otherwise pin an expired one for the rest of the process.
292
+ *
293
+ * @param ctx - Service container.
294
+ */
295
+ declare function apply(ctx: Context): void;
296
+ //#endregion
297
+ export { type AuthState, type CatalogModel, type CatalogSource, type Credential, FALLBACK_MODELS, MINIMAXCODE_PROVIDER_ID, MINIMAX_AUTH_FILENAME, MINIMAX_DATA_DIR, MINIMAX_GATEWAYS, MINIMAX_REGIONS, MINIMAX_STATUS_PATH, MINIMAX_STREAM_IDLE_TIMEOUT_MS, MinimaxCatalog, type MinimaxRegion, type StatusDocument, type TokenClaims, apply, authPath, bearerToken, catalogUrl, chatBaseUrl, createMinimaxCodeAdapter, dataDir, daysRemaining, decodeClaims, expiresInMs, expiryLevel, inject, name, parseAuthDocument, parseCatalog, readCredential, statusDocument };
package/lib/index.js ADDED
@@ -0,0 +1,715 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createProvider } from "@earendil-works/pi-ai";
6
+ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
7
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
8
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
+ //#region src/auth.ts
10
+ /**
11
+ * Reading the MiniMax Code desktop app's own sign-in.
12
+ *
13
+ * The desktop app persists its bearer token as plaintext JSON under its data
14
+ * directory. This module reads that file, decodes the JWT without verifying it
15
+ * (the signature exists to stop *tampering*, and nobody tampers with a token we
16
+ * only ever send back to the issuer), and reports expiry so the UI can warn
17
+ * before the models go dark.
18
+ *
19
+ * Nothing here performs a login, a refresh, or any write. The token's lifetime
20
+ * is owned by the desktop app: renewing it means signing in there again, which
21
+ * is a deliberate step the user takes in the app, never one this plugin makes
22
+ * on their behalf.
23
+ *
24
+ * @module dsh-connect-minimaxcode/auth
25
+ */
26
+ /** File the desktop app writes its runtime auth context into. */
27
+ const MINIMAX_AUTH_FILENAME = "local-runtime.auth.json";
28
+ /** Directory holding it, relative to the user's home. */
29
+ const MINIMAX_DATA_DIR = ".minimax";
30
+ /** Region-specific gateways the desktop app ships with. */
31
+ const MINIMAX_GATEWAYS = {
32
+ cn: "https://agent.minimax.cn",
33
+ io: "https://agent.minimax.io",
34
+ com: "https://agent.minimaxi.com"
35
+ };
36
+ /** Every region, for a fallback sweep when the configured one fails. */
37
+ const MINIMAX_REGIONS = [
38
+ "cn",
39
+ "io",
40
+ "com"
41
+ ];
42
+ /**
43
+ * Base URL for pi-ai's anthropic client on a region gateway.
44
+ *
45
+ * This must stop *before* the API version segment: the Anthropic SDK appends
46
+ * `/v1/messages` itself, so a base ending in `/v1` produced the doubled path
47
+ * `/mavis/api/v1/llm/v1/v1/messages`, which the gateway answers with
48
+ * `503 direct_route_not_configured`. The correct request URL is therefore
49
+ * `<this>/v1/messages`.
50
+ */
51
+ function chatBaseUrl(region) {
52
+ return `${MINIMAX_GATEWAYS[region]}/mavis/api/v1/llm`;
53
+ }
54
+ /** Base URL of the authoritative model catalog on a region gateway. */
55
+ function catalogUrl(region) {
56
+ return `${MINIMAX_GATEWAYS[region]}/mavis/api/v1/models`;
57
+ }
58
+ /** Default data directory, honouring the app's own override variables. */
59
+ function dataDir(env = process.env) {
60
+ return env.MINIMAX_DATA_DIR ?? env.MAVIS_DATA_DIR ?? join(homedir(), ".minimax");
61
+ }
62
+ /** Absolute path of the auth file this plugin reads. */
63
+ function authPath(env = process.env) {
64
+ return join(dataDir(env), MINIMAX_AUTH_FILENAME);
65
+ }
66
+ /**
67
+ * Decode a JWT payload without verifying its signature.
68
+ *
69
+ * Only the expiry is trusted, and only to decide how loudly to warn: a token we
70
+ * cannot parse is reported as malformed rather than silently accepted, and a
71
+ * token that verifies as expired is reported as expired rather than sent. The
72
+ * issuer remains the sole authority on both questions.
73
+ */
74
+ function decodeClaims(token) {
75
+ const parts = token.split(".");
76
+ if (parts.length !== 3) return void 0;
77
+ try {
78
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
79
+ if (typeof payload.exp !== "number") return void 0;
80
+ const user = payload.user;
81
+ const claims = { exp: payload.exp };
82
+ if (typeof user?.id === "string") claims.userId = user.id;
83
+ if (typeof user?.name === "string") claims.name = user.name;
84
+ return claims;
85
+ } catch {
86
+ return;
87
+ }
88
+ }
89
+ /** Milliseconds until the token expires; negative once it has. */
90
+ function expiresInMs(claims, now = Date.now()) {
91
+ return claims.exp * 1e3 - now;
92
+ }
93
+ /** Warn inside the last day, and treat anything past it as already gone. */
94
+ const EXPIRING_THRESHOLD_MS = 864e5;
95
+ /** Classify a token's remaining lifetime for display. */
96
+ function expiryLevel(claims, now = Date.now()) {
97
+ const remaining = expiresInMs(claims, now);
98
+ if (remaining <= 0) return "expired";
99
+ if (remaining <= EXPIRING_THRESHOLD_MS) return "expiring";
100
+ return "healthy";
101
+ }
102
+ /** Whole days left, rounded up so 1 hour reads as "1 day", not "0 days". */
103
+ function daysRemaining(claims, now = Date.now()) {
104
+ const remaining = expiresInMs(claims, now);
105
+ return remaining <= 0 ? 0 : Math.ceil(remaining / 864e5);
106
+ }
107
+ /**
108
+ * Parse the app's auth document into a state.
109
+ *
110
+ * Shared by the async and sync readers so the two can never disagree about what
111
+ * counts as signed in.
112
+ *
113
+ * @param text - Raw file contents.
114
+ * @param now - Clock, injected so expiry is testable.
115
+ * @returns The usable credential, or why there is none.
116
+ */
117
+ function parseAuthDocument(text, now = Date.now()) {
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(text);
121
+ } catch {
122
+ return {
123
+ state: "signed-out",
124
+ reason: "unreadable"
125
+ };
126
+ }
127
+ const token = parsed.auth?.accessToken;
128
+ if (typeof token !== "string" || token.length === 0) return {
129
+ state: "signed-out",
130
+ reason: "not-signed-in"
131
+ };
132
+ const claims = decodeClaims(token);
133
+ if (claims === void 0) return {
134
+ state: "signed-out",
135
+ reason: "malformed"
136
+ };
137
+ if (expiresInMs(claims, now) <= 0) return {
138
+ state: "expired",
139
+ expiresAtMs: claims.exp * 1e3
140
+ };
141
+ return {
142
+ state: "signed-in",
143
+ credential: {
144
+ token,
145
+ claims,
146
+ updatedAtMs: typeof parsed.updatedAtMs === "number" ? parsed.updatedAtMs : void 0
147
+ }
148
+ };
149
+ }
150
+ /**
151
+ * Read the desktop app's credential.
152
+ *
153
+ * Every failure mode is a state rather than an exception: a user who has not
154
+ * installed or signed in yet is a normal condition the card renders, not an
155
+ * error the host logs on every start.
156
+ *
157
+ * @param env - Environment used to locate the data directory.
158
+ * @param now - Clock, injected so expiry is testable.
159
+ * @returns The usable credential, or why there is none.
160
+ */
161
+ async function readCredential(env = process.env, now = Date.now()) {
162
+ let text;
163
+ try {
164
+ text = await readFile(authPath(env), "utf8");
165
+ } catch {
166
+ return {
167
+ state: "signed-out",
168
+ reason: "app-missing"
169
+ };
170
+ }
171
+ return parseAuthDocument(text, now);
172
+ }
173
+ /**
174
+ * The bearer token for the current request, or `undefined`.
175
+ *
176
+ * pi-ai's anthropic-messages client sends the resolved `apiKey` as `x-api-key`,
177
+ * but MiniMax's gateway answers `{"code":401,"message":"token is required"}`
178
+ * unless an `Authorization: Bearer` header is also present. That header has to
179
+ * ride on the model descriptor ({@link Model.headers}), which is built inside
180
+ * the provider's synchronous `getModels()` — hence the synchronous read here.
181
+ * pi-ai calls `getModels()` once per operation, so the token is re-read per
182
+ * request and a re-login takes effect without a restart.
183
+ *
184
+ * @param env - Environment used to locate the data directory.
185
+ * @param now - Clock, injected so expiry is testable.
186
+ * @returns The token, or `undefined` when there is no usable one.
187
+ */
188
+ function bearerToken(env = process.env, now = Date.now()) {
189
+ let text;
190
+ try {
191
+ text = readFileSync(authPath(env), "utf8");
192
+ } catch {
193
+ return;
194
+ }
195
+ const state = parseAuthDocument(text, now);
196
+ return state.state === "signed-in" ? state.credential.token : void 0;
197
+ }
198
+ /**
199
+ * Inert pi-ai auth plane.
200
+ *
201
+ * The minimaxcode route authenticates only through `resolveApiKey`, which reads
202
+ * the desktop app's token per request, so pi-ai's own credential lifecycle and
203
+ * ambient discovery must never manufacture a credential for it — otherwise a
204
+ * stale or absent store would shadow the real token.
205
+ *
206
+ * `PiAiAdapterOptions.auth` is required and takes the pi-ai pair
207
+ * `{ credentials, authContext }`, not a request-shaped object; every ambient
208
+ * question here answers "nothing stored, nothing set", and a login is refused
209
+ * outright rather than silently accepted.
210
+ */
211
+ const INERT_AUTH = {
212
+ credentials: {
213
+ async read() {},
214
+ async list() {
215
+ return [];
216
+ },
217
+ async modify() {
218
+ throw new Error("dsh-connect-minimaxcode: the minimaxcode route has no pi-ai credential lifecycle");
219
+ },
220
+ async delete() {}
221
+ },
222
+ authContext: {
223
+ async env() {},
224
+ async fileExists() {
225
+ return false;
226
+ }
227
+ }
228
+ };
229
+ /** Provider id registered with DSH, distinct from the upstream one. */
230
+ const MINIMAXCODE_PROVIDER_ID = "minimaxcode";
231
+ /**
232
+ * Used when the catalog cannot be fetched, so the picker still offers the
233
+ * models the desktop app is known to ship.
234
+ *
235
+ * Deliberately small and conservative: a fallback entry overstates nothing we
236
+ * could not confirm.
237
+ */
238
+ const FALLBACK_MODELS = [{
239
+ id: "MiniMax-M2.7",
240
+ name: "M2.7",
241
+ contextWindow: 2e5,
242
+ maxOutputTokens: 128e3,
243
+ toolCall: true,
244
+ reasoning: true,
245
+ inputModalities: ["text"],
246
+ attachment: false
247
+ }, {
248
+ id: "MiniMax-M3",
249
+ name: "M3",
250
+ contextWindow: 512e3,
251
+ maxOutputTokens: 128e3,
252
+ toolCall: true,
253
+ reasoning: true,
254
+ inputModalities: [
255
+ "text",
256
+ "image",
257
+ "video"
258
+ ],
259
+ attachment: true
260
+ }];
261
+ /** Model with the same id, from a newer snapshot. */
262
+ function sameCatalog(a, b) {
263
+ if (a.length !== b.length) return false;
264
+ return a.every((model, index) => {
265
+ const other = b[index];
266
+ return model.id === other.id && model.contextWindow === other.contextWindow && model.toolCall === other.toolCall && model.reasoning === other.reasoning && model.attachment === other.attachment;
267
+ });
268
+ }
269
+ /** Keep only models whose id the upstream keys them by. */
270
+ function usable(id, raw) {
271
+ return id.length > 0 && id.startsWith("MiniMax-") && typeof raw === "object" && raw !== null;
272
+ }
273
+ /** Read the fields the upstream sends, tolerating their absence. */
274
+ function readModel(id, raw) {
275
+ const limit = raw.limit ?? {};
276
+ const modalities = raw.modalities ?? {};
277
+ const input = Array.isArray(modalities.input) ? modalities.input.filter((m) => typeof m === "string") : ["text"];
278
+ const contextWindow = typeof limit.context === "number" ? limit.context : 2e5;
279
+ return {
280
+ id,
281
+ name: typeof raw.name === "string" ? raw.name : id,
282
+ contextWindow,
283
+ maxOutputTokens: typeof limit.output === "number" ? limit.output : void 0,
284
+ toolCall: raw.tool_call === true,
285
+ reasoning: raw.reasoning === true,
286
+ inputModalities: input,
287
+ attachment: raw.attachment === true || input.includes("image")
288
+ };
289
+ }
290
+ /**
291
+ * Parse the upstream catalog document.
292
+ *
293
+ * Unknown providers are ignored rather than rejected: the endpoint serves every
294
+ * provider the desktop app is configured for, and only MiniMax models belong in
295
+ * this plugin's picker.
296
+ *
297
+ * @param document - Decoded response body.
298
+ * @returns Models in upstream order, or an empty list when none qualify.
299
+ */
300
+ function parseCatalog(document) {
301
+ if (typeof document !== "object" || document === null) return [];
302
+ const providers = document.providers;
303
+ if (!Array.isArray(providers)) return [];
304
+ const models = [];
305
+ for (const entry of providers) {
306
+ if (typeof entry !== "object" || entry === null) continue;
307
+ const provider = entry;
308
+ if (provider.providerId !== "minimax") continue;
309
+ const declared = (provider.config ?? {}).models;
310
+ if (typeof declared !== "object" || declared === null) continue;
311
+ for (const [id, raw] of Object.entries(declared)) {
312
+ if (!usable(id, raw)) continue;
313
+ models.push(readModel(id, raw));
314
+ }
315
+ }
316
+ return models;
317
+ }
318
+ /**
319
+ * Holds the catalog and answers the picker's questions about it.
320
+ *
321
+ * Refreshes are explicit: the plugin refreshes at startup and on demand, and
322
+ * keeps serving the last good snapshot while a refresh is in flight, so a
323
+ * network blip never empties the model list.
324
+ */
325
+ var MinimaxCatalog = class {
326
+ #snapshot = {
327
+ models: [...FALLBACK_MODELS],
328
+ source: "fallback"
329
+ };
330
+ #inflight;
331
+ #lastIdentity;
332
+ /** The models to serve right now. */
333
+ current() {
334
+ return this.#snapshot.models;
335
+ }
336
+ /** Provenance of {@link current}. */
337
+ get source() {
338
+ return this.#snapshot.source;
339
+ }
340
+ /** When {@link current} was fetched, when it came from upstream. */
341
+ get fetchedAtMs() {
342
+ return this.#snapshot.fetchedAtMs;
343
+ }
344
+ /** Why the last refresh failed, if one did. */
345
+ get error() {
346
+ return this.#snapshot.error;
347
+ }
348
+ /**
349
+ * Replace the served list, adopting upstream's own ordering.
350
+ *
351
+ * @returns Whether anything changed, so callers can skip a needless reload.
352
+ */
353
+ set(models, source, fetchedAtMs) {
354
+ if (source === this.#snapshot.source && sameCatalog(this.#snapshot.models, models)) {
355
+ if (source === "live") this.#snapshot.fetchedAtMs = fetchedAtMs ?? this.#snapshot.fetchedAtMs;
356
+ return false;
357
+ }
358
+ this.#snapshot = {
359
+ models: [...models],
360
+ source,
361
+ fetchedAtMs,
362
+ error: void 0
363
+ };
364
+ return true;
365
+ }
366
+ /** Drop back to the built-in list, e.g. when the account signs out. */
367
+ invalidate() {
368
+ this.#snapshot = {
369
+ models: [...FALLBACK_MODELS],
370
+ source: "fallback"
371
+ };
372
+ }
373
+ /**
374
+ * Fetch the live catalog for a credential.
375
+ *
376
+ * Serialised through a single in-flight promise: a second caller joins the
377
+ * first run rather than starting a duplicate request.
378
+ *
379
+ * @returns Whether the served list changed.
380
+ */
381
+ async refresh(region, credential, fetchImpl = fetch) {
382
+ const identity = credential.claims.userId ?? credential.claims.exp.toString();
383
+ if (this.#inflight !== void 0) {
384
+ await this.#inflight;
385
+ return false;
386
+ }
387
+ this.#lastIdentity = identity;
388
+ this.#inflight = (async () => {
389
+ try {
390
+ const response = await fetchImpl(catalogUrl(region), { headers: { authorization: `Bearer ${credential.token}` } });
391
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
392
+ const models = parseCatalog(await response.json());
393
+ if (models.length === 0) throw new Error("catalog contained no MiniMax models");
394
+ this.set(models, "live", Date.now());
395
+ } catch (error) {
396
+ this.#snapshot.error = error instanceof Error ? error.message : String(error);
397
+ if (this.#snapshot.source !== "live") this.set(FALLBACK_MODELS, "fallback");
398
+ }
399
+ })();
400
+ try {
401
+ await this.#inflight;
402
+ } finally {
403
+ this.#inflight = void 0;
404
+ }
405
+ return true;
406
+ }
407
+ /** Identity the served catalog belongs to, if any. */
408
+ get identity() {
409
+ return this.#lastIdentity;
410
+ }
411
+ };
412
+ //#endregion
413
+ //#region src/adapter.ts
414
+ /**
415
+ * Registering MiniMax Code's models as a DSH provider.
416
+ *
417
+ * The upstream endpoint is a standard Anthropic Messages API, so this plugin
418
+ * reuses the shared pi-ai adapter rather than implementing a transport: the
419
+ * work here is deciding *which* models to publish, pointing them at the right
420
+ * base URL, and keeping that in step with the live catalog.
421
+ *
422
+ * @module dsh-connect-minimaxcode/adapter
423
+ */
424
+ /** How long a silent stream may last before the transport gives up. */
425
+ const MINIMAX_STREAM_IDLE_TIMEOUT_MS = 6e5;
426
+ /**
427
+ * Adapter-owned defaults a resolved route must carry.
428
+ *
429
+ * `PiAiAdapterOptions.profiles` wants a *resolved* route, so the image bounds
430
+ * below are the schema's own defaults (20MiB base64 payload, 2048px pixel
431
+ * budget, 1MiB per inline version). The retry policy is resolved by the
432
+ * library's public helper rather than hand-written. Both were previously hidden
433
+ * behind a cast, which is also what hid the missing required `auth` option.
434
+ */
435
+ const REQUEST_IMAGE_BUDGETS = {
436
+ maxRequestImageBytes: 20971520,
437
+ requestImagePixelBudget: 4194304,
438
+ requestImageMaxBytes: 1048576
439
+ };
440
+ /** Unknown price, stated as zero rather than invented. */
441
+ const NO_COST = {
442
+ input: 0,
443
+ output: 0,
444
+ cacheRead: 0,
445
+ cacheWrite: 0
446
+ };
447
+ /**
448
+ * Translate one catalog entry into the descriptor pi-ai expects.
449
+ *
450
+ * Capabilities come from upstream rather than being assumed, so a model that
451
+ * loses tool support upstream stops advertising it here. `video` is dropped from
452
+ * the declared input modalities because pi-ai models images, not video.
453
+ *
454
+ * Two gateway quirks are encoded here, both measured against the live endpoint:
455
+ *
456
+ * - `authorization` is why this descriptor carries `headers` at all. pi-ai's
457
+ * anthropic client sends the resolved apiKey as `x-api-key`, and MiniMax
458
+ * rejects that alone with 401 "token is required"; it accepts both headers, so
459
+ * the bearer value is added alongside rather than replacing it.
460
+ * - `thinkingLevelMap.off = null` marks "thinking off" as unsupported. Every
461
+ * MiniMax model here requires reasoning, and pi-ai answers an explicit
462
+ * `thinkingEnabled: false` with `thinking: {type: "disabled"}`, which the
463
+ * gateway refuses with `400 ... requires adaptive thinking`. Marking the level
464
+ * unsupported makes pi-ai omit the field instead, which the gateway accepts
465
+ * (and, correctly, hides the option that cannot work).
466
+ *
467
+ * @param model - Catalog entry to describe.
468
+ * @param baseUrl - Region gateway base the anthropic client extends.
469
+ * @param authorization - Value for the `Authorization` header, when signed in.
470
+ * @returns The pi-ai model descriptor.
471
+ */
472
+ function toPiModel(model, baseUrl, authorization) {
473
+ const input = model.inputModalities.includes("image") ? ["text", "image"] : ["text"];
474
+ return {
475
+ id: model.id,
476
+ name: model.name,
477
+ api: "anthropic-messages",
478
+ provider: MINIMAXCODE_PROVIDER_ID,
479
+ baseUrl,
480
+ reasoning: model.reasoning,
481
+ input: [...input],
482
+ cost: { ...NO_COST },
483
+ contextWindow: model.contextWindow,
484
+ maxTokens: model.maxOutputTokens ?? 128e3,
485
+ ...authorization === void 0 ? {} : { headers: { authorization } },
486
+ ...model.reasoning ? { thinkingLevelMap: { off: null } } : {}
487
+ };
488
+ }
489
+ /**
490
+ * Build the DSH adapter that serves the catalog.
491
+ *
492
+ * The model list is rebuilt on demand rather than snapshotted, which is what
493
+ * lets a refresh upstream appear without restarting the host.
494
+ *
495
+ * @param options - Catalog, region and token resolution.
496
+ * @returns The adapter plus a way to drop its cached profiles.
497
+ */
498
+ function createMinimaxCodeAdapter(options) {
499
+ const { catalog } = options;
500
+ const buildModels = () => {
501
+ const token = options.bearerToken();
502
+ const authorization = token === void 0 ? void 0 : `Bearer ${token}`;
503
+ return catalog.current().map((model) => toPiModel(model, chatBaseUrl(options.region()), authorization));
504
+ };
505
+ const provider = {
506
+ ...createProvider({
507
+ id: MINIMAXCODE_PROVIDER_ID,
508
+ name: "MiniMax Code",
509
+ auth: { apiKey: {
510
+ name: "MiniMax Code access token",
511
+ async resolve({ credential }) {
512
+ const apiKey = credential?.key;
513
+ return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
514
+ auth: { apiKey },
515
+ source: "MiniMax Code"
516
+ };
517
+ }
518
+ } },
519
+ models: buildModels(),
520
+ api: anthropicMessagesApi()
521
+ }),
522
+ getModels: () => buildModels()
523
+ };
524
+ /**
525
+ * Build a fresh route map for the catalog and region of this moment.
526
+ *
527
+ * `getModels` already reads the live catalog on every call, so the model list
528
+ * needs no invalidation; what a refresh does invalidate is the per-model state
529
+ * carried by the route — configured maxTokens and recorded model errors, which
530
+ * would otherwise outlive a catalog or region change.
531
+ */
532
+ const buildProfiles = () => {
533
+ const route = {
534
+ provider: MINIMAXCODE_PROVIDER_ID,
535
+ displayName: "MiniMax Code",
536
+ streamIdleTimeoutMs: MINIMAX_STREAM_IDLE_TIMEOUT_MS,
537
+ retryPolicy: resolveRetryPolicy(void 0, "dsh-connect-minimaxcode retryPolicy"),
538
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
539
+ modelErrors: /* @__PURE__ */ new Map(),
540
+ ...REQUEST_IMAGE_BUDGETS,
541
+ piProvider: provider
542
+ };
543
+ return /* @__PURE__ */ new Map([[MINIMAXCODE_PROVIDER_ID, route]]);
544
+ };
545
+ let profiles = buildProfiles();
546
+ return {
547
+ adapter: new PiAiAdapter({
548
+ profiles: () => profiles,
549
+ resolveApiKey: options.resolveApiKey,
550
+ auth: INERT_AUTH
551
+ }),
552
+ invalidate: () => {
553
+ profiles = buildProfiles();
554
+ }
555
+ };
556
+ }
557
+ //#endregion
558
+ //#region src/status-route.ts
559
+ /** Route this plugin mounts, matching its settings card. */
560
+ const MINIMAX_STATUS_PATH = "/plugins/dsh-connect-minimaxcode/status";
561
+ const HINTS = {
562
+ "app-missing": "未找到 MiniMax Code 的登录信息。请先安装并登录 MiniMax Code 桌面 App,然后重启 DSH。",
563
+ "not-signed-in": "MiniMax Code 尚未登录。请在 MiniMax Code 桌面 App 中登录后重启 DSH。",
564
+ unreadable: "登录信息文件无法读取。请确认 MiniMax Code 仍处于登录状态。",
565
+ malformed: "登录令牌格式无法解析。请在 MiniMax Code 中重新登录后重启 DSH。"
566
+ };
567
+ /**
568
+ * Build the card's status document.
569
+ *
570
+ * The three non-signed-in states carry different remedies, and the difference
571
+ * is the whole point of the card: a user who has never installed the app needs
572
+ * a different next step than one whose token simply aged out.
573
+ */
574
+ function statusDocument(deps) {
575
+ const { auth } = deps;
576
+ const base = {
577
+ state: auth.state,
578
+ region: deps.region,
579
+ catalogSource: deps.catalogSource,
580
+ ...deps.catalogError === void 0 ? {} : { catalogError: deps.catalogError },
581
+ models: deps.models,
582
+ refreshing: deps.refreshing
583
+ };
584
+ if (auth.state === "expired") return {
585
+ ...base,
586
+ expiry: "expired",
587
+ daysRemaining: 0,
588
+ hint: "登录令牌已过期。请打开并登录 MiniMax Code 桌面 App 以获取新令牌,然后重启 DSH。"
589
+ };
590
+ if (auth.state === "signed-out") return {
591
+ ...base,
592
+ hint: HINTS[auth.reason ?? "not-signed-in"] ?? HINTS["not-signed-in"]
593
+ };
594
+ return {
595
+ ...base,
596
+ expiry: auth.expiry,
597
+ daysRemaining: auth.daysRemaining
598
+ };
599
+ }
600
+ /** Only loopback callers may read the document. */
601
+ function isLoopback(host) {
602
+ if (host === void 0) return false;
603
+ const name = host.replace(/:\d+$/, "");
604
+ return name === "127.0.0.1" || name === "localhost" || name === "[::1]" || name === "::1";
605
+ }
606
+ //#endregion
607
+ //#region src/index.ts
608
+ /** Loader entry name; also the id the bundle patch inserts. */
609
+ const name = "dsh-connect-minimaxcode";
610
+ /** Services this plugin needs before it can serve anything. */
611
+ const inject = ["llm"];
612
+ /** Which gateway a given account should be served from. */
613
+ function pickRegion(preferred) {
614
+ if (preferred !== void 0 && MINIMAX_REGIONS.includes(preferred)) return preferred;
615
+ return "cn";
616
+ }
617
+ /** How often the card may pull a fresh catalog. */
618
+ const CATALOG_TTL_MS = 3e5;
619
+ /**
620
+ * Compose the plugin.
621
+ *
622
+ * The credential is re-read on every request rather than cached at start: the
623
+ * desktop app refreshes its token independently, and a long-lived host would
624
+ * otherwise pin an expired one for the rest of the process.
625
+ *
626
+ * @param ctx - Service container.
627
+ */
628
+ function apply(ctx) {
629
+ const catalog = new MinimaxCatalog();
630
+ let region = "cn";
631
+ let credential;
632
+ let lastCatalogAtMs = 0;
633
+ let refreshing = false;
634
+ const readNow = async () => {
635
+ const state = await readCredential();
636
+ if (state.state !== "signed-in") {
637
+ credential = void 0;
638
+ catalog.invalidate();
639
+ return;
640
+ }
641
+ credential = state.credential;
642
+ return credential;
643
+ };
644
+ /** Refresh the catalog at most once per TTL, never blocking a request. */
645
+ const refreshCatalog = async (current) => {
646
+ if (refreshing || Date.now() - lastCatalogAtMs < CATALOG_TTL_MS) return;
647
+ refreshing = true;
648
+ try {
649
+ await catalog.refresh(region, current);
650
+ lastCatalogAtMs = Date.now();
651
+ } finally {
652
+ refreshing = false;
653
+ }
654
+ };
655
+ const { adapter, invalidate } = createMinimaxCodeAdapter({
656
+ catalog,
657
+ region: () => region,
658
+ resolveApiKey: async () => (await readNow())?.token,
659
+ bearerToken: () => bearerToken()
660
+ });
661
+ ctx.llm.registerAdapter(["minimaxcode"], adapter);
662
+ /** Build the document the card renders, without ever exposing the token. */
663
+ const buildStatus = async () => {
664
+ const state = await readCredential();
665
+ return statusDocument({
666
+ auth: state.state === "signed-in" ? {
667
+ state: "signed-in",
668
+ daysRemaining: Math.ceil(expiresInMs(state.credential.claims) / 864e5),
669
+ expiry: expiryLevel(state.credential.claims)
670
+ } : state.state === "expired" ? { state: "expired" } : {
671
+ state: "signed-out",
672
+ reason: state.reason
673
+ },
674
+ region,
675
+ catalogSource: catalog.source,
676
+ catalogError: catalog.error,
677
+ models: catalog.current().map(({ id, name: modelName, contextWindow }) => ({
678
+ id,
679
+ name: modelName,
680
+ contextWindow
681
+ })),
682
+ refreshing
683
+ });
684
+ };
685
+ (async () => {
686
+ const current = await readNow();
687
+ if (current !== void 0) {
688
+ region = pickRegion(void 0);
689
+ await refreshCatalog(current);
690
+ invalidate();
691
+ }
692
+ })();
693
+ ctx.inject(["webServer"], (webCtx) => {
694
+ webCtx.effect(() => webCtx.webServer.register({
695
+ kind: "exact",
696
+ path: MINIMAX_STATUS_PATH,
697
+ handler: async (req, res) => {
698
+ if (!isLoopback(req.headers.host)) {
699
+ res.writeHead(403, { "Content-Type": "application/json" });
700
+ res.end(JSON.stringify({ error: "loopback only" }));
701
+ return;
702
+ }
703
+ try {
704
+ res.writeHead(200, { "Content-Type": "application/json" });
705
+ res.end(JSON.stringify(await buildStatus()));
706
+ } catch (error) {
707
+ res.writeHead(500, { "Content-Type": "application/json" });
708
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
709
+ }
710
+ }
711
+ }));
712
+ });
713
+ }
714
+ //#endregion
715
+ export { FALLBACK_MODELS, MINIMAXCODE_PROVIDER_ID, MINIMAX_AUTH_FILENAME, MINIMAX_DATA_DIR, MINIMAX_GATEWAYS, MINIMAX_REGIONS, MINIMAX_STATUS_PATH, MINIMAX_STREAM_IDLE_TIMEOUT_MS, MinimaxCatalog, apply, authPath, bearerToken, catalogUrl, chatBaseUrl, createMinimaxCodeAdapter, dataDir, daysRemaining, decodeClaims, expiresInMs, expiryLevel, inject, name, parseAuthDocument, parseCatalog, readCredential, statusDocument };
package/package.json CHANGED
@@ -1,6 +1,80 @@
1
- {
2
- "name": "dsh-connect-minimaxcode",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
1
+ {
2
+ "name": "dsh-connect-minimaxcode",
3
+ "displayName": "DSH Connect MiniMax Code",
4
+ "description": "把本机已登录的 MiniMax Code 桌面 App 的模型接入 DeepSeek Harness:复用其登录态,提供 Anthropic Messages 兼容接入与动态模型目录。",
5
+ "version": "0.2.1",
6
+ "author": "JeffTsuiCUG",
7
+ "license": "MIT",
8
+ "private": false,
9
+ "publishConfig": {
10
+ "registry": "https://registry.npmjs.org",
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode.git"
16
+ },
17
+ "homepage": "https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/JeffTsuiCUG/dsh-connect-minimaxcode/issues"
20
+ },
21
+ "type": "module",
22
+ "engines": {
23
+ "node": "^22.19.0 || >=24.0.0"
24
+ },
25
+ "main": "lib/index.js",
26
+ "types": "lib/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./lib/index.d.ts",
30
+ "default": "./lib/index.js"
31
+ },
32
+ "./cordis.patch.yml": "./cordis.patch.yml",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "lib",
37
+ "cordis.patch.yml",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "prepack": "npm run build",
43
+ "build": "tsdown",
44
+ "test": "vitest run",
45
+ "typecheck": "tsc --noEmit -p tsconfig.json",
46
+ "check": "npm run typecheck && npm run test && npm run build"
47
+ },
48
+ "keywords": [
49
+ "dsh-plugin",
50
+ "deepseek-harness",
51
+ "minimax",
52
+ "minimax-code",
53
+ "model-provider"
54
+ ],
55
+ "peerDependencies": {
56
+ "@deepseek-ai/cordis": ">=4.0.4 <5.0.0",
57
+ "@deepseek-ai/dsh-atomic-write": ">=0.1.7-rc.1 <0.3.0-0",
58
+ "@deepseek-ai/dsh-home-paths": ">=0.1.7-rc.1 <0.3.0-0",
59
+ "@deepseek-ai/dsh-host-webserver": ">=0.1.7-rc.1 <0.3.0-0",
60
+ "@deepseek-ai/dsh-llm": ">=0.1.7-rc.1 <0.3.0-0",
61
+ "@deepseek-ai/dsh-llm-pi-ai": ">=0.1.7-rc.1 <0.3.0-0",
62
+ "@deepseek-ai/dsh-settings": ">=0.1.7-rc.1 <0.3.0-0",
63
+ "@deepseek-ai/schemastery": ">=3.18.4 <4.0.0",
64
+ "@earendil-works/pi-ai": ">=0.85.1"
65
+ },
66
+ "devDependencies": {
67
+ "@deepseek-ai/cordis": "^4.0.4",
68
+ "@deepseek-ai/schemastery": "3.18.4",
69
+ "@earendil-works/pi-ai": "^0.85.1",
70
+ "@types/node": "^22.20.0",
71
+ "tsdown": "^0.22.2",
72
+ "typescript": "^5.9.3",
73
+ "vitest": "^4.1.8"
74
+ },
75
+ "dsh": {
76
+ "bundle": {
77
+ "patch": "./cordis.patch.yml"
78
+ }
79
+ }
6
80
  }