magpie-ima-local-auth 0.1.1 → 0.2.0

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/AGENTS.md CHANGED
@@ -1,23 +1,24 @@
1
- # AGENTS.md —— 给维护者的入口
2
-
3
- 这个仓库是 `magpie-plugin-scaffold` 的派生插件。动手前先读:
4
-
5
- 1. `README.md` —— 用户看什么、支持哪些模型、怎么登录。
6
- 2. `docs/internals.md` —— 协议证据、IUA 的必要性、被否掉的方案。
7
- 3. `docs/evaluation.md` —— 反代价值评估与实测数字。
8
- 4. `node scripts/check.mjs` —— 合规定稿检查,退出码必须 0。
9
- 5. `node --test` —— 语义锁(bkn / 模型 / SSE / DSH 上下文过滤)。
10
-
11
- ## 会静默出错的四条
12
-
13
- | 症状 | 判据 | 出处 |
14
- |---|---|---|
15
- | 模型目录退回 DeepSeek-V4.1-Flash / Hy3 / Hy4 / GLM,问答 200 但没正文 | `x-ima-cookie` 必须带 `IMA-IUA`;`src/api.mjs` 的 `defaultIua()` 永远补 | `docs/internals.md` 第 3 节 |
16
- | 回复答非所问,像是在回答“技能目录” | DSH 把 AGENTS/技能目录作为 user 消息追加;`isContextMessage()` 必须把它们从问题/历史里滤掉 | `src/chat.mjs`、`docs/internals.md` |
17
- | 流式客户端报 `Stream ended without finish_reason` | `finish()` 必须先发带 `finish_reason:"stop"` 的帧,再置 `closed`;`frame()` 不能提前 return | `src/chat.mjs` |
18
- | 登录后模型 0 个 / 401 | 用 `importLocalLogin()` 的种子或 profile;`provider.models` 调用 `ensureFresh` 后续期 | `src/login.mjs`、`src/api.mjs` |
19
-
20
- ## 发版
21
-
22
- `scripts/release.mjs`(预演 → `--yes`),或按 scaffold 的 `UPDATING.md` 七步走。
23
- `git push` 只是第 4 步;npm publish、tag + GitHub Release、四条验证一个都不能少。
1
+ # AGENTS.md —— 给维护者的入口
2
+
3
+ 这个仓库是 `magpie-plugin-scaffold` 的派生插件。动手前先读:
4
+
5
+ 1. `README.md` —— 用户看什么、支持哪些模型、怎么登录。
6
+ 2. `docs/internals.md` —— 协议证据、IUA 的必要性、被否掉的方案。
7
+ 3. `docs/evaluation.md` —— 反代价值评估与实测数字。
8
+ 4. `node scripts/check.mjs` —— 合规定稿检查,退出码必须 0。
9
+ 5. `node --test` —— 语义锁(bkn / 模型 / SSE / DSH 上下文过滤)。
10
+
11
+ ## 会静默出错的四条
12
+
13
+ | 症状 | 判据 | 出处 |
14
+ |---|---|---|
15
+ | 模型目录退回 V3.2 / HY2.0,Copilot `scene:1` 只有默认模型 | `CLIENT-TYPE=256021` 指纹缺失;`src/api.mjs` 的 `cookieFor()` 默认带 | `docs/internals.md` 第 3 节 |
16
+ | 模型目录退回 V4.1 / Hy3 / Hy4 / GLM(匿名默认),问答 200 但没正文 | `IMA-IUA` 缺失;`defaultIua()` 永远补 | `docs/internals.md` 第 3 节 |
17
+ | 回复答非所问,像是在回答“技能目录” | DSH 把 AGENTS/技能目录作为 user 消息追加;`isContextMessage()` 必须把它们从问题/历史里滤掉 | `src/chat.mjs`、`docs/internals.md` |
18
+ | 流式客户端报 `Stream ended without finish_reason` | `finish()` 必须先发带 `finish_reason:"stop"` 的帧,再置 `closed`;`frame()` 不能提前 return | `src/chat.mjs` |
19
+ | 登录后模型 0 个 / 401 | 用 `importLocalLogin()` 的种子或 profile;`provider.models` 调用 `ensureFresh` 后续期 | `src/login.mjs`、`src/api.mjs` |
20
+
21
+ ## 发版
22
+
23
+ `scripts/release.mjs`(预演 → `--yes`),或按 scaffold 的 `UPDATING.md` 七步走。
24
+ `git push` 只是第 4 步;npm publish、tag + GitHub Release、四条验证一个都不能少。
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.2.0 — 2026-10-28
4
+
5
+ - **修复客户端指纹**:`x-ima-cookie` 的 `CLIENT-TYPE` 改为 ima 2.6.15 实际值 `256021`。没有它,服务端静默退回旧目录(DeepSeek V3.2 / HY 2.0,Copilot 只有默认模型);有了它,免费目录变为 **DeepSeek-V4.1-Flash / Hy3 / Hy4 preview / GLM-5.3-Flash(各带 Think 变体)**,Copilot `scene:1` 也能读到完整积分目录。
6
+ - 静态兜底模型同步为 V4.1-Flash / Hy3 / Hy4 preview / GLM-5.3-Flash。
7
+ - `scripts/import-cdp.mjs` 写入 `clientType`;`IMA_CLIENT_TYPE` 可覆盖指纹。
8
+ - README / docs 更正“V4.1 标签实际是 V3.2”的错误归因:那是旧 `CLIENT-TYPE` 造成的降级。
9
+
3
10
  ## 0.1.1 — 2026-10-28
4
11
 
5
12
  - 兼容 ima 客户端 UI 新版的 `STRUCTURED_BLOCK` 流事件(`Data.text_message` 正文 / `Data.thinking_message` 思考),与旧版 `MESSAGE` / `THINKING` 同时支持。
package/README.md CHANGED
@@ -28,16 +28,19 @@
28
28
 
29
29
  | 发行商 | 代表模型 | 思考 | 计费 |
30
30
  |---|---|---|---|
31
- | DeepSeek | `deepseek-v3.2` | — | 免费(知识库问答) |
32
- | DeepSeek | `deepseek-v3.2-think` | ✓ | 免费(知识库问答) |
33
- | Tencent | `tencent-hy-2.0` | — | 免费(知识库问答) |
34
- | Tencent | `tencent-hy-2.0-think` | ✓ | 免费(知识库问答) |
31
+ | DeepSeek | `deepseek-v4.1-flash` | 客户端可切快速/深度 | 免费(知识库问答) |
32
+ | Tencent | `hy3` | 同上 | 免费(知识库问答) |
33
+ | Tencent | `hy4-preview` | 同上 | 免费(知识库问答) |
34
+ | 智谱 | `glm-5.3-flash` | 同上 | 免费(知识库问答) |
35
35
 
36
- > 这里只列本机实测到的代表模型。真实目录由 `provider.models` 在登录后整份替换;
36
+ > 每个基础模型在 ima 目录里还带一个深度思考变体(`deepseek-v4.1-flash-think` 等);
37
+ > 插件会从 `sub_model_infos` 自动生成。真实目录由 `provider.models` 在登录后整份替换;
37
38
  > 装好以后 `magpie plugin list` / 模型选择器里看到的才是账号当前的全部。
38
39
 
39
- **Copilot 积分模型默认不列。** 本机 ima 2.6.15 对 Copilot 问答返回「当前版本暂不支持」;
40
- 更重要的是 Copilot 是 ima 的云端 agent,不是模型通道。需要积分模型时请用别的供应商。
40
+ **Copilot 积分模型默认不列。** 用 `CLIENT-TYPE=256021` 的客户端指纹可以读到完整 Copilot
41
+ 目录(Hy3 ×0.2、Hy4 preview ×0.9、DeepSeek-V4.1-Flash ×0.4 峰时、MiMo / GLM / MiniMax / Kimi…),
42
+ 但 Copilot 是 ima 的云端 agent(`robot_type=15`),不是模型通道;本插件按“不做云端 agent”的
43
+ 定位不接它。免费目录已经包含 V4.1-Flash / Hy3 / Hy4 / GLM-5.3,不需要为了这些模型开 Copilot。
41
44
 
42
45
  ---
43
46
 
@@ -7,28 +7,69 @@
7
7
 
8
8
  | 反代对象 | 价值 | 判定 |
9
9
  |---|---|---|
10
- | **免费知识库模型**(DeepSeek V3.2 / Tencent HY 2.0,含 Think 档) | **中(有其它免费源时低)** | 4 个模型可用、不扣算力、支持流式与思考;本机账号没有 HY3/HY4/GLM-5.3,面对 CB 免费 HY3 只剩“免费备胎”价值 |
11
- | **积分模型(Copilot)** | **0(在“不能是云端 agent”约束下)** | 本机 2.6.15 对 Copilot 问答直接返回 `{"Code":1,"Msg":"当前版本暂不支持"}`;即使别的版本支持,它也是 ima 自己的云端 agent(`robot_type=15`),正是 today 式结果的来源 |
10
+ | **免费模型**(DeepSeek-V4.1-Flash / Hy3 / Hy4 preview / GLM-5.3-Flash + 4 个 Think 变体) | **高** | 4 个基础模型 + 4 个思考变体、不扣算力、支持流式;质量已接近/覆盖 CodeBuddy 免费 HY3 这一档 |
11
+ | **Copilot 积分模型**(Hy3 / Hy4 / V4.1 / MiMo / GLM / MiniMax / Kimi…) | **模型能力高,但本插件不接** | Copilot 是 ima 的云端 agent(`robot_type=15` + `command_info.type=2000`),正是 today 式结果的来源;即使能拿到完整目录也默认不列 |
12
12
  | 知识库文件 / 文档管理 | 低(本次不做) | 是 ima 的知识库业务,不是模型;对 magpie 供应商没有直接价值 |
13
- | 图片生成 / 播客等 | 低(本次不做) | 与 magpie 的 chat 供应商无关;且需要单独的计费与权限 |
13
+ | 图片生成 / 播客等 | 低(本次不做) | 与 magpie 的 chat 供应商无关 |
14
14
 
15
- **一句话:这个软件的反代价值只在“免费模型”上,而且只是“多一个免费备胎”;积分模型不是模型通道,是云端 agent,不值得为它牺牲模型级体验。若已有 CB 免费 HY3,本插件的边际价值很低。**
15
+ **一句话:免费模型这一层的价值是真实的(V4.1-Flash / Hy3 / Hy4 / GLM-5.3 免费);
16
+ Copilot 积分模型能力更强,但它是云端 agent,按“不能是云端 agent”的约束不接。**
17
+
18
+ ## 关键发现:`CLIENT-TYPE=256021` 是指纹钥匙
19
+
20
+ 同一账号、同一 token,只改 `x-ima-cookie` 里的 `CLIENT-TYPE`,`model_manage/get_models`
21
+ 返回的目录完全不同:
22
+
23
+ | `CLIENT-TYPE` | `{}`(免费目录) | `{"scene":1}`(Copilot 目录) |
24
+ |---|---|---|
25
+ | `windows` | DeepSeek V3.2 / V3.2 Think / Tencent HY 2.0 / HY 2.0 Think | 只有 `默认模型 ×1.0` |
26
+ | **`256021`**(客户端实际值) | **DeepSeek-V4.1-Flash / Hy3 / Hy4 preview / GLM-5.3-Flash** | **完整:Hy3 ×0.2、Hy4 preview ×0.9、DeepSeek-V4.1-Flash ×0.4 峰时、Xiaomi MiMo、GLM-5.3、MiniMax-M3、Kimi…共 12 个** |
27
+
28
+ 这正对应“像超级麦吉要组织指纹 key”的猜测:ima 的客户端类型/指纹就是 `CLIENT-TYPE=256021`。
29
+ 没有它,服务端不报错,只会退回旧目录,Copilot 也只给默认模型——这就是之前
30
+ 「本机 Copilot 暂不支持 / 模型只有 V3.2」现象的根因。
31
+
32
+ > 2026-10-28 从 ima 2.6.15 客户端真实请求头抓包得到;不同客户端版本可能变化,
33
+ > 可用 `IMA_CLIENT_TYPE` 覆盖。
16
34
 
17
35
  ## 实测证据
18
36
 
19
- ### 免费模型目录(`POST /cgi-bin/model_manage/get_models`,body `{}`)
37
+ ### 免费模型目录(`POST /cgi-bin/model_manage/get_models`,body `{}`,`CLIENT-TYPE=256021`)
20
38
 
21
39
  | model_type | model_id | 显示名 | 倍率字段 |
22
40
  |---|---|---|---|
23
- | 3 | official_3 | DeepSeek V3.2 | 空 |
24
- | 1 | official_1 | DeepSeek V3.2 Think | 空 |
25
- | 0 | official_0 | Tencent HY 2.0 | 空 |
26
- | 2 | official_2 | Tencent HY 2.0 Think | 空 |
41
+ | 3 | official_3 | DeepSeek-V4.1-Flash | 空 |
42
+ | 1 | official_1 | DeepSeek-V4.1-Flash Think | 空 |
43
+ | 0 | official_0 | Hy3 | 空 |
44
+ | 2 | official_2 | Hy3 Think | 空 |
45
+ | 1001 | official_1001 | Hy4 preview | 空 |
46
+ | 1002 | official_1002 | Hy4 preview Think | 空 |
47
+ | 3000 | official_3000 | GLM-5.3-Flash | 空 |
48
+ | 3001 | official_3001 | GLM-5.3-Flash Think | 空 |
49
+
50
+ `model_token_copywriting` 为空 = 不扣算力。实测问答后 `token_manager/get_balance`
51
+ 仍为 500。
52
+
53
+ ### Copilot 目录(`{"scene":1}`,`CLIENT-TYPE=256021`)
27
54
 
28
- `model_token_copywriting` 为空 = 知识库问答不扣算力。连续多次 `assistant/qa` 后
29
- `token_manager/get_balance` 仍为 500,说明免费不是“暂时没扣”。
55
+ `自动选择最优模型 ×1.0`、`Hy3 ×0.2`、`Hy4 preview ×0.9`、`DeepSeek-V4.1-Flash ×0.4 峰时`、
56
+ `Xiaomi MiMo-V2.6-Flash ×0.2`、`Xiaomi MiMo-V2.6-Pro ×0.6`、`GLM-5.3-Flash ×0.2`、
57
+ `GLM-5.3 ×2.2`、`MiniMax-M3.1-Flash ×0.2`、`MiniMax-M3 ×0.6`、
58
+ `Kimi-K2.8-Preview ×1.9`、`Kimi-K3.1-Pro ×3.8`。
30
59
 
31
- ### 问答
60
+ Copilot 的实际请求(抓包):
61
+
62
+ ```
63
+ POST /cgi-bin/assistant/qa
64
+ {"robot_type":15,"command_info":{"type":2000,"copilot_qa_info":{}},
65
+ "model_info":{"model_id":"official_paid_ep-i2azx8ko","model_type":110000,"enable_enhancement":false}}
66
+ → SSE STRUCTURED_BLOCK(blockThinking / text_message)
67
+ ```
68
+
69
+ DeepSeek-V4.1-Flash 在 Copilot 里是真实模型(`official_paid_ep-i2azx8ko` / type 110000),
70
+ `STRUCTURED_BLOCK` 里能看到流式思考;但它始终是 Copilot agent。
71
+
72
+ ### 问答与流式
32
73
 
33
74
  ```
34
75
  POST /cgi-bin/session_logic/init_session
@@ -38,12 +79,14 @@ POST /cgi-bin/session_logic/init_session
38
79
  POST /cgi-bin/assistant/qa (SSE)
39
80
  {"session_id":"…","robot_type":10000,"question":"…","question_type":2,
40
81
  "command_info":{"question_info":{}},"model_info":{"model_type":3,"model_id":"official_3"}}
41
- → MESSAGE {"Text":"DSH-IMA-OK"} … COMPLETED {"Code":0}
82
+ → MESSAGE {"Text":"…"} / STRUCTURED_BLOCK {"Data":{"text_message":{"Text":"…"}}}
83
+ → COMPLETED {"Code":0}
42
84
  ```
43
85
 
44
86
  - 多轮:同一 session 第一问“记住 7391”、第二问“刚才的数字”返回 7391。
45
- - 长回答:9 个 `MESSAGE` 增量拼出完整 1..20。
46
- - 思考模型:`THINKING {"Message":"…","Status":1}` 增量;最后一条 `Status:2` 是结束标记。
87
+ - 长回答:多个增量事件拼出完整 1..20。
88
+ - 思考:`THINKING {"Message":"…","Status":1}` / `STRUCTURED_BLOCK.blockThinking` 增量;
89
+ 最后一条 `Status:2` 是结束标记。
47
90
  - `SUGGEST_QUESTION` 是建议列表,不是答案。
48
91
 
49
92
  ### 登录态
@@ -53,44 +96,23 @@ profile 的 `tencent.wxlogin.account_secret_encrypted`(DPAPI)。插件读后
53
96
  不开 CDP、不要求粘贴 cookie。读到的 `token` 约 2 小时,`refresh_token` 约一年;
54
97
  `/auth_login/refresh` 实测可换新 token(返回相对 7200 秒)。
55
98
 
56
- ### IUA 的必要性
57
-
58
- `x-ima-cookie` 里少了 `IMA-IUA`,服务端不报错,但会把请求当匿名:
59
- 模型目录退回默认的 DeepSeek-V4.1-Flash / Hy3 / Hy4 / GLM,`assistant/qa` 返回 200
60
- 却没有正文。补一个最小 IUA
61
- (`PR=IMA&PP=com.tencent.imawin&PPVN=<版本>&PL=WIN&COVC=<Chromium 版本>&EV=`)
62
- 就按账号目录回答。所以插件永远补 IUA。
63
-
64
- ## UI 标签 vs 账号目录(2026-10-28 抓包)
99
+ ## UI 标签 vs 账号目录(修正)
65
100
 
66
- 在 ima 2.6.15 客户端里选中 **DeepSeek-V4.1-Flash**(选项带 `_selected`),抓到的
67
- `POST /cgi-bin/assistant/qa` 请求是:
101
+ 之前用 `CLIENT-TYPE=windows` 抓包时,UI 选 V4.1-Flash 发出的 `official_3` 被账号目录标成
102
+ V3.2;把客户端真实的 `CLIENT-TYPE=256021` 带上后,同一 `official_3` 的目录名是
103
+ **DeepSeek-V4.1-Flash**。所以:
68
104
 
69
- | UI 模式 | 请求 `model_info` |
70
- |---|---|
71
- | 快速 | `{"model_type":3,"model_id":"official_3","enable_enhancement":true}` |
72
- | 深度 | `{"model_type":1,"model_id":"official_1","enable_enhancement":true}` |
73
-
74
- 同一账号的 `/model_manage/get_models` 把 `official_3` 标成 **DeepSeek V3.2**,把
75
- `official_1` 标成 **DeepSeek V3.2 Think**。直接发 `official_3` 时 `QA_START` 自报
76
- `"model_name":"DeepSeek V3.2"`。
77
-
78
- **结论:这个账号的 UI 标签 “DeepSeek-V4.1-Flash” 实际落在 V3.2 的槽位上;UI 的默认模型
79
- 列表和账号目录不一致。** 不同账号 / 客户端版本可能不同,不能拿 UI 标签当模型身份。
105
+ - **UI 的 V4.1-Flash 是真 V4.1**;之前的“背后是 V3.2”是**我们的请求缺客户端指纹**造成的
106
+ 服务端降级,不是 UI 造假。
107
+ - Copilot 的 V4.1(`official_paid_ep-i2azx8ko`)也是真 V4.1;两条通道不同,但都真实存在。
80
108
 
81
109
  ## 与 CodeBuddy / 其它免费源的对比
82
110
 
83
- 腾讯 CodeBuddy(CB)已经免费提供 HY3;本机 ima 账号的免费目录只有 DeepSeek V3.2 / Tencent HY 2.0,
84
- 档位明显更旧。把两者放在一起:
85
-
86
111
  | 免费源 | 档位 | 接入成本 | 结论 |
87
112
  |---|---|---|---|
88
- | CodeBuddy 免费 HY3 | HY3(更新) | 现成插件/账号 | 优先 |
89
- | 本机 ima 账号 | DeepSeek V3.2 / HY 2.0 | 本插件 + 本机客户端 | 备胎 |
90
- | 其它 ima 账号(可能) | V4.1-Flash / Hy4 / GLM-5.3 | 同一插件动态目录 | 取决于账号 |
91
-
92
- **所以“免费”本身不是护城河。** 只有当某个 ima 账号的目录里有别人没有的模型,
93
- 或者需要一个独立于 CB 的备用账号时,这个插件才有超出“技术演示”的价值。
113
+ | CodeBuddy 免费 HY3 | HY3 | 现成插件/账号 | 可作主力免费源 |
114
+ | ima 免费目录(本插件) | V4.1-Flash / Hy3 / Hy4 preview / GLM-5.3-Flash | 本插件 + 本机客户端 | **同档,多模型;有真实价值** |
115
+ | ima Copilot(积分) | V4.1 / Hy3 / Hy4 / MiMo / GLM / MiniMax / Kimi | 需要开 Copilot agent | 能力更高,但 agent 约束下不接 |
94
116
 
95
117
  ## 与现有实现的关系
96
118
 
@@ -98,26 +120,26 @@ profile 的 `tencent.wxlogin.account_secret_encrypted`(DPAPI)。插件读后
98
120
  |---|---|---|
99
121
  | `magpie-ima-auth`(npm) | 浏览器 cookie → 云端 `assistant/qa` | 登录要手工复制 `x-ima-cookie`;本质是把 ima 的云端 agent 会话包装成 chat(单问、无工具、system prompt 受限) |
100
122
  | `opencli-plugin-ima` | 直接 API + WebContents/UI | 主要面向知识库问答;Windows 直接 API 遇到 600001;需要客户端/UI 自动化 |
101
- | 本插件 | 本机客户端 profile → DPAPI 登录态 → 伪装请求头 | 不粘贴 cookie、运行时不需要 CDP;只走 `robot_type=10000` 的普通问答;模型目录/流式/思考/续期都按模型供应商做 |
123
+ | 本插件 | 本机客户端 profile → DPAPI 登录态 → 伪装请求头(含 `CLIENT-TYPE=256021`) | 不粘贴 cookie、运行时不需要 CDP;只走 `robot_type=10000` 的普通问答;模型目录/流式/思考/续期都按模型供应商做 |
102
124
 
103
125
  ## 限制(如实写)
104
126
 
105
- - **单问 session**:ima 的 `assistant/qa` 一次只收一个问题,没有 messages 数组;插件用
106
- session 保持多轮,但 system prompt 只在 session 第一问里发。
127
+ - **单问 session**:`assistant/qa` 一次只收一个问题,没有 messages 数组;插件用 session
128
+ 保持多轮,但 system prompt 只在 session 第一问里发。
107
129
  - **没有工具调用**:ima 的问答接口没有 tool 通道;模型条目如实声明 `toolcall: false`。
108
130
  - **没有 token 计数**:ima 不返回 usage;插件不按字符数编,返回 0。
109
131
  - **没有上下文窗口**:模型目录不下发;`limit.context` 留 0,交给 magpie 的模型库。
110
- - **私有接口**:随时可能变;ima 客户端升级后 IUA/字段可能需要跟着调。
132
+ - **私有接口 + 指纹**:`CLIENT-TYPE=256021` 是抓包值,客户端升级可能变;可用
133
+ `IMA_CLIENT_TYPE` 覆盖。API 本身也可能随时变。
111
134
  - **登录导入平台**:DPAPI 解密目前只做 Windows;macOS 用种子文件 / `IMA_COPILOT_LOGIN_FILE`。
112
- - **Copilot 不可用**:本机版本直接“暂不支持”,且它是 agent;插件默认不列。
135
+ - **Copilot 不进默认目录**:它可用、流式思考也正常,但它是 agent,不是模型通道。
113
136
 
114
137
  ## 已完成的验收
115
138
 
116
139
  - `node scripts/check.mjs`:通过 29 · 警告 0 · 失败 0
117
140
  - `node --test`:10/10
118
- - magpie 0.1.1814 安装后 `ima (ima, 4 models) signed in as 001ab…`
119
- - 网关:`POST /v1/chat/completions`(`ima/deepseek-v3.2`)返回 `GATEWAY-OK`;
120
- 流式返回 role/content/`finish_reason:"stop"`;`ima/deepseek-v3.2-think` 返回
121
- `reasoning_content`。
122
- - DSH headless(`dsh --profile headless` + patch,模型 `ima/tencent-hy-2.0`)返回
123
- `DSH-IMA-OK`。
141
+ - magpie 0.1.1814 安装后 `ima (ima, 8 models) signed in as 001ab…`
142
+ - 网关:`POST /v1/chat/completions`(`ima/deepseek-v4.1-flash`)返回 `GATEWAY-V41`;
143
+ 流式返回 role/content/`finish_reason:"stop"`;思考模型返回 `reasoning_content`。
144
+ - DSH headless(`dsh --profile headless` + patch,模型 `ima/hy3` 或 `ima/deepseek-v4.1-flash`)
145
+ 返回 `DSH-IMA-OK`。
package/docs/internals.md CHANGED
@@ -42,7 +42,7 @@ ima 的 token 有两份:
42
42
  实测(2026-10-28,ima 2.6.15.5280 / Chromium 150.0.7871.5469):
43
43
 
44
44
  ```
45
- x-ima-cookie: PLATFORM=H5; CLIENT-TYPE=windows; WEB-VERSION=5.16.12;
45
+ x-ima-cookie: PLATFORM=H5; CLIENT-TYPE=256021; WEB-VERSION=5.16.12;
46
46
  IMA-GUID=...; IMA-UID=...; IMA-TOKEN=...; IMA-REFRESH-TOKEN=...;
47
47
  UID-TYPE=2; TOKEN-TYPE=14; IMA-IUA=PR=IMA&PP=com.tencent.imawin&PPVN=...
48
48
  from_browser_ima: 1
@@ -50,7 +50,12 @@ extension_version: 5.16.12
50
50
  x-ima-bkn: djb2(IMA-TOKEN) & 0x7fffffff
51
51
  ```
52
52
 
53
- **`IMA-IUA` 是关键。** 少了它,服务端不会报错,只会把整套请求当成匿名:
53
+ **`CLIENT-TYPE=256021` 是指纹钥匙。** 它是 ima 2.6.15 客户端抓包得到的客户端类型;
54
+ 用它 `model_manage/get_models` 才会返回账号当前目录(V4.1-Flash / Hy3 / Hy4 / GLM-5.3),
55
+ `{"scene":1}` 才会返回完整 Copilot 目录。用 `windows` 会静默退回旧目录
56
+ (V3.2 / HY2.0,Copilot 只有默认模型)。可用 `IMA_CLIENT_TYPE` 覆盖。
57
+
58
+ **`IMA-IUA` 也关键。** 少了它,服务端不会报错,只会把整套请求当成匿名:
54
59
  `model_manage/get_models` 返回默认目录(DeepSeek-V4.1-Flash / Hy3 / Hy4 / GLM),
55
60
  `assistant/qa` 返回 200 但没有 `MESSAGE` 正文。实测只要 IUA 以
56
61
  `PR=IMA&PP=com.tencent.imawin&PPVN=<版本>&PL=WIN&COVC=<Chromium 版本>&EV=` 的形式存在,
@@ -82,14 +87,15 @@ HEARTBEAT
82
87
 
83
88
  | model_type | model_id | 名字 | model_token_copywriting |
84
89
  |---|---|---|---|
85
- | 3 | official_3 | DeepSeek V3.2 | 空 |
86
- | 1 | official_1 | DeepSeek V3.2 Think | 空 |
87
- | 0 | official_0 | Tencent HY 2.0 | 空 |
88
- | 2 | official_2 | Tencent HY 2.0 Think | 空 |
90
+ | 3 | official_3 | DeepSeek-V4.1-Flash | 空 |
91
+ | 0 | official_0 | Hy3 | 空 |
92
+ | 1001 | official_1001 | Hy4 preview | 空 |
93
+ | 3000 | official_3000 | GLM-5.3-Flash | 空 |
89
94
 
90
95
  `model_token_copywriting` 为空 = 不扣算力,插件标 `free: true`;`×1.0` 之类是积分模型,
91
- 插件标 `rate`。Copilot 的 `scene:1` 在本机只返回「默认模型 ×1.0」,且 `assistant/qa`
92
- (robot 15)返回 `{"Code":1,"Msg":"当前版本暂不支持"}`;所以默认不列 Copilot。
96
+ 插件标 `rate`。`CLIENT-TYPE=256021` 下 `scene:1` 返回完整 Copilot 目录(12 个,含
97
+ V4.1-Flash `official_paid_ep-i2azx8ko`);但 Copilot 走 `robot_type=15`,是 ima 的云端
98
+ agent,所以默认不列。
93
99
 
94
100
  ## 6. 被否掉的方案
95
101
 
@@ -99,16 +105,18 @@ HEARTBEAT
99
105
  实例是否拿到登录态不稳定;每次使用还要拉起/重启客户端。不用。
100
106
  - **扫描客户端进程内存**:能找到字符串常量,但 token 不在可检索的明文里;且需要
101
107
  ReadProcessMemory 权限,跨版本极脆。不用。
102
- - **Copilot 积分模型**:本机版本直接「暂不支持」,且它是 agent 通道(today 式结果)。
103
- 默认不列;代码保留了 `robot_type` 参数,但本版本没有开启。
108
+ - **Copilot 积分模型**:`CLIENT-TYPE=256021` 下已确认可用(V4.1-Flash / Hy3 / Hy4 / MiMo /
109
+ GLM / MiniMax / Kimi,SSE `STRUCTURED_BLOCK` 流式思考)。但它是 agent 通道
110
+ (`robot_type=15` + `command_info.type=2000`,today 式结果),按当前约束默认不接。
104
111
 
105
112
  ## 7. 已验证
106
113
 
107
114
  2026-10-28,本机 ima 2.6.15.5280:
108
115
 
109
- - 从 profile 导入登录态 → `model_manage/get_models` 返回 4 个免费模型
110
- - `assistant/qa`(robot 10000, model_type=3)返回 `PLUGIN-OK` / `FACTORY-OK` / `IUA-OK`
111
- - `THINKING` 事件进 `reasoning_content`;`COMPLETED Code 0` 正常结束
116
+ - 从 profile 导入登录态 → `CLIENT-TYPE=256021` 下 `model_manage/get_models` 返回
117
+ V4.1-Flash / Hy3 / Hy4 preview / GLM-5.3-Flash 四个免费模型
118
+ - `assistant/qa`(robot 10000, model_type=3)返回 `V41-OK`
119
+ - `THINKING` / `STRUCTURED_BLOCK.blockThinking` 进 `reasoning_content`;`COMPLETED Code 0` 正常结束
112
120
  - `token_manager/get_balance` 返回算力余额
113
121
  - `auth.refresh` 成功换到新 access token(`token_valid_time` 为相对 7200 秒)
114
122
  - 插件 factory 的 loader 流式/非流式都返回 200
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-ima-local-auth",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "magpie / OpenCode provider plugin for the ima.copilot desktop app: its free DeepSeek and Tencent HY models, imported from the client already signed in on this machine, without pasting cookies.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -1,113 +1,115 @@
1
- #!/usr/bin/env node
2
- // 从正在运行的 ima.copilot(带 CDP 端口)导入登录态 —— CDP + native bridge 路径。
3
- //
4
- // node scripts/import-cdp.mjs --port 9222
5
- //
6
- // 它做的事:
7
- // 1. 连 http://127.0.0.1:<port>/json,找 chrome://home/ 页面
8
- // 2. 在该页面里执行 chrome.imaFrame.invokeWithCallback({action:'getAccountInfo'})
9
- // 和 getDeviceInfo —— 和 ima 官方前端完全同一条 native bridge
10
- // 3. 把 token / refresh_token / uid / guid / QUA 写成种子文件
11
- //
12
- // 这个脚本用于:profile 里还没有 tencent.wxlogin 凭证,但客户端正开着调试端口。
13
- // 只打印账号与有效期,不打印 token。
14
-
15
- import { saveSeed, SEED_FILE } from "../src/login.mjs"
16
-
17
- const argv = process.argv.slice(2)
18
- const get = (name, fallback = "") => {
19
- const i = argv.indexOf(name)
20
- return i >= 0 ? argv[i + 1] : fallback
21
- }
22
- const port = Number(get("--port", "9222"))
23
- if (!Number.isFinite(port) || port <= 0) {
24
- console.error("用法:node scripts/import-cdp.mjs --port 9222")
25
- process.exit(1)
26
- }
27
-
28
- const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
29
- const target = targets.find((t) => t.url === "chrome://home/") || targets.find((t) => t.type === "page")
30
- if (!target?.webSocketDebuggerUrl) {
31
- console.error(`端口 ${port} 上没有 ima 的 chrome://home/ 页面。确认客户端用 --remote-debugging-port=${port} 启动。`)
32
- process.exit(1)
33
- }
34
-
35
- const ws = new WebSocket(target.webSocketDebuggerUrl)
36
- let id = 0
37
- const pending = new Map()
38
- ws.onmessage = (e) => {
39
- const m = JSON.parse(e.data)
40
- if (m.id && pending.has(m.id)) {
41
- pending.get(m.id)(m)
42
- pending.delete(m.id)
43
- }
44
- }
45
- await new Promise((res, rej) => {
46
- ws.onopen = res
47
- ws.onerror = rej
48
- })
49
- const cmd = (method, params = {}) =>
50
- new Promise((res) => {
51
- const i = ++id
52
- pending.set(i, res)
53
- ws.send(JSON.stringify({ id: i, method, params }))
54
- })
55
- const evaluate = async (expression) => {
56
- const r = await cmd("Runtime.evaluate", {
57
- expression,
58
- returnByValue: true,
59
- awaitPromise: true,
60
- userGesture: true,
61
- })
62
- if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
63
- return r.result?.result?.value
64
- }
65
-
66
- const raw = await evaluate(`(async () => {
67
- const invoke = (action, params = {}) => new Promise((resolve) => {
68
- const bridge = globalThis.chrome && globalThis.chrome.imaFrame
69
- if (!bridge || !bridge.invokeWithCallback) return resolve({ code: -1, msg: "imaFrame unavailable", data: "" })
70
- const timer = setTimeout(() => resolve({ code: 996, msg: "timeout", data: "" }), 10000)
71
- bridge.invokeWithCallback({ action, params: JSON.stringify(params) }, (r) => {
72
- clearTimeout(timer)
73
- resolve(r || {})
74
- })
75
- })
76
- const parse = (r) => { try { return typeof r.data === "string" ? JSON.parse(r.data || "{}") : (r.data || {}) } catch { return {} } }
77
- const account = parse(await invoke("getAccountInfo"))
78
- const device = parse(await invoke("getDeviceInfo"))
79
- const version = chrome.runtime.getManifest().version
80
- return JSON.stringify({ account, device, version })
81
- })()`)
82
- ws.close()
83
-
84
- const { account, device, version } = JSON.parse(raw)
85
- if (!account?.is_login || !account?.token) {
86
- console.error("这个 ima.copilot 页面没有登录:先在客户端里登录,再重试。")
87
- process.exit(1)
88
- }
89
- const state = {
90
- access: account.token,
91
- refresh: account.refresh_token,
92
- expires: Number(account.token_valid_time) * 1000,
93
- uid: account.user_id,
94
- idType: String(account.id_type ?? ""),
95
- tokenType: String(account.token_type ?? ""),
96
- guid: device?.guid || "",
97
- q36: device?.q36 || "",
98
- iua: device?.qua || "",
99
- extensionVersion: version,
100
- accountId: account?.user_info?.open_info?.nickname || "ima.copilot",
101
- nickname: account?.user_info?.open_info?.nickname || "",
102
- source: `cdp:${port}`,
103
- }
104
- const ok = saveSeed(state)
105
- console.log(JSON.stringify({
106
- imported: true,
107
- seed: SEED_FILE,
108
- saved: ok,
109
- uid: state.uid,
110
- account: state.accountId,
111
- expiresAt: new Date(state.expires).toISOString(),
112
- hasRefresh: Boolean(state.refresh),
113
- }, null, 2))
1
+ #!/usr/bin/env node
2
+ // 从正在运行的 ima.copilot(带 CDP 端口)导入登录态 —— CDP + native bridge 路径。
3
+ //
4
+ // node scripts/import-cdp.mjs --port 9222
5
+ //
6
+ // 它做的事:
7
+ // 1. 连 http://127.0.0.1:<port>/json,找 chrome://home/ 页面
8
+ // 2. 在该页面里执行 chrome.imaFrame.invokeWithCallback({action:'getAccountInfo'})
9
+ // 和 getDeviceInfo —— 和 ima 官方前端完全同一条 native bridge
10
+ // 3. 把 token / refresh_token / uid / guid / QUA 写成种子文件
11
+ //
12
+ // 这个脚本用于:profile 里还没有 tencent.wxlogin 凭证,但客户端正开着调试端口。
13
+ // 只打印账号与有效期,不打印 token。
14
+
15
+ import { saveSeed, SEED_FILE } from "../src/login.mjs"
16
+ import { CLIENT_TYPE } from "../src/context.mjs"
17
+
18
+ const argv = process.argv.slice(2)
19
+ const get = (name, fallback = "") => {
20
+ const i = argv.indexOf(name)
21
+ return i >= 0 ? argv[i + 1] : fallback
22
+ }
23
+ const port = Number(get("--port", "9222"))
24
+ if (!Number.isFinite(port) || port <= 0) {
25
+ console.error("用法:node scripts/import-cdp.mjs --port 9222")
26
+ process.exit(1)
27
+ }
28
+
29
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
30
+ const target = targets.find((t) => t.url === "chrome://home/") || targets.find((t) => t.type === "page")
31
+ if (!target?.webSocketDebuggerUrl) {
32
+ console.error(`端口 ${port} 上没有 ima 的 chrome://home/ 页面。确认客户端用 --remote-debugging-port=${port} 启动。`)
33
+ process.exit(1)
34
+ }
35
+
36
+ const ws = new WebSocket(target.webSocketDebuggerUrl)
37
+ let id = 0
38
+ const pending = new Map()
39
+ ws.onmessage = (e) => {
40
+ const m = JSON.parse(e.data)
41
+ if (m.id && pending.has(m.id)) {
42
+ pending.get(m.id)(m)
43
+ pending.delete(m.id)
44
+ }
45
+ }
46
+ await new Promise((res, rej) => {
47
+ ws.onopen = res
48
+ ws.onerror = rej
49
+ })
50
+ const cmd = (method, params = {}) =>
51
+ new Promise((res) => {
52
+ const i = ++id
53
+ pending.set(i, res)
54
+ ws.send(JSON.stringify({ id: i, method, params }))
55
+ })
56
+ const evaluate = async (expression) => {
57
+ const r = await cmd("Runtime.evaluate", {
58
+ expression,
59
+ returnByValue: true,
60
+ awaitPromise: true,
61
+ userGesture: true,
62
+ })
63
+ if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
64
+ return r.result?.result?.value
65
+ }
66
+
67
+ const raw = await evaluate(`(async () => {
68
+ const invoke = (action, params = {}) => new Promise((resolve) => {
69
+ const bridge = globalThis.chrome && globalThis.chrome.imaFrame
70
+ if (!bridge || !bridge.invokeWithCallback) return resolve({ code: -1, msg: "imaFrame unavailable", data: "" })
71
+ const timer = setTimeout(() => resolve({ code: 996, msg: "timeout", data: "" }), 10000)
72
+ bridge.invokeWithCallback({ action, params: JSON.stringify(params) }, (r) => {
73
+ clearTimeout(timer)
74
+ resolve(r || {})
75
+ })
76
+ })
77
+ const parse = (r) => { try { return typeof r.data === "string" ? JSON.parse(r.data || "{}") : (r.data || {}) } catch { return {} } }
78
+ const account = parse(await invoke("getAccountInfo"))
79
+ const device = parse(await invoke("getDeviceInfo"))
80
+ const version = chrome.runtime.getManifest().version
81
+ return JSON.stringify({ account, device, version })
82
+ })()`)
83
+ ws.close()
84
+
85
+ const { account, device, version } = JSON.parse(raw)
86
+ if (!account?.is_login || !account?.token) {
87
+ console.error("这个 ima.copilot 页面没有登录:先在客户端里登录,再重试。")
88
+ process.exit(1)
89
+ }
90
+ const state = {
91
+ access: account.token,
92
+ refresh: account.refresh_token,
93
+ expires: Number(account.token_valid_time) * 1000,
94
+ uid: account.user_id,
95
+ idType: String(account.id_type ?? ""),
96
+ tokenType: String(account.token_type ?? ""),
97
+ guid: device?.guid || "",
98
+ q36: device?.q36 || "",
99
+ iua: device?.qua || "",
100
+ extensionVersion: version,
101
+ clientType: process.env.IMA_CLIENT_TYPE || CLIENT_TYPE,
102
+ accountId: account?.user_info?.open_info?.nickname || "ima.copilot",
103
+ nickname: account?.user_info?.open_info?.nickname || "",
104
+ source: `cdp:${port}`,
105
+ }
106
+ const ok = saveSeed(state)
107
+ console.log(JSON.stringify({
108
+ imported: true,
109
+ seed: SEED_FILE,
110
+ saved: ok,
111
+ uid: state.uid,
112
+ account: state.accountId,
113
+ expiresAt: new Date(state.expires).toISOString(),
114
+ hasRefresh: Boolean(state.refresh),
115
+ }, null, 2))
package/src/api.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // 这一层把所有网络与错误语义收在一处,钩子层不再判断 code。
6
6
 
7
7
  import { randomUUID } from "node:crypto"
8
- import { BASE, CLIENT_VERSION, HOST, IMA_APP_VERSION, IMA_CHROMIUM_VERSION } from "./context.mjs"
8
+ import { BASE, CLIENT_TYPE, CLIENT_VERSION, HOST, IMA_APP_VERSION, IMA_CHROMIUM_VERSION } from "./context.mjs"
9
9
 
10
10
  export const AUTH_CODES = new Set([41, 600001])
11
11
  export const SESSION_CODES = new Set([5, 51])
@@ -48,7 +48,7 @@ export function defaultIua() {
48
48
  }
49
49
 
50
50
  /** 拼 x-ima-cookie。空字段不出现(ima 对空字段比缺字段更敏感) */
51
- export function cookieFor(auth, clientType = process.platform === "darwin" ? "mac" : "windows") {
51
+ export function cookieFor(auth, clientType = auth?.clientType || process.env.IMA_CLIENT_TYPE || CLIENT_TYPE) {
52
52
  const fields = {
53
53
  PLATFORM: "H5",
54
54
  "CLIENT-TYPE": clientType,
package/src/context.mjs CHANGED
@@ -11,6 +11,10 @@ export const SDK = "@ai-sdk/openai-compatible"
11
11
  export const REFRESH_LEAD_MS = 10 * 60_000
12
12
  export const TOKEN_TTL_SECONDS = 7200
13
13
  export const CLIENT_VERSION = "5.16.12"
14
+ // ima 客户端的 CLIENT-TYPE 指纹。没有它,服务端会退回旧目录(V3.2/HY2.0),
15
+ // 有了它才返回当前账号的 V4.1-Flash / Hy3 / Hy4 / GLM-5.3 目录。
16
+ // 2026-10-28 从 ima 2.6.15 客户端实际请求头抓包得到;可用 IMA_CLIENT_TYPE 覆盖。
17
+ export const CLIENT_TYPE = "256021"
14
18
  export const IMA_APP_VERSION = "2.6.15.5280"
15
19
  export const IMA_CHROMIUM_VERSION = "150.0.7871.5469"
16
20
 
@@ -26,10 +30,14 @@ export const ICON = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAA
26
30
  * 那些模型在当前账号上不一定能调,列出来反而会 404。
27
31
  */
28
32
  export const MODELS = [
29
- ["deepseek-v3.2", "DeepSeek V3.2", 3, "official_3", false],
30
- ["deepseek-v3.2-think", "DeepSeek V3.2 Think", 1, "official_1", true],
31
- ["tencent-hy-2.0", "Tencent HY 2.0", 0, "official_0", false],
32
- ["tencent-hy-2.0-think", "Tencent HY 2.0 Think", 2, "official_2", true],
33
+ ["deepseek-v4.1-flash", "DeepSeek-V4.1-Flash", 3, "official_3", false],
34
+ ["deepseek-v4.1-flash-think", "DeepSeek-V4.1-Flash Think", 1, "official_1", true],
35
+ ["hy3", "Hy3", 0, "official_0", false],
36
+ ["hy3-think", "Hy3 Think", 2, "official_2", true],
37
+ ["hy4-preview", "Hy4 preview", 1001, "official_1001", false],
38
+ ["hy4-preview-think", "Hy4 preview Think", 1002, "official_1002", true],
39
+ ["glm-5.3-flash", "GLM-5.3-Flash", 3000, "official_3000", false],
40
+ ["glm-5.3-flash-think", "GLM-5.3-Flash Think", 3001, "official_3001", true],
33
41
  ]
34
42
 
35
43
  /** 静态模型条目(magpie 的 config.models 必须有内容,未登录也要显示) */
package/src/login.mjs CHANGED
@@ -1,238 +1,240 @@
1
- // src/login.mjs —— 从本机 ima.copilot 客户端导入登录态
2
- //
3
- // ima 把登录态放在两个地方:
4
- // 1. 正在运行的客户端内存里(native bridge,官方前端用 chrome.imaFrame 读它);
5
- // 2. 客户端正常退出时,把账号凭证写进 profile 的 Preferences
6
- // (tencent.wxlogin.account_secret_encrypted,DPAPI 加密)。
7
- //
8
- // 这个文件读第 2 种,外加一个可选的明文 JSON 种子文件(IMA_COPILOT_LOGIN_FILE)。
9
- // 不读进程内存、不开 CDP、不要求用户粘贴 cookie。
10
- //
11
- // ⚠ 这里会碰到 token 明文。绝不 console.log / 写进错误消息。
12
-
13
- import { execFileSync } from "node:child_process"
14
- import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"
15
- import { homedir } from "node:os"
16
- import { join, delimiter } from "node:path"
17
- import { CLIENT_VERSION } from "./context.mjs"
18
-
19
- const CACHE_DIR = join(homedir(), ".cache", "magpie", "ima-local-auth")
20
- export const SEED_FILE = join(CACHE_DIR, "login.json")
21
-
22
- const readJson = (file) => {
23
- try {
24
- return JSON.parse(readFileSync(file, "utf8"))
25
- } catch {
26
- return null
27
- }
28
- }
29
-
30
- const number = (v) => {
31
- const n = Number(v)
32
- return Number.isFinite(n) ? n : 0
33
- }
34
-
35
- /**
36
- * DPAPI 解密(Windows 当前用户)。account_secret_encrypted 是 base64 的
37
- * CryptProtectData 输出;解出来是 { token, refresh_token, token_valid_time,
38
- * refresh_token_valid_time } 的 JSON。
39
- */
40
- export function dpapiDecrypt(base64) {
41
- if (process.platform !== "win32") throw new Error("ima 的账号凭证解密目前只支持 Windows")
42
- const script = [
43
- "Add-Type -AssemblyName System.Security",
44
- "$b=[Convert]::FromBase64String($env:IMA_SECRET_B64)",
45
- "$p=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser')",
46
- "[Convert]::ToBase64String($p)",
47
- ].join("; ")
48
- const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
49
- encoding: "utf8",
50
- windowsHide: true,
51
- timeout: 15_000,
52
- env: { ...process.env, IMA_SECRET_B64: base64 },
53
- })
54
- const line = String(out ?? "")
55
- .split(/\r?\n/)
56
- .map((s) => s.trim())
57
- .filter(Boolean)
58
- .pop()
59
- if (!line) throw new Error("DPAPI 解密没有输出")
60
- return Buffer.from(line, "base64").toString("utf8")
61
- }
62
-
63
- /** 把 Preferences 里的 wxlogin 段变成插件要的登录态 */
64
- export function loginFromPreferences(prefsPath) {
65
- const prefs = readJson(prefsPath)
66
- const wx = prefs?.tencent?.wxlogin
67
- if (!wx?.account_secret_encrypted) return null
68
- const meta = (() => {
69
- try {
70
- return JSON.parse(wx.account_meta || "{}")
71
- } catch {
72
- return {}
73
- }
74
- })()
75
- const secret = JSON.parse(dpapiDecrypt(String(wx.account_secret_encrypted)))
76
- const q36 = (() => {
77
- const groups = prefs?.sync?.transport_data_per_account
78
- if (!groups || typeof groups !== "object") return ""
79
- for (const v of Object.values(groups)) {
80
- if (v && typeof v === "object" && v["sync.cache_guid"]) return String(v["sync.cache_guid"])
81
- }
82
- return ""
83
- })()
84
- return normalize({
85
- ...secret,
86
- uid: meta.user_id,
87
- id_type: meta.id_type,
88
- token_type: meta.token_type,
89
- guid: meta?.user_info?.open_info?.guid,
90
- q36,
91
- accountId: meta?.user_info?.open_info?.nickname || "ima.copilot",
92
- extensionVersion: extensionVersionOf(prefsPath) || CLIENT_VERSION,
93
- source: prefsPath,
94
- })
95
- }
96
-
97
- /** 同目录的 Secure Preferences 里,copilot-v2 扩展的版本 */
98
- function extensionVersionOf(prefsPath) {
99
- try {
100
- const secure = join(prefsPath, "..", "Secure Preferences")
101
- const d = readJson(secure)
102
- const settings = d?.extensions?.settings
103
- if (!settings) return ""
104
- for (const [id, v] of Object.entries(settings)) {
105
- const name = String(v?.manifest?.name ?? "")
106
- if (id === "ebbbfiigemhmjadjbckcodajdmnceamb" || /copilot/i.test(name)) {
107
- const ver = String(v?.manifest?.version ?? "")
108
- if (ver) return ver
109
- }
110
- }
111
- } catch {
112
- // 读不到就用默认版本
113
- }
114
- return ""
115
- }
116
-
117
- /** 候选种子文件(明文 JSON) */
118
- function seedPaths() {
119
- const out = []
120
- if (process.env.IMA_COPILOT_LOGIN_FILE) out.push(process.env.IMA_COPILOT_LOGIN_FILE)
121
- out.push(SEED_FILE, join(homedir(), ".config", "magpie", "ima-local-auth", "login.json"))
122
- return out
123
- }
124
-
125
- /** 候选 profile 目录(每个目录下找 Default/Preferences 或 Preferences) */
126
- function profileDirs() {
127
- const out = []
128
- const env = process.env.IMA_COPILOT_PROFILE_DIRS
129
- if (env) out.push(...env.split(delimiter).map((s) => s.trim()).filter(Boolean))
130
- const local = process.env.LOCALAPPDATA
131
- if (local) {
132
- out.push(join(local, "ima.copilot", "User Data"))
133
- // 某些安装会多一个 Application 子目录;Preferences 仍然在 User Data 下。
134
- out.push(join(local, "ima.copilot", "User Data", "Default"))
135
- }
136
- out.push(join(CACHE_DIR, "profile"))
137
- return [...new Set(out)]
138
- }
139
-
140
- function preferencesIn(dir) {
141
- for (const p of [join(dir, "Default", "Preferences"), join(dir, "Preferences")]) {
142
- if (existsSync(p)) return p
143
- }
144
- return ""
145
- }
146
-
147
- /** 登录态归一化:返回插件内部形状(expires 用毫秒时间戳) */
148
- export function normalize(raw) {
149
- if (!raw || typeof raw !== "object") return null
150
- const access = String(raw.access ?? raw.token ?? "")
151
- const refresh = String(raw.refresh ?? raw.refresh_token ?? "")
152
- if (!access) return null
153
- let expires = number(raw.expires)
154
- if (!expires) {
155
- const t = number(raw.token_valid_time)
156
- if (t > 1e12) expires = t
157
- else if (t > 1e9) expires = t * 1000
158
- else if (t > 0) expires = Date.now() + t * 1000
159
- }
160
- if (!expires) expires = Date.now() + 7200 * 1000
161
- const uid = String(raw.uid ?? raw.user_id ?? "")
162
- return {
163
- access,
164
- refresh,
165
- expires,
166
- uid,
167
- idType: String(raw.idType ?? raw.id_type ?? ""),
168
- tokenType: String(raw.tokenType ?? raw.token_type ?? ""),
169
- guid: String(raw.guid ?? ""),
170
- q36: String(raw.q36 ?? ""),
171
- iua: String(raw.iua ?? ""),
172
- extensionVersion: String(raw.extensionVersion ?? raw.extension_version ?? CLIENT_VERSION),
173
- accountId: String(raw.accountId ?? uid ?? "ima.copilot"),
174
- nickname: String(raw.nickname ?? ""),
175
- source: String(raw.source ?? ""),
176
- }
177
- }
178
-
179
- /**
180
- * 找一个能用的登录态:种子文件 → 本机 ima profile。
181
- * @returns {object|null}
182
- */
183
- export function importLocalLogin() {
184
- for (const file of seedPaths()) {
185
- if (!file || !existsSync(file)) continue
186
- const state = normalize(readJson(file))
187
- if (state) return state
188
- }
189
- for (const dir of profileDirs()) {
190
- const prefs = preferencesIn(dir)
191
- if (!prefs) continue
192
- try {
193
- const state = loginFromPreferences(prefs)
194
- if (state) return state
195
- } catch {
196
- // 这个 profile 没登录 / 解密失败:试下一个
197
- }
198
- }
199
- return null
200
- }
201
-
202
- /** 把登录态写成种子文件(600),供下次登录导入。 */
203
- export function saveSeed(state) {
204
- if (!state?.access) return false
205
- try {
206
- mkdirSync(CACHE_DIR, { recursive: true })
207
- writeFileSync(
208
- SEED_FILE,
209
- JSON.stringify(
210
- {
211
- access: state.access,
212
- refresh: state.refresh,
213
- expires: state.expires,
214
- uid: state.uid,
215
- id_type: state.idType,
216
- token_type: state.tokenType,
217
- guid: state.guid,
218
- q36: state.q36,
219
- iua: state.iua,
220
- extensionVersion: state.extensionVersion,
221
- accountId: state.accountId,
222
- nickname: state.nickname,
223
- savedAt: new Date().toISOString(),
224
- },
225
- null,
226
- 2,
227
- ),
228
- )
229
- try {
230
- chmodSync(SEED_FILE, 0o600)
231
- } catch {}
232
- return true
233
- } catch {
234
- return false
235
- }
236
- }
237
-
238
- export const _loginInternal = { CACHE_DIR, SEED_FILE, seedPaths, profileDirs, preferencesIn }
1
+ // src/login.mjs —— 从本机 ima.copilot 客户端导入登录态
2
+ //
3
+ // ima 把登录态放在两个地方:
4
+ // 1. 正在运行的客户端内存里(native bridge,官方前端用 chrome.imaFrame 读它);
5
+ // 2. 客户端正常退出时,把账号凭证写进 profile 的 Preferences
6
+ // (tencent.wxlogin.account_secret_encrypted,DPAPI 加密)。
7
+ //
8
+ // 这个文件读第 2 种,外加一个可选的明文 JSON 种子文件(IMA_COPILOT_LOGIN_FILE)。
9
+ // 不读进程内存、不开 CDP、不要求用户粘贴 cookie。
10
+ //
11
+ // ⚠ 这里会碰到 token 明文。绝不 console.log / 写进错误消息。
12
+
13
+ import { execFileSync } from "node:child_process"
14
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"
15
+ import { homedir } from "node:os"
16
+ import { join, delimiter } from "node:path"
17
+ import { CLIENT_TYPE, CLIENT_VERSION } from "./context.mjs"
18
+
19
+ const CACHE_DIR = join(homedir(), ".cache", "magpie", "ima-local-auth")
20
+ export const SEED_FILE = join(CACHE_DIR, "login.json")
21
+
22
+ const readJson = (file) => {
23
+ try {
24
+ return JSON.parse(readFileSync(file, "utf8"))
25
+ } catch {
26
+ return null
27
+ }
28
+ }
29
+
30
+ const number = (v) => {
31
+ const n = Number(v)
32
+ return Number.isFinite(n) ? n : 0
33
+ }
34
+
35
+ /**
36
+ * DPAPI 解密(Windows 当前用户)。account_secret_encrypted 是 base64 的
37
+ * CryptProtectData 输出;解出来是 { token, refresh_token, token_valid_time,
38
+ * refresh_token_valid_time } 的 JSON。
39
+ */
40
+ export function dpapiDecrypt(base64) {
41
+ if (process.platform !== "win32") throw new Error("ima 的账号凭证解密目前只支持 Windows")
42
+ const script = [
43
+ "Add-Type -AssemblyName System.Security",
44
+ "$b=[Convert]::FromBase64String($env:IMA_SECRET_B64)",
45
+ "$p=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser')",
46
+ "[Convert]::ToBase64String($p)",
47
+ ].join("; ")
48
+ const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
49
+ encoding: "utf8",
50
+ windowsHide: true,
51
+ timeout: 15_000,
52
+ env: { ...process.env, IMA_SECRET_B64: base64 },
53
+ })
54
+ const line = String(out ?? "")
55
+ .split(/\r?\n/)
56
+ .map((s) => s.trim())
57
+ .filter(Boolean)
58
+ .pop()
59
+ if (!line) throw new Error("DPAPI 解密没有输出")
60
+ return Buffer.from(line, "base64").toString("utf8")
61
+ }
62
+
63
+ /** 把 Preferences 里的 wxlogin 段变成插件要的登录态 */
64
+ export function loginFromPreferences(prefsPath) {
65
+ const prefs = readJson(prefsPath)
66
+ const wx = prefs?.tencent?.wxlogin
67
+ if (!wx?.account_secret_encrypted) return null
68
+ const meta = (() => {
69
+ try {
70
+ return JSON.parse(wx.account_meta || "{}")
71
+ } catch {
72
+ return {}
73
+ }
74
+ })()
75
+ const secret = JSON.parse(dpapiDecrypt(String(wx.account_secret_encrypted)))
76
+ const q36 = (() => {
77
+ const groups = prefs?.sync?.transport_data_per_account
78
+ if (!groups || typeof groups !== "object") return ""
79
+ for (const v of Object.values(groups)) {
80
+ if (v && typeof v === "object" && v["sync.cache_guid"]) return String(v["sync.cache_guid"])
81
+ }
82
+ return ""
83
+ })()
84
+ return normalize({
85
+ ...secret,
86
+ uid: meta.user_id,
87
+ id_type: meta.id_type,
88
+ token_type: meta.token_type,
89
+ guid: meta?.user_info?.open_info?.guid,
90
+ q36,
91
+ accountId: meta?.user_info?.open_info?.nickname || "ima.copilot",
92
+ extensionVersion: extensionVersionOf(prefsPath) || CLIENT_VERSION,
93
+ source: prefsPath,
94
+ })
95
+ }
96
+
97
+ /** 同目录的 Secure Preferences 里,copilot-v2 扩展的版本 */
98
+ function extensionVersionOf(prefsPath) {
99
+ try {
100
+ const secure = join(prefsPath, "..", "Secure Preferences")
101
+ const d = readJson(secure)
102
+ const settings = d?.extensions?.settings
103
+ if (!settings) return ""
104
+ for (const [id, v] of Object.entries(settings)) {
105
+ const name = String(v?.manifest?.name ?? "")
106
+ if (id === "ebbbfiigemhmjadjbckcodajdmnceamb" || /copilot/i.test(name)) {
107
+ const ver = String(v?.manifest?.version ?? "")
108
+ if (ver) return ver
109
+ }
110
+ }
111
+ } catch {
112
+ // 读不到就用默认版本
113
+ }
114
+ return ""
115
+ }
116
+
117
+ /** 候选种子文件(明文 JSON) */
118
+ function seedPaths() {
119
+ const out = []
120
+ if (process.env.IMA_COPILOT_LOGIN_FILE) out.push(process.env.IMA_COPILOT_LOGIN_FILE)
121
+ out.push(SEED_FILE, join(homedir(), ".config", "magpie", "ima-local-auth", "login.json"))
122
+ return out
123
+ }
124
+
125
+ /** 候选 profile 目录(每个目录下找 Default/Preferences 或 Preferences) */
126
+ function profileDirs() {
127
+ const out = []
128
+ const env = process.env.IMA_COPILOT_PROFILE_DIRS
129
+ if (env) out.push(...env.split(delimiter).map((s) => s.trim()).filter(Boolean))
130
+ const local = process.env.LOCALAPPDATA
131
+ if (local) {
132
+ out.push(join(local, "ima.copilot", "User Data"))
133
+ // 某些安装会多一个 Application 子目录;Preferences 仍然在 User Data 下。
134
+ out.push(join(local, "ima.copilot", "User Data", "Default"))
135
+ }
136
+ out.push(join(CACHE_DIR, "profile"))
137
+ return [...new Set(out)]
138
+ }
139
+
140
+ function preferencesIn(dir) {
141
+ for (const p of [join(dir, "Default", "Preferences"), join(dir, "Preferences")]) {
142
+ if (existsSync(p)) return p
143
+ }
144
+ return ""
145
+ }
146
+
147
+ /** 登录态归一化:返回插件内部形状(expires 用毫秒时间戳) */
148
+ export function normalize(raw) {
149
+ if (!raw || typeof raw !== "object") return null
150
+ const access = String(raw.access ?? raw.token ?? "")
151
+ const refresh = String(raw.refresh ?? raw.refresh_token ?? "")
152
+ if (!access) return null
153
+ let expires = number(raw.expires)
154
+ if (!expires) {
155
+ const t = number(raw.token_valid_time)
156
+ if (t > 1e12) expires = t
157
+ else if (t > 1e9) expires = t * 1000
158
+ else if (t > 0) expires = Date.now() + t * 1000
159
+ }
160
+ if (!expires) expires = Date.now() + 7200 * 1000
161
+ const uid = String(raw.uid ?? raw.user_id ?? "")
162
+ return {
163
+ access,
164
+ refresh,
165
+ expires,
166
+ uid,
167
+ idType: String(raw.idType ?? raw.id_type ?? ""),
168
+ tokenType: String(raw.tokenType ?? raw.token_type ?? ""),
169
+ guid: String(raw.guid ?? ""),
170
+ q36: String(raw.q36 ?? ""),
171
+ iua: String(raw.iua ?? ""),
172
+ extensionVersion: String(raw.extensionVersion ?? raw.extension_version ?? CLIENT_VERSION),
173
+ clientType: String(raw.clientType ?? raw.client_type ?? process.env.IMA_CLIENT_TYPE ?? CLIENT_TYPE),
174
+ accountId: String(raw.accountId ?? uid ?? "ima.copilot"),
175
+ nickname: String(raw.nickname ?? ""),
176
+ source: String(raw.source ?? ""),
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 找一个能用的登录态:种子文件 → 本机 ima profile。
182
+ * @returns {object|null}
183
+ */
184
+ export function importLocalLogin() {
185
+ for (const file of seedPaths()) {
186
+ if (!file || !existsSync(file)) continue
187
+ const state = normalize(readJson(file))
188
+ if (state) return state
189
+ }
190
+ for (const dir of profileDirs()) {
191
+ const prefs = preferencesIn(dir)
192
+ if (!prefs) continue
193
+ try {
194
+ const state = loginFromPreferences(prefs)
195
+ if (state) return state
196
+ } catch {
197
+ // 这个 profile 没登录 / 解密失败:试下一个
198
+ }
199
+ }
200
+ return null
201
+ }
202
+
203
+ /** 把登录态写成种子文件(600),供下次登录导入。 */
204
+ export function saveSeed(state) {
205
+ if (!state?.access) return false
206
+ try {
207
+ mkdirSync(CACHE_DIR, { recursive: true })
208
+ writeFileSync(
209
+ SEED_FILE,
210
+ JSON.stringify(
211
+ {
212
+ access: state.access,
213
+ refresh: state.refresh,
214
+ expires: state.expires,
215
+ uid: state.uid,
216
+ id_type: state.idType,
217
+ token_type: state.tokenType,
218
+ guid: state.guid,
219
+ q36: state.q36,
220
+ iua: state.iua,
221
+ extensionVersion: state.extensionVersion,
222
+ clientType: state.clientType,
223
+ accountId: state.accountId,
224
+ nickname: state.nickname,
225
+ savedAt: new Date().toISOString(),
226
+ },
227
+ null,
228
+ 2,
229
+ ),
230
+ )
231
+ try {
232
+ chmodSync(SEED_FILE, 0o600)
233
+ } catch {}
234
+ return true
235
+ } catch {
236
+ return false
237
+ }
238
+ }
239
+
240
+ export const _loginInternal = { CACHE_DIR, SEED_FILE, seedPaths, profileDirs, preferencesIn }
package/src/models.mjs CHANGED
@@ -84,6 +84,22 @@ export function buildModels(rows = []) {
84
84
  const thinking = thinkingName(name)
85
85
  models[id] = entry(id, name, thinking, { free, rate })
86
86
  index.set(id, { id, name, type, modelId, thinking })
87
+
88
+ // ima 的免费模型用 sub_model_infos 带一个深度思考变体:
89
+ // {"0":{model_type:3,...},"1":{model_type:1,...}}
90
+ // 把它也做成独立模型,用户才能在 magpie 里选思考档。
91
+ for (const sub of Object.values(row?.sub_model_infos ?? {})) {
92
+ const subType = Number(sub?.model_type)
93
+ const subModelId = String(sub?.model_id ?? "")
94
+ if (!Number.isFinite(subType) || !subModelId) continue
95
+ if (subType === type && subModelId === modelId) continue
96
+ let subId = `${slug(name)}-think`
97
+ if (used.has(subId)) subId = `${subId}-${subType}`
98
+ used.add(subId)
99
+ const subName = `${name} Think`
100
+ models[subId] = entry(subId, subName, true, { free, rate })
101
+ index.set(subId, { id: subId, name: subName, type: subType, modelId: subModelId, thinking: true })
102
+ }
87
103
  }
88
104
  return { models, index }
89
105
  }