magicrew-auth 0.3.18 → 0.3.24
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 +14 -2
- package/CHANGELOG.md +218 -0
- package/docs/internals.md +179 -4
- package/docs/upstream-issue-3950.md +90 -0
- package/package.json +1 -1
- package/src/headless.mjs +129 -22
- package/src/hooks/loader.mjs +234 -93
- package/src/hooks/methods.mjs +38 -9
- package/src/hooks/usage.mjs +124 -22
- package/src/keys.mjs +48 -1
package/src/headless.mjs
CHANGED
|
@@ -118,24 +118,73 @@ export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, de
|
|
|
118
118
|
|
|
119
119
|
const { jwt, org } = extractLoginCreds(json)
|
|
120
120
|
if (!jwt) {
|
|
121
|
-
throw Object.assign(
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
throw Object.assign(
|
|
122
|
+
new Error(`登录成功但响应里没找到可用令牌。响应结构:${describeShape(json)}`),
|
|
123
|
+
{ raw: json },
|
|
124
|
+
)
|
|
124
125
|
}
|
|
125
126
|
// 响应里给了组织码就用它;没给就把该账号的组织**全列出来**,
|
|
126
|
-
// 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn
|
|
127
|
-
// 只取第一个是不够的:多组织账号会落到「列表第一个」而不是用户平时用的那个。
|
|
127
|
+
// 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn)。
|
|
128
128
|
const orgs = org ? [org] : await fetchOrgs(site, jwt)
|
|
129
129
|
if (!orgs.length) {
|
|
130
|
-
throw Object.assign(
|
|
130
|
+
throw Object.assign(
|
|
131
|
+
new Error(
|
|
132
|
+
`登录成功但拿不到组织码。\n` +
|
|
133
|
+
` · 登录响应结构:${describeShape(json)}\n` +
|
|
134
|
+
` · 用该令牌调 login-data 的探测结果:${orgProbeErrors().join(" / ") || "(没探)"}\n` +
|
|
135
|
+
` (前端拿 access_token 直接 setAuthorization 后就用它调这个接口)`,
|
|
136
|
+
),
|
|
137
|
+
{ raw: json },
|
|
138
|
+
)
|
|
131
139
|
}
|
|
132
140
|
return { jwt, org: orgs[0], orgs, raw: json }
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
/**
|
|
144
|
+
* 把一段 JSON 的形状描述出来:**只有键名和类型,没有任何值**。
|
|
145
|
+
*
|
|
146
|
+
* 用来让真机上的报错自带诊断信息 —— 用户截一张图,就能看出服务端到底回了什么结构,
|
|
147
|
+
* 不用反复试。token 之类的东西一律不会出现在这里。
|
|
148
|
+
*/
|
|
149
|
+
export function describeShape(o, depth = 0) {
|
|
150
|
+
if (o === null) return "null"
|
|
151
|
+
if (o === undefined) return "undefined"
|
|
152
|
+
const t = typeof o
|
|
153
|
+
if (t !== "object") return t === "string" ? `str(${String(o).length})` : t
|
|
154
|
+
if (depth >= 3) return "…"
|
|
155
|
+
if (Array.isArray(o)) {
|
|
156
|
+
return `[${o.length}]` + (o.length ? describeShape(o[0], depth + 1) : "")
|
|
157
|
+
}
|
|
158
|
+
const keys = Object.keys(o).slice(0, 24)
|
|
159
|
+
return "{" + keys.map((k) => `${k}:${describeShape(o[k], depth + 1)}`).join(", ") + "}"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 从登录响应里取「授权令牌」和「组织码」。
|
|
164
|
+
*
|
|
165
|
+
* ⚠ 登录返回的**不是 JWT,是 `access_token`** —— 前端拿到之后立刻
|
|
166
|
+
* `setAuthorization(access_token)` 并用它调 `getUserProfile`。
|
|
167
|
+
* 早先这里只按 `eyJ` 前缀找 JWT,结果在响应里挑到了别的字段,
|
|
168
|
+
* 拿去调 `login-data` 自然拿不到组织(真机表现就是
|
|
169
|
+
* 「登录成功但拿不到组织码」)。
|
|
170
|
+
*/
|
|
135
171
|
function extractLoginCreds(json) {
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
172
|
+
const d = json?.data ?? json ?? {}
|
|
173
|
+
const pick = (...keys) => {
|
|
174
|
+
for (const k of keys) {
|
|
175
|
+
const v = d?.[k]
|
|
176
|
+
if (typeof v === "string" && v.trim()) return v.trim()
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
// 优先认登录接口自己给的字段名
|
|
181
|
+
const token =
|
|
182
|
+
pick("access_token", "accessToken", "token", "authorization", "teamshare_token") ??
|
|
183
|
+
extractCreds(json).jwt
|
|
184
|
+
const org =
|
|
185
|
+
pick("organization_code", "organizationCode", "magic_organization_code") ??
|
|
186
|
+
extractCreds(json).org
|
|
187
|
+
return { jwt: token, org }
|
|
139
188
|
}
|
|
140
189
|
|
|
141
190
|
/**
|
|
@@ -143,22 +192,80 @@ function extractLoginCreds(json) {
|
|
|
143
192
|
*
|
|
144
193
|
* ⚠ 浏览器登录能直接读到用户**当前选中**的组织(localStorage 里的
|
|
145
194
|
* `MAGIC:user:organizationCode`),无头这条路没有那个信息 —— 所以只能把候选
|
|
146
|
-
*
|
|
147
|
-
*
|
|
195
|
+
* 全拿回来,让调用方逐个验,验不过再换下一个。
|
|
196
|
+
*
|
|
197
|
+
* ⚠ `authorization` 头**两种写法都要试**:前端这个接口是
|
|
198
|
+
* `h.set("authorization", authorization)` —— 直接放 token,**没有 `Bearer ` 前缀**;
|
|
199
|
+
* 但平台其它接口(`/api/v1/flows/models` 等)走的是 `Bearer <jwt>` 并且能通。
|
|
200
|
+
* 登录拿到的是 438 字符的 `access_token`,和网页 localStorage 里那把 354 字符的不是
|
|
201
|
+
* 同一个东西,所以不能想当然 —— 两种都打,先成的算。
|
|
202
|
+
* 全失败时把每一步的真实原因记在模块变量里,供上层错误信息引用。
|
|
148
203
|
*/
|
|
204
|
+
let lastOrgProbeErrors = []
|
|
205
|
+
export function orgProbeErrors() {
|
|
206
|
+
return lastOrgProbeErrors.slice()
|
|
207
|
+
}
|
|
208
|
+
|
|
149
209
|
export async function fetchOrgs(site, jwt) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
210
|
+
lastOrgProbeErrors = []
|
|
211
|
+
|
|
212
|
+
// 三个端点 × 两种 authorization 写法,全试一遍 —— 它们对令牌形态的宽容度不一样:
|
|
213
|
+
// · login-data 前端是 h.set("authorization", token)(无 Bearer),但它同时
|
|
214
|
+
// 接受 `Bearer <网页版 JWT>`(用本机 354 字符 JWT 实测通过)
|
|
215
|
+
// · me/organizations 实测连 organization-code 头都不要,只认令牌
|
|
216
|
+
// · auth/status 前端 bindMagicAuthorization 用它,返回 Array<MagicOrganization>
|
|
217
|
+
// 登录拿到的是 438 字符的 access_token,和网页 localStorage 那把 354 的不同,
|
|
218
|
+
// 所以不能想当然只打一个。
|
|
219
|
+
const probes = [
|
|
220
|
+
{ tag: "login-data/Bearer", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
|
|
221
|
+
{ tag: "login-data/裸", url: `${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, auth: jwt, pick: (j) => j?.data?.auth_status ?? j?.auth_status },
|
|
222
|
+
{ tag: "auth-status/Bearer", url: `${site.host}/api/v1/auth/status?login_code=`, auth: `Bearer ${jwt}`, pick: (j) => j?.data ?? j },
|
|
223
|
+
{ tag: "auth-status/裸", url: `${site.host}/api/v1/auth/status?login_code=`, auth: jwt, pick: (j) => j?.data ?? j },
|
|
224
|
+
{ tag: "me-organizations/Bearer", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: `Bearer ${jwt}`, pick: (j) => j?.data?.items ?? j?.items },
|
|
225
|
+
{ tag: "me-organizations/裸", url: `${site.host}/api/v1/contact/accounts/me/organizations`, auth: jwt, pick: (j) => j?.data?.items ?? j?.items },
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
for (const p of probes) {
|
|
229
|
+
let res = null
|
|
230
|
+
try {
|
|
231
|
+
res = await fetch(p.url, {
|
|
232
|
+
headers: { authorization: p.auth, "user-agent": "magpie-magicrew-auth" },
|
|
233
|
+
signal: AbortSignal.timeout(20_000),
|
|
234
|
+
})
|
|
235
|
+
} catch (e) {
|
|
236
|
+
lastOrgProbeErrors.push(`${p.tag}:网络失败 ${String(e?.message ?? e).slice(0, 60)}`)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const text = await res.text().catch(() => "")
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
lastOrgProbeErrors.push(`${p.tag}:HTTP ${res.status} ${text.slice(0, 70)}`)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
let j = null
|
|
246
|
+
try {
|
|
247
|
+
j = JSON.parse(text)
|
|
248
|
+
} catch {
|
|
249
|
+
lastOrgProbeErrors.push(`${p.tag}:非 JSON ${text.slice(0, 70)}`)
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (j?.code !== 1000) {
|
|
253
|
+
lastOrgProbeErrors.push(`${p.tag}:code ${j?.code} ${j?.message ?? ""}`.trim())
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
const list = p.pick(j)
|
|
257
|
+
if (!Array.isArray(list) || !list.length) {
|
|
258
|
+
lastOrgProbeErrors.push(`${p.tag}:200 但列表为空`)
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
const orgs = parseOrgs(list)
|
|
262
|
+
if (orgs.length) return orgs
|
|
263
|
+
lastOrgProbeErrors.push(`${p.tag}:有 ${list.length} 项但取不到组织码`)
|
|
264
|
+
}
|
|
265
|
+
return []
|
|
266
|
+
}
|
|
161
267
|
|
|
268
|
+
function parseOrgs(list) {
|
|
162
269
|
// ⚠ 字段名是 `magic_organization_code`(实测)。`organization_code` 只在**外层**存在,
|
|
163
270
|
// 而且是空串 —— 认它就会拿到 ""。每项里另有 third_platform_ / teamshare_ 两份同值码。
|
|
164
271
|
const KEYS = [
|
package/src/hooks/loader.mjs
CHANGED
|
@@ -1,93 +1,234 @@
|
|
|
1
|
-
// ④ auth.loader(getAuth) —— 请求接管
|
|
2
|
-
//
|
|
3
|
-
// 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
|
|
4
|
-
// 这是唯一能改请求体的地方。本项目用它干两件事:
|
|
5
|
-
//
|
|
6
|
-
// · 国际站网关强制要 organization-code(国内站不要),不带就是
|
|
7
|
-
// HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
|
|
8
|
-
// · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
|
|
9
|
-
//
|
|
10
|
-
// ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
|
|
11
|
-
// 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
|
|
12
|
-
|
|
13
|
-
import { ensureKey, accountOf } from "../keys.mjs"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
1
|
+
// ④ auth.loader(getAuth) —— 请求接管
|
|
2
|
+
//
|
|
3
|
+
// 返回 baseURL / apiKey / headers / fetch(a,b),magpie 拿它发真实请求。
|
|
4
|
+
// 这是唯一能改请求体的地方。本项目用它干两件事:
|
|
5
|
+
//
|
|
6
|
+
// · 国际站网关强制要 organization-code(国内站不要),不带就是
|
|
7
|
+
// HTTP 500 "Internal Server Error."(22 字节纯文本,不是 JSON)
|
|
8
|
+
// · 推理模型低于 512 的 max_tokens 会被 reasoning 吃光,content 返回空串
|
|
9
|
+
//
|
|
10
|
+
// ⚠ 不要在这里读流、不要重试换号、不要手拼 x-api-key ——
|
|
11
|
+
// 前两件 magpie 做了,第三件看 SDK 包自动加。见 README「最小交付面」。
|
|
12
|
+
|
|
13
|
+
import { ensureKey, accountOf } from "../keys.mjs"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 把 JSON Schema 里的 `additionalProperties` **非布尔值**原地换成 `true`。
|
|
17
|
+
*
|
|
18
|
+
* 上游只认布尔值,收到任何对象都回 HTTP 400
|
|
19
|
+
* `{"error":{"message":"模型响应失败","code":4017}}`,整个请求被拒。
|
|
20
|
+
* 实测(2026-10-28,glm-5.3-flash 与 deepseek-v4.1-flash **两个模型完全一致**,
|
|
21
|
+
* 同一把 key、同一份请求、只改这一个字段):
|
|
22
|
+
*
|
|
23
|
+
* additionalProperties: 缺省 → 200
|
|
24
|
+
* additionalProperties: false → 200
|
|
25
|
+
* additionalProperties: true → 200
|
|
26
|
+
* additionalProperties: {} → 400 code 4017
|
|
27
|
+
* additionalProperties: {type:"string"} → 400 code 4017
|
|
28
|
+
* additionalProperties: {type:"string",description:"x"} → 400 code 4017
|
|
29
|
+
*
|
|
30
|
+
* ⚠ 注意**不只是空对象**。有约束的 schema 形态(`{type:"string"}`)一样被拒 ——
|
|
31
|
+
* 这一点比"上游只认 true/false"更进一步:它根本不支持 schema 形态。
|
|
32
|
+
* 所以这里一律换成 `true`,属于**降级放宽**:
|
|
33
|
+
*
|
|
34
|
+
* · `{}` → `true` 语义等价(都是"任意,不加约束")
|
|
35
|
+
* · `{type:"string"}` → `true` **不等价** —— 丢掉了"额外属性必须是 string"的约束
|
|
36
|
+
*
|
|
37
|
+
* 为什么接受这个不等价:上游无法表达它,保留原值就是整个请求失败。两害相权,
|
|
38
|
+
* 让工具调用能跑(可能多出未声明的字段)比让 agent 完全不可用要好。方向也安全 ——
|
|
39
|
+
* 是放宽不是收紧,不会让原本合法的调用被拒。
|
|
40
|
+
*
|
|
41
|
+
* 命中来源:ZCode / Claude Code 系客户端生成 schema 时爱写 `{}`;
|
|
42
|
+
* 一次真实请求里 54 个工具中的 `ExitPlanMode` 与 `CreateWorkflow` 就这么写。
|
|
43
|
+
* 客户端没写错(按规范 `{}` 与 `true` 等价),是上游兼容性不足,插件这边兜住。
|
|
44
|
+
*
|
|
45
|
+
* @returns {boolean} 有没有改到
|
|
46
|
+
*/
|
|
47
|
+
export function loosenAdditionalProperties(node) {
|
|
48
|
+
if (!node || typeof node !== "object") return false
|
|
49
|
+
if (Array.isArray(node)) {
|
|
50
|
+
let any = false
|
|
51
|
+
for (const n of node) if (loosenAdditionalProperties(n)) any = true
|
|
52
|
+
return any
|
|
53
|
+
}
|
|
54
|
+
let changed = false
|
|
55
|
+
for (const [k, v] of Object.entries(node)) {
|
|
56
|
+
if (k === "additionalProperties") {
|
|
57
|
+
// 布尔值上游认,原样留着(false 是"不许额外属性",语义相反,绝对不能动)
|
|
58
|
+
if (typeof v !== "boolean") {
|
|
59
|
+
node[k] = true
|
|
60
|
+
changed = true
|
|
61
|
+
}
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
if (v && typeof v === "object" && loosenAdditionalProperties(v)) changed = true
|
|
65
|
+
}
|
|
66
|
+
return changed
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 上游的 JSON Schema 校验器不接受 `items: {}`,整轮请求回 HTTP 400:
|
|
71
|
+
*
|
|
72
|
+
* {"error":{"code":null,"message":"超级麦吉: HTTP 400 请求错误: {\"error\":
|
|
73
|
+
* {\"code\":\"InvalidParameter\",\"message\":\"tools.40.function.parameters/
|
|
74
|
+
* properties/functions/items: invalid JSON Schema; check the schema
|
|
75
|
+
* keywords and references Request id: ...\",\"param\":\"\",\"type\":
|
|
76
|
+
* \"BadRequest\"}}","param":null,"type":"invalid_request_error"}}
|
|
77
|
+
*
|
|
78
|
+
* 报错只给路径,不说是哪个客户端。实测(2026-10-28,同一把 key、同一份请求、
|
|
79
|
+
* 只改 tools[0] 的这一处;国内站 `magicrew` 与国际站 `magicrew-intl` 逐行一致):
|
|
80
|
+
*
|
|
81
|
+
* items: 缺省 → 200
|
|
82
|
+
* items: {} → 400 invalid JSON Schema
|
|
83
|
+
* items: {description:"x"} → 200 // 空对象才炸,不是"缺 type"
|
|
84
|
+
* items: {type:"string"} → 200
|
|
85
|
+
* items: true → 400 模型响应失败(另一种 400)
|
|
86
|
+
* items: false → 400 模型响应失败
|
|
87
|
+
*
|
|
88
|
+
* 为什么可以删:JSON Schema 里 `items` 的缺省值就是 `{}` —— "任意元素"。
|
|
89
|
+
* 所以 `items: {}` 删掉是**严格等价**(`true` 同理),不是降级放宽。
|
|
90
|
+
* `items: false` 表示"数组必须为空",语义不同,不能删,原样留着。
|
|
91
|
+
*
|
|
92
|
+
* 命中来源:客户端用 `z.array(z.unknown())` / 动态函数列表生成工具 schema 时,
|
|
93
|
+
* 数组元素会序列化成 `{}`。一个真实请求里第 41 个工具的参数是
|
|
94
|
+
* `properties.functions.items: {}`,于是整轮失败。
|
|
95
|
+
*
|
|
96
|
+
* @returns {boolean} 有没有改到
|
|
97
|
+
*/
|
|
98
|
+
export function loosenEmptyItems(node) {
|
|
99
|
+
if (!node || typeof node !== "object") return false
|
|
100
|
+
if (Array.isArray(node)) {
|
|
101
|
+
let any = false
|
|
102
|
+
for (const n of node) if (loosenEmptyItems(n)) any = true
|
|
103
|
+
return any
|
|
104
|
+
}
|
|
105
|
+
let changed = false
|
|
106
|
+
for (const [k, v] of Object.entries(node)) {
|
|
107
|
+
if (k === "items") {
|
|
108
|
+
const emptyObject =
|
|
109
|
+
v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0
|
|
110
|
+
if (emptyObject || v === true) {
|
|
111
|
+
delete node[k]
|
|
112
|
+
changed = true
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (v && typeof v === "object" && loosenEmptyItems(v)) changed = true
|
|
117
|
+
}
|
|
118
|
+
return changed
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 改写发往上游的请求体。**一次 parse,四处改写都在这**。
|
|
123
|
+
*
|
|
124
|
+
* 四处都是实测出来的,不是猜的:
|
|
125
|
+
*
|
|
126
|
+
* ① max_tokens 抬到 512
|
|
127
|
+
* 实测 glm-5.3-flash:64 → finish_reason:"length" 且 content:""
|
|
128
|
+
* 512 → 正常出话(reasoning 208 / total 233)
|
|
129
|
+
*
|
|
130
|
+
* ② 流式请求补 stream_options.include_usage
|
|
131
|
+
* magicrew 在 stream:true 下**默认不返回 usage**;补上之后,[DONE]
|
|
132
|
+
* 之前会多一条 {"choices":[],"usage":{...}} 的收尾 chunk。
|
|
133
|
+
* magpie 新版靠这条收尾 chunk 判定「流正常结束了」——缺了它就报
|
|
134
|
+
* 「Stream ended without finish_reason」然后重试(实测 2026-10-27)。
|
|
135
|
+
*
|
|
136
|
+
* ③ tools 里的 additionalProperties: {} → true(见 looser 的注释)
|
|
137
|
+
*
|
|
138
|
+
* ④ tools 里的 items: {} → 删除(见 loosenEmptyItems 的注释)
|
|
139
|
+
*
|
|
140
|
+
* 没改到时返回原始字符串(省一次序列化 —— 54 个工具的请求体不小)。
|
|
141
|
+
*
|
|
142
|
+
* @param {unknown} raw
|
|
143
|
+
* @returns {unknown} 改了就是新字符串,没改就原样返回
|
|
144
|
+
*/
|
|
145
|
+
export function rewriteRequestBody(raw) {
|
|
146
|
+
if (typeof raw !== "string") return raw
|
|
147
|
+
// 粗筛:三个关键字一个都没有,就不必 parse
|
|
148
|
+
if (!/"stream"|"max_tokens"|"tools"/.test(raw)) return raw
|
|
149
|
+
|
|
150
|
+
let j
|
|
151
|
+
try {
|
|
152
|
+
j = JSON.parse(raw)
|
|
153
|
+
} catch {
|
|
154
|
+
return raw
|
|
155
|
+
}
|
|
156
|
+
if (!j || typeof j !== "object") return raw
|
|
157
|
+
|
|
158
|
+
let changed = false
|
|
159
|
+
|
|
160
|
+
if (Number(j.max_tokens) > 0 && Number(j.max_tokens) < 512) {
|
|
161
|
+
j.max_tokens = 512
|
|
162
|
+
changed = true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (j.stream === true && !j.stream_options) {
|
|
166
|
+
j.stream_options = { include_usage: true }
|
|
167
|
+
changed = true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (Array.isArray(j.tools)) {
|
|
171
|
+
for (const t of j.tools) {
|
|
172
|
+
const p = t?.function?.parameters
|
|
173
|
+
if (!p || typeof p !== "object") continue
|
|
174
|
+
if (loosenAdditionalProperties(p)) changed = true
|
|
175
|
+
if (loosenEmptyItems(p)) changed = true
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return changed ? JSON.stringify(j) : raw
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function makeLoader(c) {
|
|
183
|
+
return async (getAuth) => {
|
|
184
|
+
const auth = await getAuth()
|
|
185
|
+
if (!auth) return {}
|
|
186
|
+
return {
|
|
187
|
+
baseURL: c.BASE,
|
|
188
|
+
apiKey: auth.access,
|
|
189
|
+
headers: { "user-agent": "MagicrewClient/1.0" },
|
|
190
|
+
async fetch(input, init) {
|
|
191
|
+
const fresh = await getAuth()
|
|
192
|
+
if (!fresh) throw new Error(`${c.SHORT} 未登录`)
|
|
193
|
+
|
|
194
|
+
// key 只有一个来源(src/keys.mjs)—— 服务端签一把顶一把,多入口必互相吊销
|
|
195
|
+
const key = await ensureKey(c, getAuth)
|
|
196
|
+
if (!key) throw new Error(`${c.SHORT} 未登录(拿不到可用的 key)`)
|
|
197
|
+
const account = accountOf(fresh)
|
|
198
|
+
|
|
199
|
+
const req = input instanceof Request ? input : null
|
|
200
|
+
const url =
|
|
201
|
+
typeof input === "string"
|
|
202
|
+
? input
|
|
203
|
+
: input instanceof URL
|
|
204
|
+
? input.toString()
|
|
205
|
+
: (req?.url ?? "")
|
|
206
|
+
|
|
207
|
+
const incoming = new Headers(init?.headers ?? req?.headers ?? {})
|
|
208
|
+
const headers = {
|
|
209
|
+
authorization: `Bearer ${key.apiKey}`,
|
|
210
|
+
"content-type": "application/json",
|
|
211
|
+
}
|
|
212
|
+
if (c.needsOrgOnGateway && fresh.org) headers["organization-code"] = fresh.org
|
|
213
|
+
|
|
214
|
+
for (const [k, v] of incoming) {
|
|
215
|
+
if (["authorization", "host", "content-length", "user-agent"].includes(k.toLowerCase())) continue
|
|
216
|
+
headers[k] = v
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let body = init?.body
|
|
220
|
+
if (body === undefined && req) body = await req.text()
|
|
221
|
+
|
|
222
|
+
// 四处请求体改写,全在 rewriteRequestBody 里(那里有实测出处)
|
|
223
|
+
body = rewriteRequestBody(body)
|
|
224
|
+
|
|
225
|
+
return fetch(url, {
|
|
226
|
+
method: init?.method ?? "POST",
|
|
227
|
+
headers,
|
|
228
|
+
body,
|
|
229
|
+
signal: init?.signal ?? req?.signal ?? AbortSignal.timeout(300_000),
|
|
230
|
+
})
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/hooks/methods.mjs
CHANGED
|
@@ -44,10 +44,17 @@ function kickoffFirstTask(c, jwt, org) {
|
|
|
44
44
|
* 记组织 / 滚邀请码 / 补首任务 / 取昵称」这一串只有一份实现。
|
|
45
45
|
*
|
|
46
46
|
* `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
|
|
47
|
-
* 按顺序用 `fetchPoints`
|
|
48
|
-
*
|
|
47
|
+
* 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算。
|
|
48
|
+
*
|
|
49
|
+
* ⚠ 别说这解决了「多组织选哪个」—— **它没解决,只是容错**:
|
|
50
|
+
* 账号所属的任何一个组织都能通过 `fetchPoints`(只要 JWT 有效),
|
|
51
|
+
* 所以「第一个验得过的」实际就是「列表第一个」。
|
|
52
|
+
* 真正的区别在浏览器登录那条路:它直接读 localStorage 里用户**当前选中**的那个。
|
|
53
|
+
* 服务端虽然给了 `is_current` 字段,但实测恒为 false(带不带 organization-code
|
|
54
|
+
* 头都一样),不能拿来判断。⇒ 多组织账号在无头这条路上会落到列表第一个组织,
|
|
55
|
+
* 这是已知限制,不是 bug。
|
|
49
56
|
*/
|
|
50
|
-
async function completeSignIn(c, jwt, org, orgs = []) {
|
|
57
|
+
async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null) {
|
|
51
58
|
const candidates = [org, ...orgs.filter((o) => o && o !== org)]
|
|
52
59
|
let lastErr = null
|
|
53
60
|
let picked = null
|
|
@@ -71,12 +78,23 @@ async function completeSignIn(c, jwt, org, orgs = []) {
|
|
|
71
78
|
await rollInvite(c.id, jwt, picked, c.host)
|
|
72
79
|
kickoffFirstTask(c, jwt, picked)
|
|
73
80
|
const who = await fetchWho(jwt, picked, c.host).catch(() => null)
|
|
81
|
+
|
|
82
|
+
// ⚠ `accountId` 决定 magpie 是「替换」还是「新增」—— 同一个账号在两条登录路上
|
|
83
|
+
// 必须算出同一个字符串,否则就会出现「同一个号被加了两遍」。
|
|
84
|
+
//
|
|
85
|
+
// 真机踩过:浏览器登录拿到的是打码手机号 `189****051`(fetchWho 成功,
|
|
86
|
+
// 服务端只给打码号);无头那条路 fetchWho 挂了(438 字符的 access_token
|
|
87
|
+
// 配 Bearer 头不通),于是兜底用了组织码 `h97gkbu8bruj` ⇒ 两条并存。
|
|
88
|
+
//
|
|
89
|
+
// 所以优先用**调用方明确给的** accountId(无头路径里就是它自己算出来的打码手机号),
|
|
90
|
+
// `who.name` 只作为补充,组织码是最后一档 —— 它绝不该当账号名。
|
|
91
|
+
const accountId = preferredAccountId || who?.name || picked
|
|
74
92
|
return {
|
|
75
93
|
type: "success",
|
|
76
94
|
access: key.apiKey,
|
|
77
95
|
refresh: key.refreshToken,
|
|
78
96
|
expires: Date.now() + 2 * 3600_000,
|
|
79
|
-
accountId
|
|
97
|
+
accountId,
|
|
80
98
|
uid: picked,
|
|
81
99
|
jwt,
|
|
82
100
|
org: picked,
|
|
@@ -109,7 +127,13 @@ export function makeMethods(c) {
|
|
|
109
127
|
],
|
|
110
128
|
async authorize(inputs) {
|
|
111
129
|
const phone = String(inputs?.phone || "").replace(/[\s-]/g, "")
|
|
112
|
-
|
|
130
|
+
// ⚠ authorize() 里**不能**返回 { type: "failed" }:magpie 把这里的返回值当作
|
|
131
|
+
// 「登录流程对象」,会去调它的 `.callback()` —— 少了这个函数就是一句
|
|
132
|
+
// `callback is not a function`,用户只看到莫名其妙的报错。
|
|
133
|
+
// 实测触发路径:callback 里失败 → magpie 显示「重试」 → 点重试会**重新调
|
|
134
|
+
// authorize** → 这次走到这条失败分支 → 返回没有 callback 的对象 → 崩。
|
|
135
|
+
// ⇒ authorize 里失败一律抛异常。
|
|
136
|
+
if (!/^1\d{10}$/.test(phone)) throw new Error("手机号不完整")
|
|
113
137
|
|
|
114
138
|
const vision = readVisionModel()
|
|
115
139
|
const masked = phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
|
|
@@ -195,7 +219,9 @@ export function makeMethods(c) {
|
|
|
195
219
|
try {
|
|
196
220
|
sent = await startSmsLogin(c, { mobile: phone })
|
|
197
221
|
} catch (e) {
|
|
198
|
-
|
|
222
|
+
// 同上:这里也不能 return failed —— 点「重试」会重新走 authorize,
|
|
223
|
+
// 返回没有 callback 的对象会让 magpie 崩在 `.callback()`
|
|
224
|
+
throw new Error(String(e?.message ?? e).slice(0, 500))
|
|
199
225
|
}
|
|
200
226
|
|
|
201
227
|
return {
|
|
@@ -220,7 +246,9 @@ export function makeMethods(c) {
|
|
|
220
246
|
return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
|
|
221
247
|
}
|
|
222
248
|
try {
|
|
223
|
-
|
|
249
|
+
// 传 masked(打码手机号)当 accountId —— 浏览器登录那条路拿到的也是
|
|
250
|
+
// 这个字符串,两边一致才不会把同一个号加成两条
|
|
251
|
+
return await completeSignIn(c, creds.jwt, creds.org, creds.orgs, masked)
|
|
224
252
|
} catch (e) {
|
|
225
253
|
return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
|
|
226
254
|
}
|
|
@@ -407,8 +435,9 @@ export function makeMethods(c) {
|
|
|
407
435
|
async authorize(inputs) {
|
|
408
436
|
const jwt = String(inputs?.jwt || "").trim().replace(/^Bearer\s+/i, "")
|
|
409
437
|
const org = String(inputs?.org || "").trim()
|
|
410
|
-
|
|
411
|
-
if (!
|
|
438
|
+
// 同一条规矩:authorize 里失败只抛异常,不返回 { type: "failed" }
|
|
439
|
+
if (!jwt.startsWith("eyJ")) throw new Error("不是 JWT")
|
|
440
|
+
if (!org) throw new Error("没填组织码")
|
|
412
441
|
|
|
413
442
|
const result = (async () => {
|
|
414
443
|
await fetchPoints(jwt, org, c.host)
|