magpie-marvis 0.1.9 → 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/CHANGELOG.md CHANGED
@@ -3,7 +3,27 @@
3
3
  > 每个版本一节,**一条一行**,说用户能感知的变化。
4
4
  > 格式与规矩见 [UPDATING.md](./UPDATING.md)。
5
5
 
6
- ## 0.1.9 — 2026-11-06
6
+ ## 0.2.1 — 2026-11-06
7
+
8
+ **变更**
9
+
10
+ - **上下文窗口声明改为 1,000,000**(除 `hunyuan-turbos-vision` 仍 32k)。
11
+ 依据:实测 `main-auto` 发 **400,000 字符 = 190,511 tokens** 返回 200,
12
+ 远超 `config_defaults.yaml` 声明的 128k —— 那个 128k 是客户端自己的保守值
13
+ - **用户设置优先**:`config()` 不再整块覆盖 provider。改成**逐模型、逐字段合并** ——
14
+ 用户在 magpie 设置里改过的 `limit.context` / `limit.output` / `name` / `api` 一律保留,
15
+ 插件只补缺失字段和新增模型
16
+
17
+ **修复(逆向客户端主进程后对齐)**
18
+
19
+ - **换取请求改在页面上下文里发**:`yybadaccess` 的 WAF 做 TLS 指纹校验,
20
+ Node 的 undici(OpenSSL)和 Chromium(BoringSSL)指纹不同 ——
21
+ 带头的请求从 Node 发出去一律 **400 空响应**。
22
+ - **补 `Referer`**:客户端重放时会把原始请求头整份复制(`reqHeaders` / `cookies` / `referer`),
23
+ 缺 referer 服务端判来源不合法。
24
+ - **去掉 `Ual-Access-Businessid`**:实测它会让 WAF 直接 400(这是我这轮踩的最大坑——
25
+ 加头反而更糟,不加头时至少还能拿到 `-109` JSON)。
26
+ - 浏览器先开 `about:blank`、装好钩子再 `Page.navigate` —— 否则首次请求漏过去,拦截永远不触发。
7
27
 
8
28
  **修复(这是 `-109` 的真正根因)**
9
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-marvis",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "magpie / OpenCode provider plugin for Tencent Marvis: its DeepSeek V4 Pro, Kimi K2.5, Claude Opus/Sonnet 4.6, GPT-5.4 and Hunyuan models through the Marvis model gateway — no API key to paste, sign in with QQ or WeChat.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
package/src/context.mjs CHANGED
@@ -69,6 +69,8 @@ const DEFAULT_OUTPUT = 32_000
69
69
  * 不猜、不补。`local_model` / `warmup_model` / `*placeholder` 是内部占位,不对外。
70
70
  */
71
71
  const TABLE = [
72
+ // ⚠ 上下文窗口:实测 main-auto 发 400,000 字符(190,511 tokens)返回 200,
73
+ // 已远超 config_defaults.yaml 声明的 128k —— 所以按 1M 声明(用户要求,出问题再调)。
72
74
  // ⚠ 2026-11-06 逐槽位实测(每槽连发 5 次、5 种不同难度提示词,路由 100% 稳定)。
73
75
  // 显示名 = **真实模型名**,槽位名写进 id —— 卡片上看到的就是实际在跑什么。
74
76
  // ⚠ 真实模型 id 不能直接当 `model` 传(会 400 无效模型id),只能填槽位名。
@@ -82,12 +84,12 @@ const TABLE = [
82
84
  // kimi-k2.5(500 RPC)· hunyuan-2.0-instruct-20251111(空响应)· venus-vision-llm(→ default)
83
85
 
84
86
  // ── DeepSeek ────────────────────────────────────────────────
85
- ["main-auto", "deepseek-v4-1-flash-261017", 128_000, { reasoning: true, tools: true }],
86
- ["deepseek-v4-pro-external", "deepseek-v4-pro-202606", 128_000, { reasoning: true, tools: true }],
87
- ["hy3-preview-agent", "deepseek-v4-flash-ga-260731", 128_000, { reasoning: true, tools: true }],
87
+ ["main-auto", "deepseek-v4-1-flash-261017", 1_000_000, { reasoning: true, tools: true }],
88
+ ["deepseek-v4-pro-external", "deepseek-v4-pro-202606", 1_000_000, { reasoning: true, tools: true }],
89
+ ["hy3-preview-agent", "deepseek-v4-flash-ga-260731", 1_000_000, { reasoning: true, tools: true }],
88
90
 
89
91
  // ── 其他厂商 ─────────────────────────────────────────────────
90
- ["hy3-preview-agent-real", "hy3", 128_000, { reasoning: true, tools: true }],
92
+ ["hy3-preview-agent-real", "hy3", 1_000_000, { reasoning: true, tools: true }],
91
93
  ["claude-sonnet-4-6", "claude-sonnet-4-6", 1_000_000, { reasoning: true, tools: true, vision: true }],
92
94
  ["claude-opus-4-6", "claude-opus-4-6", 1_000_000, { reasoning: true, tools: true, vision: true }],
93
95
  ["gpt-5.4", "gpt-5.4-2026-03-05", 1_000_000, { reasoning: true, tools: true, vision: true }],
@@ -2,18 +2,83 @@
2
2
  //
3
3
  // ⚠ `config.models` 必须有内容:它是**没人登录时**模型列表的唯一来源,
4
4
  // 空了就是「插件装上了但显示 0 models」。
5
- // ⚠ 用 `??=`,不要覆盖用户在 plugins.json 里改过的配置。
5
+ //
6
+ // ⚠⚠ **用户在 magpie 设置里改过的值优先,插件不许覆盖。**
7
+ // 宿主把用户改过的 provider 配置原样传进来(`cfg.provider[PROVIDER]`),
8
+ // 我们要做的是「**补齐缺失**」,不是「重置成默认」。
9
+ // 踩过的坑:早期直接 `??=` 整个 provider —— 用户改了上下文窗口,
10
+ // 插件一加载又变回默认值,用户以为没生效。
11
+ //
12
+ // 合并规则(逐模型、逐字段):
13
+ // · 用户有的字段 → 保留用户的(`limit.context` / `limit.output` / `capabilities` / `name` …)
14
+ // · 用户没有的字段 → 用默认值补
15
+ // · 用户删掉的模型 → 不恢复(尊重用户意图)
16
+ // · 默认表里有、用户配置里没有的模型 → 追加(新版本新增模型能自动出现)
6
17
 
7
18
  import { PROVIDER, PROVIDER_NAME, SDK, BASE_V1, staticModels } from "../context.mjs"
8
19
 
20
+ /** 合并一个模型:用户字段优先,缺的用默认值补 */
21
+ function mergeModel(userModel, defaultModel) {
22
+ const merged = { ...defaultModel, ...userModel }
23
+
24
+ // limit 逐字段合并 —— 只改 context 的用户不该把 output 也吃掉
25
+ merged.limit = { ...(defaultModel.limit ?? {}), ...(userModel.limit ?? {}) }
26
+
27
+ // config 形状用 modalities 数组 + tool_call 下划线;用户可能只改了其中一个
28
+ if (userModel.modalities || defaultModel.modalities) {
29
+ merged.modalities = {
30
+ ...(defaultModel.modalities ?? {}),
31
+ ...(userModel.modalities ?? {}),
32
+ }
33
+ }
34
+ return merged
35
+ }
36
+
37
+ /**
38
+ * 合并模型表。
39
+ * @param {any[]} defaults 插件内置的默认表
40
+ * @param {any[] | undefined} user 宿主里用户改过的表
41
+ * @returns {any[]}
42
+ */
43
+ export function mergeModels(defaults, user) {
44
+ if (!Array.isArray(user) || user.length === 0) return defaults
45
+
46
+ const byId = new Map(user.map((m) => [m?.id, m]).filter(([id]) => Boolean(id)))
47
+ const out = []
48
+
49
+ // 按用户顺序,逐条合并
50
+ for (const um of user) {
51
+ if (!um?.id) continue
52
+ const dm = defaults.find((d) => d.id === um.id)
53
+ out.push(dm ? mergeModel(um, dm) : um)
54
+ }
55
+
56
+ // 默认表里有、用户没有的 → 追加(新版本新增的模型)
57
+ for (const dm of defaults) {
58
+ if (!byId.has(dm.id)) out.push(dm)
59
+ }
60
+ return out
61
+ }
62
+
9
63
  export function configHook() {
10
64
  return async (cfg) => {
11
65
  cfg.provider ??= {}
12
- cfg.provider[PROVIDER] ??= {
13
- name: PROVIDER_NAME,
14
- npm: SDK,
15
- api: BASE_V1,
16
- models: staticModels(),
66
+ const existing = cfg.provider[PROVIDER]
67
+
68
+ if (!existing) {
69
+ cfg.provider[PROVIDER] = {
70
+ name: PROVIDER_NAME,
71
+ npm: SDK,
72
+ api: BASE_V1,
73
+ models: staticModels(),
74
+ }
75
+ return
17
76
  }
77
+
78
+ // 已有配置:只补缺,用户改过的优先
79
+ existing.name ??= PROVIDER_NAME
80
+ existing.npm ??= SDK
81
+ existing.api ??= BASE_V1
82
+ existing.models = mergeModels(staticModels(), existing.models)
18
83
  }
19
84
  }
@@ -198,11 +198,12 @@ async function getJson(url, timeoutMs = 3000) {
198
198
  * @param {{name:string,path:string}} o.browser findBrowser() 的返回值
199
199
  * @param {string} o.url 登录页
200
200
  * @param {RegExp} o.match 回调地址的判据
201
+ * @param {Record<string,string>} [o.extraHeaders] 注入到**每个请求**的头(MarvisExt 等)
201
202
  * @param {number} [o.timeoutMs] 默认 5 分钟
202
203
  * @param {AbortSignal} [o.signal]
203
204
  * @returns {Promise<{url:string, body:string, browser:string}>} 回调页面的地址与正文
204
205
  */
205
- export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 60_000, signal }) {
206
+ export async function openIsolatedLogin({ browser, url, match, extraHeaders, timeoutMs = 5 * 60_000, signal }) {
206
207
  if (!browser?.path) throw new Error("没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)")
207
208
 
208
209
  const profile = mkdtempSync(join(tmpdir(), "magpie-marvis-login-"))
@@ -223,7 +224,9 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
223
224
  "--exclude-switches=enable-automation",
224
225
  `--user-agent=${CHROME_UA}`,
225
226
  "--lang=zh-CN",
226
- url,
227
+ // ⚠ 必须先开 about:blank:钩子(Fetch.enable / setExtraHTTPHeaders)要在
228
+ // 导航**之前**装好,否则首次请求漏过去,拦截永远不触发。
229
+ "about:blank",
227
230
  ],
228
231
  { stdio: "ignore", windowsHide: false },
229
232
  )
@@ -295,20 +298,38 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
295
298
  acceptLanguage: "zh-CN,zh;q=0.9",
296
299
  platform: "Win32",
297
300
  }).catch(() => {})
301
+ // ⚠⚠ 关键:服务端对 `yybadaccess.3g.qq.com` 的**每个**请求都校验
302
+ // `Ual-Access-MarvisExt`(uskey 设备指纹)。没有它就返回
303
+ // 「请使用 Marvis 客户端」—— 这正是浏览器登录一直失败的原因。
304
+ // Marvis 的 BrowserWindow 用 `session.webRequest.onBeforeSendHeaders` 注入,
305
+ // 这里用 CDP 的 `Network.setExtraHTTPHeaders` 达到同样效果。
306
+ if (extraHeaders && Object.keys(extraHeaders).length > 0) {
307
+ await send(5, "Network.setExtraHTTPHeaders", { headers: extraHeaders }).catch(() => {})
308
+ }
309
+
298
310
  // 顺手抹掉 navigator.webdriver(比 --disable-blink-features 更干净,不带警告条)
299
311
  await send(3, "Page.addScriptToEvaluateOnNewDocument", {
300
312
  source: "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})",
301
313
  }).catch(() => {})
302
314
 
303
- // ⚠ 让浏览器**正常走完**回调请求(带 Cookie、带正确的 redirect_uri),
304
- // 我们只读**第一个**响应的正文。不拦截、不重发 ——
305
- // 拦截后用 Node 的 fetch 重放会丢掉 Cookie,服务端就回 `-109`。
315
+ // ⚠⚠ 核心:**在跳转发生前**拦下回调,然后在**页面上下文里**发换取请求。
316
+ //
317
+ // 为什么不能从 Node 发:`yybadaccess` 的 WAF 做 **TLS 指纹**校验 ——
318
+ // Node 的 undici(OpenSSL)和 Chromium(BoringSSL)指纹不同,
319
+ // 带 `Ual-Access-MarvisExt` 的请求从 Node 发出去一律 **400 空响应**。
320
+ // 在页面里 `fetch` 出去 = Chromium 的 TLS 栈 + 同源 Cookie,服务端才认。
321
+ await send(6, "Fetch.enable", {
322
+ patterns: [{ urlPattern: "*marvis_client_login/marvis_oauth*", requestStage: "Request" }],
323
+ }).catch(() => {})
324
+
325
+ // 钩子装好,现在才导航到授权页
326
+ await send(7, "Page.navigate", { url }).catch(() => {})
327
+
328
+ let nextId = 10
306
329
  /** @type {string | null} */
307
- let hitRequestId = null
330
+ let pausedId = null
308
331
  /** @type {string} */
309
- let hitUrl = ""
310
- /** @type {{url:string, body:string} | null} */
311
- let hit = null
332
+ let pausedUrl = ""
312
333
 
313
334
  ws.addEventListener("message", (event) => {
314
335
  let msg
@@ -317,36 +338,91 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
317
338
  } catch {
318
339
  return
319
340
  }
320
- if (msg.method === "Network.responseReceived" && !hitRequestId) {
321
- const { requestId, response } = msg.params ?? {}
322
- const u = String(response?.url ?? "")
341
+ if (msg.method === "Fetch.requestPaused" && !pausedId) {
342
+ const { requestId, request } = msg.params ?? {}
343
+ const u = String(request?.url ?? "")
323
344
  if (u && match.test(u)) {
324
- hitRequestId = requestId
325
- hitUrl = u
345
+ pausedId = requestId
346
+ pausedUrl = u
326
347
  }
327
348
  }
328
349
  })
329
350
 
330
- let nextId = 10
331
351
  while (Date.now() < deadline) {
332
352
  if (signal?.aborted) throw new Error("登录已取消")
353
+ if (pausedId) break
354
+ await new Promise((r) => setTimeout(r, 400))
355
+ }
356
+ if (!pausedId) {
357
+ throw new Error("登录超时:没有等到授权回调。请在打开的窗口里完成 QQ / 微信 授权。")
358
+ }
333
359
 
334
- if (hitRequestId && !hit) {
335
- try {
336
- const r = await send(nextId++, "Network.getResponseBody", { requestId: hitRequestId })
337
- const raw = String(r?.body ?? "")
338
- const body = r?.base64Encoded ? Buffer.from(raw, "base64").toString("utf8") : raw
339
- if (body.trim()) hit = { url: hitUrl, body }
340
- } catch {
341
- /* body 还没就绪,下一轮再取 */
342
- }
360
+ // 先把挂起的导航放行(用一张静态页顶掉,避免页面白屏),再在页面里换
361
+ await send(nextId++, "Fetch.fulfillRequest", {
362
+ requestId: pausedId,
363
+ responseCode: 200,
364
+ responseHeaders: [{ name: "content-type", value: "text/html; charset=utf-8" }],
365
+ body: Buffer.from(
366
+ '<!doctype html><meta charset="utf-8"><title>Marvis</title>' +
367
+ '<body style="font:16px/1.6 system-ui;padding:48px;color:#111">' +
368
+ "<h2>正在完成授权…</h2><p>回到 magpie 即可。</p></body>",
369
+ "utf8",
370
+ ).toString("base64"),
371
+ }).catch(() => {})
372
+
373
+ // 在页面上下文里发换取请求(Chromium 的 TLS 指纹 + 同源 Cookie + 注入的头)
374
+ //
375
+ // ⚠ 还要补 `Referer`:客户端主进程重放时会把**原始请求头**整份复制过去
376
+ // (字符串表里的 `reqHeaders` / `cookies` / `referer` / `ALL_REQ_HEADERS`),
377
+ // 其中 referer 是 QQ 授权页本身的地址。缺它服务端会判来源不合法。
378
+ // fetch 的 Referer 属于禁止头,只能走 CDP 注入。
379
+ try {
380
+ const origin = new URL(pausedUrl).origin
381
+ await send(nextId++, "Network.setExtraHTTPHeaders", {
382
+ headers: { Referer: `${origin}/oauth2.0/show?which=Login&display=pc` },
383
+ })
384
+ } catch {
385
+ /* 注入失败不致命,继续试 */
386
+ }
387
+
388
+ const exchangeUrl = pausedUrl
389
+ const expr = `(async () => {
390
+ try {
391
+ const r = await fetch(${JSON.stringify(exchangeUrl)}, {
392
+ method: "GET",
393
+ credentials: "include",
394
+ headers: { "Accept": "application/json" },
395
+ });
396
+ return JSON.stringify({ __status: r.status, __body: await r.text() });
397
+ } catch (e) {
398
+ return JSON.stringify({ __error: String(e && e.message || e) });
343
399
  }
344
- if (hit) return { url: hit.url, body: hit.body, browser: browser.name }
400
+ })()`
345
401
 
346
- await new Promise((r) => setTimeout(r, 400))
402
+ /** @type {any} */
403
+ let out = null
404
+ for (let i = 0; i < 20; i++) {
405
+ try {
406
+ const r = await send(nextId++, "Runtime.evaluate", {
407
+ expression: expr,
408
+ awaitPromise: true,
409
+ returnByValue: true,
410
+ })
411
+ const raw = r?.result?.value
412
+ if (raw) {
413
+ out = JSON.parse(String(raw))
414
+ break
415
+ }
416
+ } catch {
417
+ /* 页面还在导航 */
418
+ }
419
+ await new Promise((r) => setTimeout(r, 500))
347
420
  }
348
421
 
349
- throw new Error("登录超时:没有等到授权回调。请在打开的窗口里完成 QQ / 微信 授权。")
422
+ if (!out) throw new Error("换取登录态超时:页面里没能完成请求。请重新走一遍登录。")
423
+ if (out.__error) throw new Error(`页面内换取失败:${out.__error}`)
424
+
425
+ return { url: exchangeUrl, body: String(out.__body ?? ""), browser: browser.name }
350
426
  } finally {
351
427
  try {
352
428
  ws.close()
@@ -175,3 +175,52 @@ test("native 目录 4 个文件必须都在(缺一个就静默返回 null)",
175
175
  assert.ok(existsSync(join(dir, f)), `native/${f} 必须打包(否则 getUskey() 静默返回 null)`)
176
176
  }
177
177
  })
178
+
179
+ test("config() 合并:用户改过的值优先,插件只补缺", async () => {
180
+ const { mergeModels } = await import("../src/hooks/config.mjs")
181
+ const { staticModels } = await import("../src/context.mjs")
182
+ const defaults = staticModels()
183
+
184
+ // 用户把 main-auto 的上下文改成 2M,并删掉了 hunyuan-turbos-vision
185
+ const user = defaults
186
+ .filter((m) => m.id !== "hunyuan-turbos-vision")
187
+ .map((m) => (m.id === "main-auto" ? { ...m, limit: { ...m.limit, context: 2_000_000 } } : m))
188
+
189
+ const merged = mergeModels(defaults, user)
190
+ const main = merged.find((m) => m.id === "main-auto")
191
+
192
+ assert.equal(main.limit.context, 2_000_000, "用户设的 2M 必须保留")
193
+ assert.equal(typeof main.limit.output, "number", "用户没改的 output 不能被吃掉")
194
+ assert.ok(merged.some((m) => m.id === "hunyuan-turbos-vision"), "默认表里有、用户没有的模型要追加回来")
195
+ })
196
+
197
+ test("config() 已有 provider 时不重置用户配置", async () => {
198
+ const { MarvisAuthPlugin } = await import("../index.mjs")
199
+ const plugin = await MarvisAuthPlugin({})
200
+ const cfg = {
201
+ provider: {
202
+ marvis: {
203
+ name: "我改的名字",
204
+ npm: "@ai-sdk/openai-compatible",
205
+ api: "https://custom.example.com/v1",
206
+ models: [
207
+ {
208
+ id: "main-auto",
209
+ name: "我的 V4.1 Flash",
210
+ limit: { context: 2_000_000, output: 64_000 },
211
+ modalities: { input: ["text"], output: ["text"] },
212
+ tool_call: true,
213
+ attachment: false,
214
+ reasoning: true,
215
+ },
216
+ ],
217
+ },
218
+ },
219
+ }
220
+ await plugin.config(cfg)
221
+ const p = cfg.provider.marvis
222
+ assert.equal(p.name, "我改的名字", "provider 名不能被我覆盖")
223
+ assert.equal(p.api, "https://custom.example.com/v1", "api 不能被我覆盖")
224
+ assert.equal(p.models.find((m) => m.id === "main-auto").limit.context, 2_000_000, "上下文不能被我覆盖")
225
+ assert.equal(p.models.find((m) => m.id === "main-auto").name, "我的 V4.1 Flash")
226
+ })