@fish-under-sea/opencode-meoo-auth 0.0.0-stage → 1.0.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/index.mjs ADDED
@@ -0,0 +1,817 @@
1
+ // OpenCode & Magpie provider plugin for Meoo 秒悟 (meoo.com).
2
+ //
3
+ // 由 dsh-meoo-connect(dsh/DeepSeek Harness 插件)迁移而来,同一套 Meoo
4
+ // 官方「AI 服务」链路,换到 Magpie 的 provider 插件平面上:
5
+ //
6
+ // 登录 OAuth Authorization Code + PKCE(public client,本机 loopback 回调)
7
+ // 项目 AK 控制面 /open/v1/cli-compat/...(身份 → 项目 → 云服务 environment)
8
+ // 模型目录 网关 {gateway}/models(OpenAI 兼容,Bearer 项目服务 AK)
9
+ // 转发 不自己做适配器——声明 @ai-sdk/openai-compatible,由宿主 SDK 走网关
10
+ //
11
+ // 三种登录方式:
12
+ // 1. 浏览器授权:点一次「授权」,之后身份/项目/云服务 AK 全自动解析。
13
+ // 2. 复用本机登录态:只读 dsh-meoo-connect($DSH_HOME/meoo/state.json)或
14
+ // meoo-proxy(~/.meoo-proxy/config.json)已保存的登录态,连授权点击都省了。
15
+ // 3. 手填项目服务 AK:meoo_project_service_v1.* 开头,适合不想走 OAuth 的人。
16
+ //
17
+ // 凭证只存放在宿主的账号记录里(client.auth.set),本插件不落地任何文件。
18
+ // 调用消耗 Meoo 账号自身额度,不绕过任何计费;与 Meoo 官方无关联。
19
+
20
+ import { createHash, randomBytes } from "node:crypto"
21
+ import { createServer } from "node:http"
22
+ import { readFileSync } from "node:fs"
23
+ import { homedir } from "node:os"
24
+ import { join } from "node:path"
25
+
26
+ const PROVIDER_ID = "meoo"
27
+ // 外层显示名:Magpie 的插件/供应商列表里露出来的就是这一条短名 "Meoo";
28
+ // 点进去看到的完整包名来自宿主读到的插件 spec —— @fish-under-sea/opencode-meoo-auth。
29
+ const PROVIDER_NAME = "Meoo"
30
+ // 账号记录里没有用户名时的兜底称呼,保留中文全名,免得两个 Meoo 混淆。
31
+ const ACCOUNT_FALLBACK = "Meoo 秒悟"
32
+ const NPM = "@ai-sdk/openai-compatible"
33
+
34
+ const DEFAULT_API_BASE = "https://meoo.com"
35
+ const DEFAULT_GATEWAY = "https://api.meoo.host/meoo-ai/compatible-mode/v1"
36
+ const PROD_CLIENT_ID = "meoo_app_tdWlRbpvAxN1EjmZ8XICxdTh"
37
+ const TEST_CLIENT_ID = "meoo_app_SFNvmHUFNePxyyfGjCd9hyaE"
38
+ const OAUTH_SCOPE = "cli.compat"
39
+ const CALLBACK_PATH = "/oauth/callback"
40
+ // 控制面要求 CLI 客户端标记(与 dsh-meoo-connect 同一套公开契约)。
41
+ const CLI_HEADERS = { "x-meoo-client": "cli", "x-meoo-cli-version": "0.5.6" }
42
+
43
+ const EARLY_MS = 60 * 1000
44
+ const LEAD_MS = 5 * 60 * 1000
45
+ const KEEP_RENEWED_MS = 60 * 1000
46
+ const CATALOG_TTL_MS = 10 * 60 * 1000
47
+ const CONTEXT_WINDOW = 200_000
48
+ const DEFAULT_MAX_TOKENS = 64_000
49
+
50
+ /** 网关目录拿不到时的兜底清单(id → 展示名,取自 meoo 文档与控制台)。 */
51
+ const FALLBACK_MODELS = [
52
+ { id: "qwen3.8-flash", name: "Qwen3.8-Flash" },
53
+ { id: "qwen3.8-max", name: "Qwen3.8-Max" },
54
+ { id: "qwen3.7-max", name: "Qwen3.7-Max" },
55
+ { id: "qwen3.7-plus", name: "Qwen3.7-Plus" },
56
+ { id: "deepseek-v4.1-flash-1017", name: "DeepSeek V4.1 Flash" },
57
+ { id: "deepseek-v4-pro-0813", name: "DeepSeek V4 Pro" },
58
+ { id: "deepseek-v4-flash-0731", name: "DeepSeek V4 Flash" },
59
+ { id: "glm-5.2-fast-preview", name: "GLM-5.2-Fast" },
60
+ { id: "glm-5.2", name: "GLM-5.2" },
61
+ ]
62
+
63
+ const mainOrigin = () => String(process.env.MEOO_MAIN_SITE_URL || DEFAULT_API_BASE).replace(/\/+$/, "")
64
+ const gatewayBase = () => String(process.env.MEOO_GATEWAY_BASE_URL || DEFAULT_GATEWAY).replace(/\/+$/, "")
65
+ // apiBase 跟着账号记录走(登录用的哪个站点,续期/控制面就打哪个),环境变量只是兜底。
66
+ const apiBaseOf = (a) => String(a?.apiBase || mainOrigin()).replace(/\/+$/, "")
67
+
68
+ function clientIdFor(apiBaseUrl) {
69
+ try {
70
+ return new URL(apiBaseUrl).hostname.toLowerCase() === "meoo.com" ? PROD_CLIENT_ID : TEST_CLIENT_ID
71
+ } catch {
72
+ return TEST_CLIENT_ID
73
+ }
74
+ }
75
+
76
+ const labelOf = (id) => FALLBACK_MODELS.find((m) => m.id === id)?.name || id
77
+ const int = (n, fallback = 0) => {
78
+ const v = Math.trunc(Number(n))
79
+ return Number.isFinite(v) && v >= 0 ? v : fallback
80
+ }
81
+ const vendorError = (json, text) => json?.detail || json?.message || String(text ?? "").slice(0, 160)
82
+
83
+ // ---- OAuth(Authorization Code + PKCE,public client)----------------------
84
+
85
+ async function tokenRequest(apiBaseUrl, params) {
86
+ const res = await fetch(`${apiBaseUrl}/oauth/token`, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
89
+ body: new URLSearchParams(params).toString(),
90
+ signal: AbortSignal.timeout(20_000),
91
+ })
92
+ const text = await res.text()
93
+ let json = {}
94
+ try {
95
+ json = JSON.parse(text)
96
+ } catch {}
97
+ if (!res.ok) throw new Error(`OAuth 换票失败 HTTP ${res.status}: ${json.error || text.slice(0, 160)}`)
98
+ return json
99
+ }
100
+
101
+ // 单飞续期:同一 refresh token 的并发续期共享一次请求,60 秒内的结果直接复用。
102
+ const renewed = new Map()
103
+ const renewing = new Map()
104
+
105
+ async function callRefreshApi(a) {
106
+ const base = apiBaseOf(a)
107
+ const token = await tokenRequest(base, {
108
+ grant_type: "refresh_token",
109
+ client_id: clientIdFor(base),
110
+ refresh_token: a.refresh,
111
+ })
112
+ return {
113
+ access: token.access_token,
114
+ ...(token.refresh_token ? { refresh: token.refresh_token } : {}),
115
+ expires: Date.now() + int(token.expires_in || 3600, 3600) * 1000,
116
+ ...(token.scope ? { scope: token.scope } : {}),
117
+ ...(token.openid ? { openid: token.openid } : {}),
118
+ }
119
+ }
120
+
121
+ async function renewToken(a, save) {
122
+ const key = a.refresh
123
+ const done = renewed.get(key)
124
+ if (done && Date.now() - done.at < KEEP_RENEWED_MS) {
125
+ const merged = { ...a, ...done.next }
126
+ await save?.(merged)
127
+ return merged
128
+ }
129
+ let r = renewing.get(key)
130
+ if (!r) {
131
+ r = (async () => {
132
+ const next = await callRefreshApi(a)
133
+ renewed.set(key, { next, at: Date.now() })
134
+ return next
135
+ })().finally(() => renewing.delete(key))
136
+ renewing.set(key, r)
137
+ }
138
+ const next = await r
139
+ const merged = { ...a, ...next }
140
+ await save?.(merged)
141
+ return merged
142
+ }
143
+
144
+ // Meoo 的 access token 过期时间是登录/续期时给的 expires_in,不是 JWT 声明;
145
+ // 没有 expires 就当过期(与 dsh 的 ensureAccessToken 语义一致)。
146
+ function isStale(a, marginMs = EARLY_MS) {
147
+ const exp = Number(a?.expires) || 0
148
+ return !(exp > Date.now() + marginMs)
149
+ }
150
+
151
+ async function currentAuth(ctx, auth, force = false) {
152
+ let a = { ...auth }
153
+ for (let i = 0; i < 8 && a.refresh; i++) {
154
+ const d = renewed.get(a.refresh)
155
+ if (!d || Date.now() - d.at >= KEEP_RENEWED_MS) break
156
+ a = { ...a, ...d.next }
157
+ }
158
+ const alreadyRenewed = a.access !== auth.access || a.refresh !== auth.refresh
159
+ if ((alreadyRenewed || !force) && !isStale(a, EARLY_MS)) {
160
+ if (alreadyRenewed) await persistOf(ctx)(a)
161
+ return { a, didRenew: alreadyRenewed }
162
+ }
163
+ if (!a.refresh) {
164
+ if (!force && a.access && !isStale(a, 0)) return { a, didRenew: alreadyRenewed }
165
+ const err = new Error("Meoo 登录态已过期且没有 refresh token,请重新登录")
166
+ err.signIn = "expired"
167
+ throw err
168
+ }
169
+ const next = await renewToken(a, persistOf(ctx))
170
+ return { a: next, didRenew: true }
171
+ }
172
+
173
+ // ---- 控制面:账号 / 项目 / 项目服务 AK --------------------------------------
174
+
175
+ async function controlCall(apiBase, access, method, apiPath, body) {
176
+ const res = await fetch(`${apiBase}${apiPath}`, {
177
+ method,
178
+ headers: {
179
+ accept: "application/json",
180
+ "content-type": "application/json",
181
+ ...CLI_HEADERS,
182
+ authorization: `Bearer ${access}`,
183
+ },
184
+ body: body === undefined ? undefined : JSON.stringify(body),
185
+ signal: AbortSignal.timeout(45_000),
186
+ })
187
+ const text = await res.text()
188
+ let json = null
189
+ try {
190
+ json = text ? JSON.parse(text) : null
191
+ } catch {
192
+ json = null
193
+ }
194
+ // Meoo 的业务错误也走 HTTP 200({"success":false,"errCode":...}),要在这里拦下来
195
+ if (json && typeof json === "object" && json.success === false) {
196
+ const err = new Error(`${apiPath} 失败: ${json.message || json.errCode || "unknown error"}`)
197
+ err.status = res.status
198
+ err.errCode = json.errCode
199
+ throw err
200
+ }
201
+ if (!res.ok) {
202
+ const err = new Error(
203
+ res.status === 401
204
+ ? `${apiPath} 失败 HTTP 401(登录态已失效,请重新登录): ${vendorError(json, text)}`
205
+ : `${apiPath} 失败 HTTP ${res.status}: ${vendorError(json, text)}`,
206
+ )
207
+ err.status = res.status
208
+ throw err
209
+ }
210
+ return json?.data !== undefined ? json.data : json
211
+ }
212
+
213
+ /** 运行期控制面调用:先确保 access 新鲜,401 时强制续期重试一次。 */
214
+ async function controlRequest(ctx, a, method, apiPath, body) {
215
+ const { a: fresh } = await currentAuth(ctx, a)
216
+ try {
217
+ return await controlCall(apiBaseOf(fresh), fresh.access, method, apiPath, body)
218
+ } catch (e) {
219
+ if (e?.status === 401 && fresh.refresh) {
220
+ const next = await renewToken(fresh, persistOf(ctx))
221
+ return controlCall(apiBaseOf(fresh), next.access, method, apiPath, body)
222
+ }
223
+ throw e
224
+ }
225
+ }
226
+
227
+ async function fetchIdentity(ctx, a) {
228
+ return await controlRequest(ctx, a, "GET", "/open/v1/cli-compat/account/identity")
229
+ }
230
+
231
+ async function listProjects(ctx, a, limit = 50) {
232
+ const data = await controlRequest(ctx, a, "GET", `/open/v1/cli-compat/projects?page_size=${limit}`)
233
+ return data?.list || data?.items || data?.projects || (Array.isArray(data) ? data : [])
234
+ }
235
+
236
+ /**
237
+ * 新账号常常一个项目都没有(项目服务 AK 挂在项目下,没有项目就没有 AK)。
238
+ * 控制面的项目创建接口已验证可用:先试带名字,失败退回空请求体(Meoo 会建
239
+ * 一个 "untitled" 项目)。创建失败返回 null,由调用方决定怎么报错。
240
+ */
241
+ async function createProject(ctx, a) {
242
+ try {
243
+ return await controlRequest(ctx, a, "POST", "/open/v1/cli-compat/projects", { name: "AI 服务" })
244
+ } catch {
245
+ try {
246
+ return await controlRequest(ctx, a, "POST", "/open/v1/cli-compat/projects", {})
247
+ } catch {
248
+ return null
249
+ }
250
+ }
251
+ }
252
+
253
+ /**
254
+ * 选中一个项目:显式指定 > 记录里的 projectUrlId > 身份默认项目 > 唯一项目。
255
+ * 多个项目时不替用户做主(计费归属不同),报错并列出可选项。
256
+ */
257
+ async function pickProject(ctx, a, explicitUrlId) {
258
+ if (explicitUrlId) return explicitUrlId
259
+ let preferred = a?.projectUrlId || ""
260
+ if (!preferred) {
261
+ try {
262
+ const me = await fetchIdentity(ctx, a)
263
+ preferred = me?.project_url_id || me?.projectUrlId || ""
264
+ } catch {}
265
+ }
266
+ if (preferred) return preferred
267
+ const projects = await listProjects(ctx, a)
268
+ if (!projects.length) {
269
+ const created = await createProject(ctx, a)
270
+ const urlId = created?.url_id || created?.urlId
271
+ if (!urlId) throw new Error("账号下还没有项目,自动创建也失败了:请在 meoo.com 手动建一个项目后重试")
272
+ return urlId
273
+ }
274
+ if (projects.length === 1) {
275
+ const only = projects[0]?.url_id || projects[0]?.urlId
276
+ if (!only) throw new Error("项目列表里没有 url_id,无法定位项目")
277
+ return only
278
+ }
279
+ const options = projects.map((p) => `${p.url_id || p.urlId}(${p.name || "未命名"})`).join("、")
280
+ const err = new Error(`账号下有多个项目,请改用「项目服务 AK」登录方式并指定项目,或先在 meoo.com 设为默认项目。可选:${options}`)
281
+ err.projects = projects
282
+ throw err
283
+ }
284
+
285
+ /** 取项目服务 AK;项目没开云服务时自动开通一次再取。 */
286
+ async function resolveServiceAk(ctx, a, { projectUrlId } = {}) {
287
+ const urlId = await pickProject(ctx, a, projectUrlId)
288
+ if (a?.serviceAk && a.serviceAkProjectId === urlId) return { urlId, ak: a.serviceAk }
289
+
290
+ const envPath = `/open/v1/cli-compat/projects/${encodeURIComponent(urlId)}/cloud/environment`
291
+ let ak = null
292
+ try {
293
+ const env = await controlRequest(ctx, a, "GET", envPath)
294
+ ak = env?.meooProjectApiKey || env?.instance?.meooProjectApiKey || null
295
+ } catch {
296
+ ak = null
297
+ }
298
+ let cloudErrMessage = null
299
+ if (!ak) {
300
+ try {
301
+ await controlRequest(ctx, a, "POST", `/open/v1/cli-compat/projects/${encodeURIComponent(urlId)}/cloud`, {
302
+ description: "opencode-meoo-auth: AI 服务",
303
+ })
304
+ const env = await controlRequest(ctx, a, "GET", envPath)
305
+ ak = env?.meooProjectApiKey || env?.instance?.meooProjectApiKey || null
306
+ } catch (e) {
307
+ cloudErrMessage = e?.message ?? String(e)
308
+ ak = null
309
+ }
310
+ }
311
+ if (!ak) {
312
+ throw new Error(
313
+ `项目 ${urlId} 还没开通 Meoo AI 服务:在 meoo.com 打开该项目,在对话框里发一句「帮我开启云服务」(或让 AI 加一个需要后端/AI 的功能,平台会自动开通并签发 AK),开通后重试` +
314
+ (cloudErrMessage ? `(自动开通失败:${cloudErrMessage})` : ""),
315
+ )
316
+ }
317
+ return { urlId, ak }
318
+ }
319
+
320
+ /** 运行期取 AK:有缓存就用缓存,否则解析并持久化进账号记录。 */
321
+ async function serviceAkOf(ctx, a) {
322
+ const urlId = await pickProject(ctx, a)
323
+ if (a?.serviceAk && a.serviceAkProjectId === urlId) return a.serviceAk
324
+ const { urlId: resolved, ak } = await resolveServiceAk(ctx, a, { projectUrlId: urlId })
325
+ await persistOf(ctx)({ ...a, projectUrlId: resolved, serviceAk: ak, serviceAkProjectId: resolved })
326
+ return ak
327
+ }
328
+
329
+ // ---- 模型目录 ---------------------------------------------------------------
330
+
331
+ let catalogCache = { origin: "", models: null, at: 0 }
332
+
333
+ const resetCatalogCache = () => {
334
+ catalogCache = { origin: "", models: null, at: 0 }
335
+ }
336
+
337
+ function parseGatewayModels(payload) {
338
+ const list = payload?.models || payload?.data || (Array.isArray(payload) ? payload : [])
339
+ const seen = new Set()
340
+ const out = []
341
+ for (const entry of Array.isArray(list) ? list : []) {
342
+ const id = typeof entry === "string" ? entry.trim() : String(entry?.id ?? "").trim()
343
+ if (!id || seen.has(id)) continue
344
+ seen.add(id)
345
+ const name = typeof entry === "string" ? labelOf(id) : String(entry?.name || labelOf(id))
346
+ out.push({ id, name, context: int(entry?.context) || CONTEXT_WINDOW, output: int(entry?.max_output || entry?.max_output_tokens || entry?.max_tokens) || DEFAULT_MAX_TOKENS })
347
+ }
348
+ return out
349
+ }
350
+
351
+ async function fetchGatewayModels(ak) {
352
+ const base = gatewayBase()
353
+ if (catalogCache.models && catalogCache.origin === base && Date.now() - catalogCache.at < CATALOG_TTL_MS) {
354
+ return catalogCache.models
355
+ }
356
+ const res = await fetch(`${base}/models`, {
357
+ headers: { authorization: `Bearer ${ak}`, accept: "application/json" },
358
+ signal: AbortSignal.timeout(25_000),
359
+ })
360
+ if (!res.ok) throw new Error(`模型目录 HTTP ${res.status}`)
361
+ const models = parseGatewayModels(await res.json())
362
+ if (!models.length) throw new Error("empty catalog")
363
+ catalogCache = { origin: base, models, at: Date.now() }
364
+ return models
365
+ }
366
+
367
+ // 思考档位:AI SDK 的 openai-compatible 线把 reasoningEffort 映射成请求体的
368
+ // reasoning_effort。网关实测(qwen3.8-flash,2026-11):low/medium/high 都被
369
+ // 接受且真的影响思考长度(reasoning tokens:low≈13、medium/high≈30),不带
370
+ // 该参数时走上游默认思考。对应 UI 的 快速/适中/极致 三档。
371
+ const REASONING_VARIANTS = {
372
+ low: { reasoningEffort: "low" },
373
+ medium: { reasoningEffort: "medium" },
374
+ high: { reasoningEffort: "high" },
375
+ }
376
+
377
+ function configModels() {
378
+ return Object.fromEntries(
379
+ FALLBACK_MODELS.map((m) => [
380
+ m.id,
381
+ {
382
+ name: m.name,
383
+ limit: { context: CONTEXT_WINDOW, output: DEFAULT_MAX_TOKENS },
384
+ tool_call: true,
385
+ reasoning: true,
386
+ attachment: false,
387
+ modalities: { input: ["text"], output: ["text"] },
388
+ variants: REASONING_VARIANTS,
389
+ },
390
+ ]),
391
+ )
392
+ }
393
+
394
+ function runtimeModel(provider, m) {
395
+ return {
396
+ id: m.id,
397
+ providerID: provider.id,
398
+ name: m.name,
399
+ api: { id: m.id, url: gatewayBase(), npm: NPM },
400
+ status: "active",
401
+ headers: {},
402
+ options: {},
403
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
404
+ limit: { context: m.context ?? CONTEXT_WINDOW, output: m.output ?? DEFAULT_MAX_TOKENS },
405
+ capabilities: {
406
+ temperature: true,
407
+ reasoning: true,
408
+ attachment: false,
409
+ toolcall: true,
410
+ input: { text: true, image: false, audio: false, video: false, pdf: false },
411
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
412
+ interleaved: false,
413
+ },
414
+ release_date: "",
415
+ variants: REASONING_VARIANTS,
416
+ }
417
+ }
418
+
419
+ // ---- 登录 -------------------------------------------------------------------
420
+
421
+ /**
422
+ * 登录成功后的账号记录:token + 控制面解析出的身份/项目/AK,一起交给宿主保存。
423
+ */
424
+ async function buildSignedInAuth(tokens, apiBase) {
425
+ const access = tokens.access_token
426
+ if (!access) throw new Error("OAuth 响应里没有 access_token")
427
+ const a = {
428
+ apiBase: apiBase.replace(/\/+$/, ""),
429
+ access,
430
+ refresh: tokens.refresh_token || "",
431
+ expires: Date.now() + int(tokens.expires_in || 3600, 3600) * 1000,
432
+ ...(tokens.scope ? { scope: tokens.scope } : {}),
433
+ ...(tokens.openid ? { openid: tokens.openid } : {}),
434
+ }
435
+ const me = await controlCall(apiBase, access, "GET", "/open/v1/cli-compat/account/identity")
436
+ if (me?.name) a.username = me.name
437
+ if (me?.user_id) a.userId = me.user_id
438
+ if (me?.project_url_id || me?.projectUrlId) a.projectUrlId = me.project_url_id || me.projectUrlId
439
+ const { urlId, ak } = await resolveServiceAk({ client: null }, a)
440
+ a.projectUrlId = urlId
441
+ a.serviceAk = ak
442
+ a.serviceAkProjectId = urlId
443
+ return {
444
+ type: "success",
445
+ access: a.access,
446
+ refresh: a.refresh,
447
+ expires: a.expires,
448
+ accountId: a.username || a.openid || ACCOUNT_FALLBACK,
449
+ ...(a.username ? { username: a.username } : {}),
450
+ ...(a.userId ? { userId: a.userId } : {}),
451
+ ...(a.openid ? { openid: a.openid } : {}),
452
+ ...(a.scope ? { scope: a.scope } : {}),
453
+ apiBase: a.apiBase,
454
+ projectUrlId: a.projectUrlId,
455
+ serviceAk: a.serviceAk,
456
+ serviceAkProjectId: a.serviceAkProjectId,
457
+ }
458
+ }
459
+
460
+ /** 方式一:浏览器 PKCE 授权,本机 loopback 自动收回调,AK 全自动解析。 */
461
+ async function browserSignIn(timeoutMs = 10 * 60 * 1000) {
462
+ const apiBase = mainOrigin()
463
+ const clientId = clientIdFor(apiBase)
464
+ const verifier = randomBytes(32).toString("base64url")
465
+ const challenge = createHash("sha256").update(verifier).digest("base64url")
466
+ const state = randomBytes(32).toString("base64url")
467
+
468
+ let complete, timer, origin, received = false, settled = false
469
+ const done = new Promise((resolve) => {
470
+ complete = resolve
471
+ })
472
+ const finish = (result) => {
473
+ if (settled) return
474
+ settled = true
475
+ clearTimeout(timer)
476
+ complete(result)
477
+ server.close()
478
+ server.closeIdleConnections?.()
479
+ }
480
+ const server = createServer(async (req, res) => {
481
+ const reply = (status, text) => {
482
+ res.writeHead(status, {
483
+ "Content-Type": "text/plain; charset=utf-8",
484
+ "Cache-Control": "no-store",
485
+ "Referrer-Policy": "no-referrer",
486
+ "Content-Security-Policy": "default-src 'none'",
487
+ })
488
+ res.end(text)
489
+ }
490
+ const url = new URL(req.url || "/", "http://127.0.0.1")
491
+ if (req.headers.host !== origin || url.pathname !== CALLBACK_PATH) return reply(404, "Not found")
492
+ if (req.method !== "GET") return reply(405, "Method not allowed")
493
+ const q = url.searchParams
494
+ if (q.getAll("state").length !== 1 || q.get("state") !== state) return reply(400, "回调不属于本次登录,请回到原授权页重试。")
495
+ if (settled || received) return reply(409, "本次回调已经处理,请回到 Magpie 查看结果。")
496
+ if (q.get("error")) {
497
+ reply(400, "登录未获授权,请回到 Magpie 重新登录。")
498
+ finish({ type: "failed", error: `授权被取消:${q.get("error")}${q.get("error_description") ? " " + q.get("error_description") : ""}` })
499
+ return
500
+ }
501
+ const code = q.get("code") || ""
502
+ if (q.getAll("code").length !== 1 || !code || code.length > 4096) return reply(400, "回调缺少有效授权码,请回到原授权页重试。")
503
+ received = true
504
+ try {
505
+ const tokens = await tokenRequest(apiBase, {
506
+ grant_type: "authorization_code",
507
+ client_id: clientId,
508
+ code,
509
+ redirect_uri: `http://${origin}${CALLBACK_PATH}`,
510
+ code_verifier: verifier,
511
+ })
512
+ const success = await buildSignedInAuth(tokens, apiBase)
513
+ if (settled) return reply(408, "登录已超时,请回到 Magpie 重新登录。")
514
+ reply(200, "Meoo 登录成功,可以关闭这个页面回到 Magpie。")
515
+ finish(success)
516
+ } catch (e) {
517
+ // 错误详情直接写进回调页:Magpie 卡片上的报错不一定被看到,浏览器这页一定看得到
518
+ reply(502, `登录未完成:${e?.message ?? String(e)}\n\n请返回 Magpie 处理后重新登录。`)
519
+ finish({ type: "failed", error: e?.message ?? String(e) })
520
+ }
521
+ })
522
+ await new Promise((resolve, reject) => {
523
+ server.once("error", reject)
524
+ server.listen(0, "127.0.0.1", resolve)
525
+ })
526
+ origin = `127.0.0.1:${server.address().port}`
527
+ const authorize = new URL("/oauth/authorize", apiBase)
528
+ authorize.searchParams.set("client_id", clientId)
529
+ authorize.searchParams.set("redirect_uri", `http://${origin}${CALLBACK_PATH}`)
530
+ authorize.searchParams.set("response_type", "code")
531
+ authorize.searchParams.set("scope", OAUTH_SCOPE)
532
+ authorize.searchParams.set("state", state)
533
+ authorize.searchParams.set("code_challenge", challenge)
534
+ authorize.searchParams.set("code_challenge_method", "S256")
535
+ timer = setTimeout(() => finish({ type: "failed", error: "Meoo 登录超时,请重新登录" }), timeoutMs)
536
+ timer.unref?.()
537
+ return {
538
+ url: authorize.toString(),
539
+ instructions: "在浏览器登录 Meoo 并点一次「授权」后,会自动返回此处;项目和 AI 服务 AK 都会自动解析。",
540
+ method: "auto",
541
+ async callback() {
542
+ return await done
543
+ },
544
+ }
545
+ }
546
+
547
+ // ---- 本机已有登录态(只读复用,不写入任何文件)------------------------------
548
+
549
+ function dshHome() {
550
+ return process.env.DSH_HOME || join(homedir(), ".dsh")
551
+ }
552
+
553
+ /**
554
+ * dsh-meoo-connect($DSH_HOME/meoo/state.json)与 meoo-proxy
555
+ * (~/.meoo-proxy/config.json)保存的登录态,两处都读、dsh 优先、缺的互填。
556
+ */
557
+ function readLegacyState(overrides = {}) {
558
+ const dshFile = overrides.dshState || join(dshHome(), "meoo", "state.json")
559
+ const proxyFile = overrides.proxyConfig || join(overrides.proxyHome || process.env.MEOO_PROXY_HOME || join(homedir(), ".meoo-proxy"), "config.json")
560
+ const readJson = (file) => {
561
+ try {
562
+ return JSON.parse(readFileSync(file, "utf8"))
563
+ } catch {
564
+ return null
565
+ }
566
+ }
567
+ const dsh = readJson(dshFile) || {}
568
+ const proxy = readJson(proxyFile) || {}
569
+ const merged = { ...proxy, ...Object.fromEntries(Object.entries(dsh).filter(([, v]) => v !== null && v !== undefined && v !== 0)) }
570
+ if (!merged.refreshToken && !proxy.refreshToken && !merged.serviceAk && !proxy.serviceAk) return null
571
+ return {
572
+ apiBase: merged.apiBaseUrl || DEFAULT_API_BASE,
573
+ accessToken: merged.accessToken || null,
574
+ refreshToken: merged.refreshToken || proxy.refreshToken || null,
575
+ expires: Number(merged.accessExpiresAt) || 0,
576
+ scope: merged.scope || null,
577
+ openid: merged.openid || null,
578
+ username: merged.username || proxy.username || null,
579
+ userId: merged.userId || proxy.userId || null,
580
+ projectUrlId: merged.projectUrlId || proxy.projectUrlId || null,
581
+ serviceAk: merged.serviceAk || proxy.serviceAk || null,
582
+ serviceAkProjectId: merged.serviceAkProjectId || proxy.serviceAkProjectId || null,
583
+ }
584
+ }
585
+
586
+ /** 方式三:复用本机 dsh-meoo-connect / meoo-proxy 的登录态(严格只读)。 */
587
+ function reuseSignIn() {
588
+ return {
589
+ url: "",
590
+ instructions: "只读复用本机已有的 Meoo 登录态(dsh-meoo-connect 的 $DSH_HOME/meoo/state.json 或 meoo-proxy 的 ~/.meoo-proxy/config.json),不会写入这两个文件。",
591
+ method: "auto",
592
+ async callback() {
593
+ const legacy = readLegacyState()
594
+ if (!legacy || (!legacy.refreshToken && !legacy.serviceAk)) {
595
+ return {
596
+ type: "failed",
597
+ error: "未找到本机 Meoo 登录态(已查找 $DSH_HOME/meoo/state.json 与 ~/.meoo-proxy/config.json);请先用浏览器授权登录一次",
598
+ }
599
+ }
600
+ try {
601
+ const a = {
602
+ apiBase: apiBaseOf(legacy),
603
+ access: legacy.accessToken || "",
604
+ refresh: legacy.refreshToken || "",
605
+ expires: legacy.expires || 0,
606
+ ...(legacy.scope ? { scope: legacy.scope } : {}),
607
+ ...(legacy.openid ? { openid: legacy.openid } : {}),
608
+ ...(legacy.username ? { username: legacy.username } : {}),
609
+ ...(legacy.userId ? { userId: legacy.userId } : {}),
610
+ projectUrlId: legacy.projectUrlId || "",
611
+ serviceAk: legacy.serviceAk || "",
612
+ serviceAkProjectId: legacy.serviceAkProjectId || "",
613
+ }
614
+ if (!a.serviceAk && !a.refresh) {
615
+ return { type: "failed", error: "本机登录态既没有项目服务 AK 也没有 refresh token,无法复用;请用浏览器授权重新登录" }
616
+ }
617
+ if (a.refresh) {
618
+ // 过期就续期(只动宿主自己的账号记录,不碰 dsh/meoo-proxy 的文件)
619
+ const { a: fresh } = await currentAuth({ client: null }, a, isStale(a, 0))
620
+ a.access = fresh.access || a.access
621
+ a.expires = fresh.expires || a.expires
622
+ a.refresh = fresh.refresh || a.refresh
623
+ }
624
+ if (!a.serviceAk) {
625
+ const { urlId, ak } = await resolveServiceAk({ client: null }, a)
626
+ a.projectUrlId = urlId
627
+ a.serviceAk = ak
628
+ a.serviceAkProjectId = urlId
629
+ } else if (!a.projectUrlId) {
630
+ a.projectUrlId = a.serviceAkProjectId
631
+ }
632
+ return {
633
+ type: "success",
634
+ access: a.access,
635
+ refresh: a.refresh,
636
+ expires: a.expires,
637
+ accountId: a.username || a.openid || ACCOUNT_FALLBACK,
638
+ ...(a.username ? { username: a.username } : {}),
639
+ ...(a.openid ? { openid: a.openid } : {}),
640
+ ...(a.scope ? { scope: a.scope } : {}),
641
+ apiBase: a.apiBase,
642
+ projectUrlId: a.projectUrlId,
643
+ serviceAk: a.serviceAk,
644
+ serviceAkProjectId: a.serviceAkProjectId,
645
+ }
646
+ } catch (e) {
647
+ return { type: "failed", error: `复用本机登录态失败:${e?.message ?? String(e)}` }
648
+ }
649
+ },
650
+ }
651
+ }
652
+
653
+ /** 方式四:手填项目服务 AK(meoo_project_service_v1.*,meoo.com 项目 → 云服务里复制)。 */
654
+ function manualKeySignIn() {
655
+ return {
656
+ url: "",
657
+ instructions: "在 meoo.com 打开项目 → 云服务,复制项目服务 AK(meoo_project_service_v1. 开头)粘贴到下方;确认前会用网关 /models 校验一次。",
658
+ method: "code",
659
+ async callback(input) {
660
+ try {
661
+ const key = String(input ?? "").trim().replace(/^Bearer\s+/i, "").replace(/^["']|["']$/g, "")
662
+ if (!key) throw new Error("请输入项目服务 AK")
663
+ if (key.startsWith("meoo_ak_")) {
664
+ throw new Error("这是用户 Key(meoo_ak_…),不是项目服务 AK;请填 meoo_project_service_v1. 开头的项目服务 AK")
665
+ }
666
+ const res = await fetch(`${gatewayBase()}/models`, {
667
+ headers: { authorization: `Bearer ${key}`, accept: "application/json" },
668
+ signal: AbortSignal.timeout(15_000),
669
+ })
670
+ if (res.status === 401 || res.status === 403) {
671
+ throw new Error(`AK 被网关拒绝(HTTP ${res.status}):确认是项目服务 AK,且该项目已开通云服务`)
672
+ }
673
+ if (!res.ok) throw new Error(`网关校验失败 HTTP ${res.status}`)
674
+ return { type: "success", key, accountId: "Meoo 项目服务 AK" }
675
+ } catch (e) {
676
+ return { type: "failed", error: e?.message ?? String(e) }
677
+ }
678
+ },
679
+ }
680
+ }
681
+
682
+ // ---- 插件出口 ---------------------------------------------------------------
683
+
684
+ function persistOf(ctx) {
685
+ return async (updated) => {
686
+ try {
687
+ await ctx?.client?.auth?.set?.({ path: { id: PROVIDER_ID }, body: updated })
688
+ } catch {}
689
+ }
690
+ }
691
+
692
+ export const MeooAuthPlugin = async ({ client }) => {
693
+ const ctx = { client }
694
+
695
+ return {
696
+ config: async (config) => {
697
+ config.provider ??= {}
698
+ const was = config.provider[PROVIDER_ID] ?? {}
699
+ config.provider[PROVIDER_ID] = {
700
+ name: PROVIDER_NAME,
701
+ npm: NPM,
702
+ api: gatewayBase(),
703
+ ...was,
704
+ models: { ...configModels(), ...(was.models ?? {}) },
705
+ }
706
+ },
707
+ provider: {
708
+ id: PROVIDER_ID,
709
+ async models(provider, { auth } = {}) {
710
+ try {
711
+ let ak = ""
712
+ if (auth?.type === "api") ak = auth.key || ""
713
+ else if (auth?.type === "oauth" && (auth.access || auth.refresh || auth.serviceAk)) {
714
+ const { a } = await currentAuth(ctx, auth)
715
+ ak = a.serviceAk || (await serviceAkOf(ctx, a))
716
+ }
717
+ if (!ak) return provider.models
718
+ const ms = await fetchGatewayModels(ak)
719
+ if (!ms.length) return provider.models
720
+ return Object.fromEntries(ms.map((m) => [m.id, runtimeModel(provider, m)]))
721
+ } catch {
722
+ return provider.models
723
+ }
724
+ },
725
+ },
726
+ auth: {
727
+ provider: PROVIDER_ID,
728
+ refreshLead: LEAD_MS,
729
+ async refresh(auth) {
730
+ if (auth?.type !== "oauth" || !auth.refresh) return undefined
731
+ const { a, didRenew } = await currentAuth(ctx, auth, true)
732
+ if (!didRenew) return undefined
733
+ return {
734
+ access: a.access,
735
+ refresh: a.refresh,
736
+ expires: a.expires,
737
+ ...(a.scope ? { scope: a.scope } : {}),
738
+ ...(a.openid ? { openid: a.openid } : {}),
739
+ ...(a.username ? { username: a.username } : {}),
740
+ ...(a.serviceAk ? { serviceAk: a.serviceAk, serviceAkProjectId: a.serviceAkProjectId, projectUrlId: a.projectUrlId, apiBase: a.apiBase } : {}),
741
+ }
742
+ },
743
+ async loader(getAuth) {
744
+ const auth = await getAuth()
745
+ if (auth?.type === "api") return auth.key ? { baseURL: gatewayBase(), apiKey: auth.key } : {}
746
+ if (auth?.type !== "oauth") return {}
747
+ try {
748
+ const { a } = await currentAuth(ctx, auth)
749
+ const ak = a.serviceAk || (await serviceAkOf(ctx, a))
750
+ if (!ak) return {}
751
+ return { baseURL: gatewayBase(), apiKey: ak }
752
+ } catch (e) {
753
+ const expired = e?.signIn === "expired"
754
+ return {
755
+ baseURL: gatewayBase(),
756
+ apiKey: "meoo",
757
+ async fetch() {
758
+ return new Response(
759
+ JSON.stringify({ error: { message: e?.message ?? String(e), type: expired ? "authentication_error" : "api_error" } }),
760
+ {
761
+ status: expired ? 401 : 502,
762
+ headers: { "Content-Type": "application/json", "X-Magpie-Sign-In": expired ? "expired" : "kept" },
763
+ },
764
+ )
765
+ },
766
+ }
767
+ }
768
+ },
769
+ methods: [
770
+ {
771
+ type: "oauth",
772
+ label: "Meoo 账号(浏览器授权,自动取项目 AK)",
773
+ authorize: async () => browserSignIn(),
774
+ },
775
+ {
776
+ type: "oauth",
777
+ label: "复用本机登录态(dsh-meoo-connect / meoo-proxy)",
778
+ authorize: async () => reuseSignIn(),
779
+ },
780
+ {
781
+ type: "api",
782
+ label: "项目服务 AK(meoo_project_service_v1.…)",
783
+ placeholder: "meoo_project_service_v1.…",
784
+ authorize: async () => manualKeySignIn(),
785
+ },
786
+ ],
787
+ },
788
+ }
789
+ }
790
+
791
+ export const _internal = {
792
+ tokenRequest,
793
+ renewToken,
794
+ currentAuth,
795
+ isStale,
796
+ controlCall,
797
+ controlRequest,
798
+ fetchIdentity,
799
+ listProjects,
800
+ createProject,
801
+ pickProject,
802
+ resolveServiceAk,
803
+ serviceAkOf,
804
+ parseGatewayModels,
805
+ fetchGatewayModels,
806
+ resetCatalogCache,
807
+ configModels,
808
+ runtimeModel,
809
+ buildSignedInAuth,
810
+ browserSignIn,
811
+ reuseSignIn,
812
+ manualKeySignIn,
813
+ readLegacyState,
814
+ clientIdFor,
815
+ apiBaseOf,
816
+ gatewayBase,
817
+ }