diff --git a/CHANGELOG.md b/CHANGELOG.md index c71badc..0a69558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ > 每个版本一节,**一条一行**,说用户能感知的变化。 > 格式与规矩见 [UPDATING.md](./UPDATING.md)。 +## 0.2.39 — 2026-11-04 + +**修「模型全都不通」:magpie 发的是它自己的模型 id,上游只认带档位前缀的原值** + +- 真机现象:国际站 2 条模型全部 `403 Forbidden`。直连上游实测——**同一份凭据、同一个模型**,`monkeycode-basic/deepseek-flash` 回 **200**,而 `monkeycode-basic-deepseek-flash`(slug)、UUID、裸名 `deepseek-flash` 全部 **403 Forbidden** +- 根因在 magpie 侧:`api: { id: m.id ?? was?.api.id ?? key }` —— 它用**我们返回的 `id`**(斜杠被压成连字符)当上游 model,**无视** config 里给的 `api.id`。而模型 id 又不能带斜杠(会被 magpie 当成 provider 边界,长出 `monkeycode-basic` / `monkeycode-pro` 三个假供应商) +- 所以只能在自己的 `loader.fetch` 里换回来:读目录缓存拿 `id → apiId` 映射,把请求体里的 `model` 从 magpie 的 id 换回平台原值,再补 system、再算签名 +- 单测锁住:`loader.fetch` 收到 `monkeycode-basic-deepseek-flash` 时,真正发出去的是 `monkeycode-basic/deepseek-flash` + ## 0.2.38 — 2026-11-04 **模型列表再按订阅档位收窄:basic 账号不再看到 pro / ultra 的池** diff --git a/package.json b/package.json index af2f487..17b3ab8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "magpie-monkeycode", - "version": "0.2.38", + "version": "0.2.39", "description": "MonkeyCode 国内站 / 国际站 + 百智云 magpie 插件:三家独立供应商,浏览器登录或 API Key 接入,网关签名与积分额度。", "type": "module", "main": "./index.mjs", diff --git a/src/monkeycode/runtime.mjs b/src/monkeycode/runtime.mjs index b49a1ac..dfbcdcf 100644 --- a/src/monkeycode/runtime.mjs +++ b/src/monkeycode/runtime.mjs @@ -680,6 +680,36 @@ export function makePlugin(site) { if (body === undefined && request) body = await request.clone().text() let text = typeof body === "string" ? body : undefined + // ── 把 magpie 的模型 id 换回上游认得的那串 ────────────────── + // + // magpie 发过来的 `model` 是**它自己的模型 id**(我们返回的 `id`, + // 斜杠被压成连字符,例如 `monkeycode-basic-deepseek-flash`)—— + // 它不会用我们在 config 里给的 `api.id`: + // magpie: `api: { id: m.id ?? was?.api.id ?? key }`(m 是 config 的条目) + // 而上游只认**平台原值**(带档位前缀的 `monkeycode-basic/deepseek-flash`): + // 2026-11-04 实测 —— 原值 200,slug 回 `403 Forbidden`, + // UUID 回 `403 Forbidden`,裸名 `deepseek-flash` 也 403。 + // + // 所以这一层必须换回来。映射来源就是目录缓存(`id → apiId`)。 + // 换完**再**补 system、**再**算签名:签名覆盖的是 prompt(system), + // 与 model 无关,但顺序上仍以「最终要发出去的 body」为准最稳。 + if (text !== undefined) { + const catalog = readCatalog(site.id) + if (catalog !== undefined && catalog.models.length > 0) { + const byId = new Map(catalog.models.map((model) => [model.id, model.apiId])) + try { + const parsed = JSON.parse(text) + const key = typeof parsed.model === "string" ? parsed.model : "" + const mapped = byId.get(key) + if (mapped !== undefined && mapped !== key && mapped.length > 0) { + parsed.model = mapped + text = JSON.stringify(parsed) + body = text + } + } catch {} + } + } + // 网关要求被签名的 prompt 非空:没有 system 的请求它会一律回 // `403 invalid ohmyagent request`(2026-11-04 实测:不带 system 的 // 探测 403、带 system 的同样请求 200)。用户直接对话时 magpie 经常 diff --git a/tests/loader-model-map.test.mjs b/tests/loader-model-map.test.mjs new file mode 100644 index 0000000..7c34134 --- /dev/null +++ b/tests/loader-model-map.test.mjs @@ -0,0 +1,111 @@ +// tests/loader-model-map.test.mjs —— 发出去的 `model` 必须是上游认得的那串 +// +// 2026-11-04 真机:国际站两条模型全部 `403 Forbidden`。直连上游用同一份凭据试: +// +// monkeycode-basic/deepseek-flash → 200 +// monkeycode-basic-deepseek-flash(slug) → 403 Forbidden +// → 403 Forbidden +// deepseek-flash(裸名) → 403 Forbidden +// +// 根因在 magpie 侧:它构造上游请求时用的是 `api: { id: m.id ?? … }` —— **我们返回的 +// `id`**(斜杠被压成连字符),config 里给的 `api.id` 被无视。而 magpie 的模型 id 又 +// 不能带斜杠(会被当成 provider 边界,长出 `monkeycode-basic` / `monkeycode-pro` / +// `monkeycode-ultra` 三个不存在的供应商)。 +// +// 所以只能在 loader.fetch 里换回来。这个测试锁的就是这一步。 + +import test from "node:test" +import assert from "node:assert/strict" +import { mkdtempSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const dir = mkdtempSync(join(tmpdir(), "magpie-modelmap-")) +process.env.MAGPIE_MONKEYCODE_CACHE_DIR = dir +process.env.MAGPIE_PLUGIN_DISABLE_BROWSER = "1" + +writeFileSync(join(dir, "monkeycode-models-monkeycodeintl.json"), JSON.stringify({ + fetchedAt: Date.now(), + models: [{ + id: "monkeycode-basic-deepseek-flash", + apiId: "monkeycode-basic/deepseek-flash", + name: "monkeycode-basic/deepseek-flash", + displayName: "DeepSeek-Flash · 基础档", + context: 1048576, + output: 32000, + image: true, + free: false, + }], +}), "utf8") + +const { MonkeyCodeIntlAuthPlugin } = await import("../index.mjs") + +const AUTH = { + type: "api", + key: JSON.stringify({ access_token: "oma_testtesttesttest", signing_secret: "secret-secret-secret" }), +} + +/** 跑一次 loader.fetch,返回真正发出去的东西。 */ +async function send(body, initExtra = {}) { + const plugin = await MonkeyCodeIntlAuthPlugin({}, {}) + const loader = await plugin.auth.loader(async () => AUTH) + let captured + const realFetch = globalThis.fetch + globalThis.fetch = async (url, init) => { + captured = { url, headers: init?.headers, body: init?.body } + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }) + } + try { + await loader.fetch(`${loader.baseURL}/messages`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + ...initExtra, + }) + } finally { + globalThis.fetch = realFetch + } + return captured +} + +test("magpie 的模型 id 被换回平台原值(带档位前缀),不是 slug", async () => { + const captured = await send({ + model: "monkeycode-basic-deepseek-flash", + max_tokens: 8, + messages: [{ role: "user", content: "hi" }], + }) + const sent = JSON.parse(captured.body) + assert.equal(sent.model, "monkeycode-basic/deepseek-flash") +}) + +test("已经是上游原值时不动它(幂等)", async () => { + const captured = await send({ + model: "monkeycode-basic/deepseek-flash", + max_tokens: 8, + messages: [{ role: "user", content: "hi" }], + }) + assert.equal(JSON.parse(captured.body).model, "monkeycode-basic/deepseek-flash") +}) + +test("目录里没有的 model 原样放行(不猜、不改)", async () => { + const captured = await send({ + model: "some-unknown-model", + max_tokens: 8, + messages: [{ role: "user", content: "hi" }], + }) + assert.equal(JSON.parse(captured.body).model, "some-unknown-model") +}) + +test("改完 model 之后补 system、并按最终 body 签名", async () => { + const captured = await send({ + model: "monkeycode-basic-deepseek-flash", + max_tokens: 8, + messages: [{ role: "user", content: "hi" }], + }) + const sent = JSON.parse(captured.body) + assert.equal(typeof sent.system, "string") + assert.ok(sent.system.length > 0, "没 system 的请求上游一律 403 invalid ohmyagent request") + const headers = captured.headers instanceof Headers ? Object.fromEntries(captured.headers) : captured.headers + const signature = headers["x-ohmyagent-signature"] ?? headers["X-OhmyAgent-Signature"] + assert.ok(String(signature).startsWith("v1="), "签名头必须存在") +})