opencode-lobster-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/README.md +255 -175
- package/README.zh-CN.md +211 -151
- package/index.mjs +226 -191
- package/lib/assemble.mjs +7 -1
- package/lib/auth-import.mjs +70 -5
- package/lib/auth-login.mjs +154 -131
- package/lib/checkin.mjs +240 -0
- package/lib/constants.mjs +67 -41
- package/lib/jwt.mjs +34 -34
- package/lib/models.mjs +11 -5
- package/lib/proxy.mjs +51 -19
- package/lib/sse.mjs +63 -67
- package/lib/stream-error.mjs +34 -0
- package/lib/thinking.mjs +58 -12
- package/lib/usage.mjs +46 -4
- package/package.json +38 -37
package/lib/auth-login.mjs
CHANGED
|
@@ -1,131 +1,154 @@
|
|
|
1
|
-
// 浏览器登录,走 LobsterAI 桌面端自己的通道。
|
|
2
|
-
//
|
|
3
|
-
// portal 的 auth 模块允许把授权码回调到任意
|
|
4
|
-
// http://127.0.0.1:<port>/auth/callback(条件是 http + 127.0.0.1 +
|
|
5
|
-
// /auth/callback + 有端口),因此插件可以自带登录,不依赖桌面端。
|
|
6
|
-
// 参数必须追加到登录页的 hash query,不是 URL query;且必须带
|
|
7
|
-
// source=electron,否则 portal 不会带上 redirect_uri。
|
|
8
|
-
|
|
9
|
-
import { createServer } from "node:http"
|
|
10
|
-
import { randomBytes } from "node:crypto"
|
|
11
|
-
import { API, LOGIN_URL } from "./constants.mjs"
|
|
12
|
-
import { jwtExp, accountOf } from "./jwt.mjs"
|
|
13
|
-
|
|
14
|
-
const TIMEOUT_MS = 5 * 60 * 1000
|
|
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
|
-
server
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
listening.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
1
|
+
// 浏览器登录,走 LobsterAI 桌面端自己的通道。
|
|
2
|
+
//
|
|
3
|
+
// portal 的 auth 模块允许把授权码回调到任意
|
|
4
|
+
// http://127.0.0.1:<port>/auth/callback(条件是 http + 127.0.0.1 +
|
|
5
|
+
// /auth/callback + 有端口),因此插件可以自带登录,不依赖桌面端。
|
|
6
|
+
// 参数必须追加到登录页的 hash query,不是 URL query;且必须带
|
|
7
|
+
// source=electron,否则 portal 不会带上 redirect_uri。
|
|
8
|
+
|
|
9
|
+
import { createServer } from "node:http"
|
|
10
|
+
import { randomBytes } from "node:crypto"
|
|
11
|
+
import { API, LOGIN_URL } from "./constants.mjs"
|
|
12
|
+
import { jwtExp, accountOf } from "./jwt.mjs"
|
|
13
|
+
|
|
14
|
+
const TIMEOUT_MS = 5 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
// announce 把回调地址同时放进普通 query(hash 里那份是 portal 要的)。
|
|
17
|
+
// portal 只读 hash,多这一个参数不影响登录——实测带与不带取回的页面字节
|
|
18
|
+
// 完全相同。宿主 Magpie 却只从普通 query 里找 loopback 端口
|
|
19
|
+
// (pluginsignin_paste.go 的 loopbackPorts),找到才为这次登录提供
|
|
20
|
+
// 「回调 URL」输入框:浏览器在另一台机器上时,粘贴登录后落在地址栏的
|
|
21
|
+
// 地址就能完成登录,不用别的通道。
|
|
22
|
+
export function authorizeUrl(loginUrl, callbackUrl, state, { announce = false } = {}) {
|
|
23
|
+
const base = loginUrl || LOGIN_URL
|
|
24
|
+
const hashAt = base.indexOf("#")
|
|
25
|
+
const head = hashAt === -1 ? base : base.slice(0, hashAt)
|
|
26
|
+
const hash = hashAt === -1 ? "" : base.slice(hashAt + 1)
|
|
27
|
+
const [route, query = ""] = hash.split("?")
|
|
28
|
+
const q = new URLSearchParams(query)
|
|
29
|
+
q.set("source", "electron")
|
|
30
|
+
q.set("redirect_uri", callbackUrl)
|
|
31
|
+
q.set("state", state)
|
|
32
|
+
if (!announce) return `${head}#${route}?${q.toString()}`
|
|
33
|
+
const at = head.indexOf("?")
|
|
34
|
+
const plain = new URLSearchParams(at === -1 ? "" : head.slice(at + 1))
|
|
35
|
+
plain.set("redirect_uri", callbackUrl)
|
|
36
|
+
const path = at === -1 ? head : head.slice(0, at)
|
|
37
|
+
return `${path}?${plain.toString()}#${route}?${q.toString()}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 起一个只监听 127.0.0.1 的临时服务;端口由系统分配(listen 0)。
|
|
41
|
+
// 返回 { port, result(ms), close() },把「等回调」和「关服务」分开,
|
|
42
|
+
// 这样调用方可以先拿到端口拼 URL,再去等结果。
|
|
43
|
+
function listenOnce() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let deliver = () => {}
|
|
46
|
+
const got = new Promise((r) => { deliver = r })
|
|
47
|
+
let timer = null
|
|
48
|
+
const server = createServer((req, res) => {
|
|
49
|
+
const u = new URL(req.url, "http://127.0.0.1")
|
|
50
|
+
if (u.pathname !== "/auth/callback") { res.writeHead(404).end(); return }
|
|
51
|
+
const code = u.searchParams.get("code")
|
|
52
|
+
const state = u.searchParams.get("state")
|
|
53
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
|
54
|
+
res.end(code
|
|
55
|
+
? "<h2>登录成功,可以关闭此页面。</h2>"
|
|
56
|
+
: "<h2>登录失败:没有拿到授权码。</h2>")
|
|
57
|
+
deliver({ code, state })
|
|
58
|
+
})
|
|
59
|
+
server.on("error", reject)
|
|
60
|
+
server.listen(0, "127.0.0.1", () => {
|
|
61
|
+
const { port } = server.address()
|
|
62
|
+
resolve({
|
|
63
|
+
port,
|
|
64
|
+
close() {
|
|
65
|
+
if (timer) clearTimeout(timer)
|
|
66
|
+
try { server.close() } catch {}
|
|
67
|
+
},
|
|
68
|
+
result(ms) {
|
|
69
|
+
const late = new Promise((r) => {
|
|
70
|
+
timer = setTimeout(() => r({ error: new Error("登录超时(5 分钟)") }), ms)
|
|
71
|
+
})
|
|
72
|
+
return Promise.race([got, late]).then((v) => {
|
|
73
|
+
if (v?.error) throw v.error
|
|
74
|
+
return v
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function exchangeCode(code) {
|
|
83
|
+
const res = await fetch(`${API}/auth/exchange`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
body: JSON.stringify({ authCode: code, firstKeyfrom: "official", latestKeyfrom: "official" }),
|
|
87
|
+
signal: AbortSignal.timeout(30_000),
|
|
88
|
+
})
|
|
89
|
+
const text = await res.text()
|
|
90
|
+
let v
|
|
91
|
+
try { v = JSON.parse(text) } catch { throw new Error(`LobsterAI 的登录响应无法解析:${text.slice(0, 200)}`) }
|
|
92
|
+
// /api/* 用 {code, message, data} 信封,业务错误也是 HTTP 200
|
|
93
|
+
if (v?.code !== 0 || !v?.data) {
|
|
94
|
+
throw new Error(`LobsterAI 拒绝了登录:${v?.message || `code ${v?.code}`}`)
|
|
95
|
+
}
|
|
96
|
+
return v.data
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// beginSignIn 起回调服务并拼出授权地址,然后把「等回调并换 token」交给
|
|
100
|
+
// finish()。分成两半是因为由 magpie 负责打开 authorize 返回的 url ——
|
|
101
|
+
// 插件自己再开一次就会开两个标签页。
|
|
102
|
+
//
|
|
103
|
+
// announce 见 authorizeUrl:让宿主知道这次登录的回调端口,它才会提供一个
|
|
104
|
+
// 粘贴回调地址的输入框(浏览器在另一台机器上时用它完成登录)。
|
|
105
|
+
export async function beginSignIn({ timeoutMs = TIMEOUT_MS, announce = false } = {}) {
|
|
106
|
+
const state = randomBytes(24).toString("base64url")
|
|
107
|
+
const listening = await listenOnce()
|
|
108
|
+
const callbackUrl = `http://127.0.0.1:${listening.port}/auth/callback`
|
|
109
|
+
const url = authorizeUrl(LOGIN_URL, callbackUrl, state, { announce })
|
|
110
|
+
|
|
111
|
+
const finish = async () => {
|
|
112
|
+
try {
|
|
113
|
+
const got = await listening.result(timeoutMs)
|
|
114
|
+
if (got.state !== state) throw new Error("登录回调的 state 不匹配,已忽略")
|
|
115
|
+
const data = await exchangeCode(got.code)
|
|
116
|
+
const access = String(data.accessToken ?? "")
|
|
117
|
+
if (!access) throw new Error("LobsterAI 没有返回 accessToken")
|
|
118
|
+
const who = accountOf(access)
|
|
119
|
+
return {
|
|
120
|
+
type: "success",
|
|
121
|
+
refresh: String(data.refreshToken ?? ""),
|
|
122
|
+
access,
|
|
123
|
+
expires: jwtExp(access) || Date.now() + 30 * 24 * 3600 * 1000,
|
|
124
|
+
accountId: who.yid ?? (who.id !== undefined ? String(who.id) : undefined),
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
listening.close()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { url, finish, close: () => listening.close() }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// beginSignInAnnounced 是给远程(NAS / Docker)用的 beginSignIn:回调服务
|
|
135
|
+
// 照常起在本机 127.0.0.1 上,登录地址额外在普通 query 里写明这个端口。
|
|
136
|
+
// 宿主 Magpie 看到它,就会在登录页旁给出「回调 URL」输入框——浏览器在
|
|
137
|
+
// 另一台机器上时,把登录后地址栏里的完整地址粘回去,Magpie 在本机把请求
|
|
138
|
+
// 送到这个端口,回调照常抵达。没有这一步,浏览器只能和插件在同一台机器上。
|
|
139
|
+
export async function beginSignInAnnounced(opts = {}) {
|
|
140
|
+
return beginSignIn({ ...opts, announce: true })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// signIn 是 beginSignIn 的「自己开浏览器」封装:给命令行与测试用。
|
|
144
|
+
// open 可注入,因此测试里不必真的弹浏览器。
|
|
145
|
+
export async function signIn({ open, timeoutMs = TIMEOUT_MS } = {}) {
|
|
146
|
+
const pending = await beginSignIn({ timeoutMs })
|
|
147
|
+
try {
|
|
148
|
+
await open(pending.url)
|
|
149
|
+
return await pending.finish()
|
|
150
|
+
} catch (e) {
|
|
151
|
+
pending.close()
|
|
152
|
+
throw e
|
|
153
|
+
}
|
|
154
|
+
}
|
package/lib/checkin.mjs
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// 每日积分礼(签到)的领取逻辑。
|
|
2
|
+
//
|
|
3
|
+
// 上游接口(2026-11-02 实测):
|
|
4
|
+
// GET /api/client-activities/slot?placement=desktop_sidebar&clientVersion=…&…
|
|
5
|
+
// → { slotState: "available", activity: { activityCode, configRevision } }
|
|
6
|
+
// GET /api/client-activities/<code>/context?configRevision=<rev>
|
|
7
|
+
// → { state: { claimedToday, claimedDays, rewardCredits, claimedCredits }, actions: [] }
|
|
8
|
+
// POST /api/client-activities/<code>/actions/check_in
|
|
9
|
+
// 体 { configRevision, idempotencyKey, payload: {} }
|
|
10
|
+
// → code 0 领取成功;code 51104「活动奖励已领取」(幂等,重复发不重复给)
|
|
11
|
+
//
|
|
12
|
+
// 判据取 context 的 actions:已领时是 [],未领时才有 check_in。
|
|
13
|
+
// 领完必须复核(再读一次 context),接口 200 不等于真的到账。
|
|
14
|
+
//
|
|
15
|
+
// 这里只搬活动那一半。原脚本(技术方案调研/lobsterai-checkin)里取 token、
|
|
16
|
+
// 调 /api/auth/refresh、把 token 缓存到磁盘的部分**不搬** —— 那会把桌面端
|
|
17
|
+
// 踢下线,是本插件明确不碰的(见 README「不做的事」)。
|
|
18
|
+
|
|
19
|
+
const BASE = "https://lobsterai-server.youdao.com"
|
|
20
|
+
const UPDATE_URL = "https://api-overmind.youdao.com/openapi/get/luna/hardware/lobsterai/prod/update"
|
|
21
|
+
const PLACEMENT = "desktop_sidebar"
|
|
22
|
+
// 实测 macOS 上也认 win32,别按本机平台改
|
|
23
|
+
const PLATFORM = "win32"
|
|
24
|
+
|
|
25
|
+
// magpie 只认这几个 outcome,其它值会被改写成 failed
|
|
26
|
+
export const OUTCOMES = ["claimed", "done", "ineligible", "inactive", "captcha", "failed"]
|
|
27
|
+
|
|
28
|
+
const timeout = (ms) => AbortSignal.timeout(ms)
|
|
29
|
+
|
|
30
|
+
async function call(method, path, token, body, version) {
|
|
31
|
+
const headers = { authorization: `Bearer ${token}`, accept: "application/json" }
|
|
32
|
+
if (version) headers["User-Agent"] = `LobsterAI/${version}`
|
|
33
|
+
if (body !== undefined) headers["content-type"] = "application/json"
|
|
34
|
+
const res = await fetch(BASE + path, {
|
|
35
|
+
method, headers,
|
|
36
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
37
|
+
signal: timeout(20_000),
|
|
38
|
+
})
|
|
39
|
+
const text = await res.text()
|
|
40
|
+
let json = null
|
|
41
|
+
try { json = JSON.parse(text) } catch {}
|
|
42
|
+
return { status: res.status, json, text }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 当前客户端版本。活动位是否投放跟版本号有关,版本过旧会拿不到活动。
|
|
46
|
+
export async function clientVersion() {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(UPDATE_URL, { signal: timeout(8_000) })
|
|
49
|
+
if (!res.ok) throw new Error(`版本查询 HTTP ${res.status}`)
|
|
50
|
+
const j = await res.json()
|
|
51
|
+
const v = j?.data?.value?.version
|
|
52
|
+
if (typeof v === "string" && /^\d{4}\.\d/.test(v)) return { version: v, known: true }
|
|
53
|
+
} catch {}
|
|
54
|
+
// 兜底:拿不到版本时用一个已知能用的值。known=false 让上层能把「可能是版本问题」
|
|
55
|
+
// 如实报出来,而不是假装活动没投放。
|
|
56
|
+
return { version: "2026.10.30", known: false }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
60
|
+
const isRevision = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
61
|
+
|
|
62
|
+
// 供应商的「一天」何时翻篇:下一个北京零点(活动 ctx 的 state.timezone 是
|
|
63
|
+
// Asia/Shanghai)。magpie 靠这个字段决定下次什么时候再问:
|
|
64
|
+
// 有 until -> 它知道今天确实过去了,下次按 until 来;
|
|
65
|
+
// 没 until -> 它无法判断,只能把结果标成 `again`(稍后再问),
|
|
66
|
+
// 界面上就显示成「暂不可签到」而不是「今日已签到」。
|
|
67
|
+
// 实测 qoder 的签到记录就带着 until(它的积分 10:00 UTC+8 开门)。
|
|
68
|
+
const BEIJING_MS = 8 * 3600 * 1000
|
|
69
|
+
export function nextDayTurn(now = Date.now()) {
|
|
70
|
+
const shifted = now + BEIJING_MS
|
|
71
|
+
const nextMidnight = Math.floor(shifted / 86400000) * 86400000 + 86400000
|
|
72
|
+
return new Date(nextMidnight - BEIJING_MS).toISOString()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 本日奖励:活动 state 里的 rewardCredits。实测 100。
|
|
76
|
+
const rewardOf = (state) => {
|
|
77
|
+
const n = Number(state?.rewardCredits)
|
|
78
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
function envelope(r) {
|
|
83
|
+
const msg = typeof r.json?.message === "string" ? r.json.message : ""
|
|
84
|
+
if (r.status === 401 || r.status === 403) return { expired: true, message: msg || "登录已失效" }
|
|
85
|
+
if (r.status < 200 || r.status >= 300) {
|
|
86
|
+
return { bad: true, message: `HTTP ${r.status}${msg ? `:${msg}` : ""}` }
|
|
87
|
+
}
|
|
88
|
+
// HTTP 成功和合法业务信封都需要。缺少 code 绝不能被当成“没有错误”。
|
|
89
|
+
if (!isObject(r.json) || !Number.isSafeInteger(r.json.code)) {
|
|
90
|
+
return { bad: true, message: `签到响应缺少有效业务 code${msg ? `:${msg}` : ""}` }
|
|
91
|
+
}
|
|
92
|
+
if (r.json.code === 0) {
|
|
93
|
+
return isObject(r.json.data)
|
|
94
|
+
? { data: r.json.data }
|
|
95
|
+
: { bad: true, message: "签到成功响应缺少有效 data" }
|
|
96
|
+
}
|
|
97
|
+
if (/未登录|登录已失效|token/i.test(msg)) return { expired: true, message: msg }
|
|
98
|
+
return { code: r.json.code, message: msg || `code ${r.json.code}` }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function validContext(data) {
|
|
102
|
+
return isObject(data?.state) && typeof data.state.claimedToday === "boolean" &&
|
|
103
|
+
Array.isArray(data.actions) && data.actions.every((action) => typeof action === "string") &&
|
|
104
|
+
(data.configRevision === undefined || isRevision(data.configRevision))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 读活动位 + 上下文,返回领不领得动、以及要用的判据
|
|
108
|
+
export async function readState(token, { version } = {}) {
|
|
109
|
+
const v = version ?? (await clientVersion())
|
|
110
|
+
const slotPath = `/api/client-activities/slot?${new URLSearchParams({
|
|
111
|
+
placement: PLACEMENT, clientVersion: v.version, containerApiVersion: "2", platform: PLATFORM,
|
|
112
|
+
})}`
|
|
113
|
+
const slot = envelope(await call("GET", slotPath, token, undefined, v.version))
|
|
114
|
+
if (slot.expired) return { outcome: "failed", reason: slot.message, version: v }
|
|
115
|
+
if (slot.bad) return { outcome: "failed", reason: slot.message, version: v }
|
|
116
|
+
if (slot.code !== undefined) return { outcome: "failed", reason: slot.message || `code ${slot.code}`, version: v }
|
|
117
|
+
|
|
118
|
+
const activity = slot.data.activity
|
|
119
|
+
const slotState = slot.data.slotState
|
|
120
|
+
if (typeof slotState !== "string" || !slotState.trim()) {
|
|
121
|
+
return { outcome: "failed", reason: "活动位响应缺少有效 slotState", version: v }
|
|
122
|
+
}
|
|
123
|
+
if (slotState !== "available") {
|
|
124
|
+
// 版本是现取的 → 上游就是没投放;版本是兜底值 → 可能是版本过旧,值得当天重试
|
|
125
|
+
return {
|
|
126
|
+
outcome: v.known ? "inactive" : "failed",
|
|
127
|
+
reason: v.known ? "" : "拿不到当前客户端版本,无法确认活动是否投放",
|
|
128
|
+
slotState, version: v,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!isObject(activity) || typeof activity.activityCode !== "string" || !activity.activityCode.trim() || !isRevision(activity.configRevision)) {
|
|
133
|
+
return { outcome: "failed", reason: "活动位响应缺少有效活动或 configRevision", version: v }
|
|
134
|
+
}
|
|
135
|
+
const code = activity.activityCode
|
|
136
|
+
const rev = activity.configRevision
|
|
137
|
+
const ctx = envelope(await call("GET", `/api/client-activities/${encodeURIComponent(code)}/context?configRevision=${rev}`, token, undefined, v.version))
|
|
138
|
+
if (ctx.expired || ctx.bad || ctx.code !== undefined) return { outcome: "failed", reason: ctx.message, version: v }
|
|
139
|
+
if (!validContext(ctx.data)) return { outcome: "failed", reason: "签到上下文缺少有效 state/actions", version: v }
|
|
140
|
+
|
|
141
|
+
const actions = ctx.data.actions.map((a) => a.toLowerCase().trim())
|
|
142
|
+
return {
|
|
143
|
+
outcome: null, code, rev: ctx.data.configRevision ?? rev, state: ctx.data.state, actions, version: v,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 领一次并复核。返回 magpie 的 outcome 形状。
|
|
148
|
+
export async function claim(token, state, { idempotencyKey } = {}) {
|
|
149
|
+
const { code, rev, actions } = state
|
|
150
|
+
// 今天已经领过:报 done,但要带上 credit 和 until ——
|
|
151
|
+
// 缺 until 时 magpie 会把它当成「今天可能还没过去」而标记 again,
|
|
152
|
+
// 界面上就变成「暂不可签到」;缺 credit 则看不到今天拿到多少。
|
|
153
|
+
if (state.state?.claimedToday === true) {
|
|
154
|
+
const out = { outcome: "done", until: nextDayTurn() }
|
|
155
|
+
const reward = rewardOf(state.state)
|
|
156
|
+
if (reward > 0) out.credit = reward
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
if (!actions.includes("check_in")) return { outcome: "ineligible" }
|
|
160
|
+
|
|
161
|
+
const body = {
|
|
162
|
+
configRevision: rev,
|
|
163
|
+
idempotencyKey: idempotencyKey ?? crypto.randomUUID(),
|
|
164
|
+
payload: {},
|
|
165
|
+
}
|
|
166
|
+
const r = envelope(await call("POST", `/api/client-activities/${encodeURIComponent(code)}/actions/check_in`, token, body, state.version?.version))
|
|
167
|
+
// 51104 = 活动奖励已领取。并发的另一路已经领过,算 done
|
|
168
|
+
if (r.code === 51104) {
|
|
169
|
+
const out = { outcome: "done", until: nextDayTurn() }
|
|
170
|
+
const reward = rewardOf(state.state)
|
|
171
|
+
if (reward > 0) out.credit = reward
|
|
172
|
+
return out
|
|
173
|
+
}
|
|
174
|
+
if (r.expired || r.code !== undefined || r.bad) return { outcome: "failed", reason: r.message ?? "领取失败" }
|
|
175
|
+
|
|
176
|
+
const granted = Number(r.data?.creditsGranted ?? r.data?.rewardCredits ?? r.data?.result?.rewardCredits) || 0
|
|
177
|
+
|
|
178
|
+
// 复核:接口 200 不等于到账。
|
|
179
|
+
// 判据只能是**观察到的状态变化**:复核里 claimedToday 必须为真。
|
|
180
|
+
// 绝不能因为 POST 自己说 creditsGranted>0 就报 claimed —— 那是拿请求的回显当证据,
|
|
181
|
+
// 响应说发了、状态没落地时会假报成功。
|
|
182
|
+
let after
|
|
183
|
+
try {
|
|
184
|
+
const c = envelope(await call("GET", `/api/client-activities/${encodeURIComponent(code)}/context?configRevision=${rev}`, token, undefined, state.version?.version))
|
|
185
|
+
if (c.expired || c.bad || c.code !== undefined) {
|
|
186
|
+
return { outcome: "failed", reason: `领取后复核失败:${c.message}` }
|
|
187
|
+
}
|
|
188
|
+
if (!validContext(c.data)) return { outcome: "failed", reason: "领取后复核响应缺少有效 state/actions" }
|
|
189
|
+
after = c.data.state
|
|
190
|
+
} catch (e) {
|
|
191
|
+
return { outcome: "failed", reason: `领取后复核请求失败:${e?.message ?? String(e)}` }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!after.claimedToday) {
|
|
195
|
+
return { outcome: "failed", reason: "领取后复核未通过(服务端仍显示今日未领取)" }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const out = { outcome: "claimed", until: nextDayTurn() }
|
|
199
|
+
// 发放额优先用 POST 的回显;回显没有(或为 0)时用复核里的本日奖励
|
|
200
|
+
const grantedNow = granted > 0 ? granted : Number(after.rewardCredits) || 0
|
|
201
|
+
if (grantedNow > 0) out.credit = grantedNow
|
|
202
|
+
// 不填 streak:claimedDays 是活动开始以来的累计天数,不是连签天数,
|
|
203
|
+
// magpie 会把 streak 显示成「连续 N 天」。
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// auth.checkin 的实现:读状态 → 该领就领。
|
|
208
|
+
// 任何失败都返回 outcome + message,**绝不抛** —— 抛异常 magpie 也记成 failed,
|
|
209
|
+
// 但丢失原因;而且两次读都走网络,超时/断网是常态不是异常。
|
|
210
|
+
export async function checkinWith(token, { version, idempotencyKey } = {}) {
|
|
211
|
+
if (!token) return { outcome: "failed", message: "尚未登录" }
|
|
212
|
+
let state
|
|
213
|
+
try {
|
|
214
|
+
state = await readState(token, { version })
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return { outcome: "failed", message: e?.message ?? String(e) }
|
|
217
|
+
}
|
|
218
|
+
if (state.outcome) {
|
|
219
|
+
const out = { outcome: state.outcome }
|
|
220
|
+
// magpie 的 `message` 就是给人看的原因;内部统一用 reason,出口转一次
|
|
221
|
+
if (state.reason) out.message = state.reason
|
|
222
|
+
return out
|
|
223
|
+
}
|
|
224
|
+
let r
|
|
225
|
+
try {
|
|
226
|
+
r = await claim(token, state, { idempotencyKey })
|
|
227
|
+
} catch (e) {
|
|
228
|
+
return { outcome: "failed", message: e?.message ?? String(e) }
|
|
229
|
+
}
|
|
230
|
+
const out = { outcome: r.outcome }
|
|
231
|
+
if (r.credit) out.credit = r.credit
|
|
232
|
+
// until 必须带出去:magpie 靠它判断供应商的「一天」何时翻篇。
|
|
233
|
+
// 丢了它,「今天已签」会被当成「可能还没过去」而标记 again,
|
|
234
|
+
// 界面就从「今日已签到」退化成「暂不可签到」。
|
|
235
|
+
if (r.until) out.until = r.until
|
|
236
|
+
if (r.reason) out.message = r.reason
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export { PLACEMENT, PLATFORM, UPDATE_URL }
|
package/lib/constants.mjs
CHANGED
|
@@ -1,41 +1,67 @@
|
|
|
1
|
-
// LobsterAI(有道龙虾)的固定事实:地址、客户端标识、未登录时的兜底模型表。
|
|
2
|
-
// 兜底表只在没登录时显示;登录后由 /api/models/available 的实时结果替换。
|
|
3
|
-
|
|
4
|
-
export const PROVIDER = "lobster"
|
|
5
|
-
export const NAME = "LobsterAI"
|
|
6
|
-
export const BASE = "https://lobsterai-server.youdao.com"
|
|
7
|
-
export const API = BASE + "/api"
|
|
8
|
-
export const CHAT_BASE = BASE + "/api/proxy/v1"
|
|
9
|
-
export const NPM = "@ai-sdk/openai-compatible"
|
|
10
|
-
|
|
11
|
-
export const LOGIN_URL = "https://lobsterai.youdao.com/portal#/login"
|
|
12
|
-
export const ICON = "https://lobsterai.youdao.com/portal/lobsterai-logo.png"
|
|
13
|
-
|
|
14
|
-
// 桌面端 2026.9.23 发的标识头;上游不强制,但可能影响特性开关
|
|
15
|
-
export const CLIENT_VERSION = "2026.9.23"
|
|
16
|
-
export const CAPABILITIES = "kimi-k3-agentic-v1,thinking-level-control-v1"
|
|
17
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
|
|
1
|
+
// LobsterAI(有道龙虾)的固定事实:地址、客户端标识、未登录时的兜底模型表。
|
|
2
|
+
// 兜底表只在没登录时显示;登录后由 /api/models/available 的实时结果替换。
|
|
3
|
+
|
|
4
|
+
export const PROVIDER = "lobster"
|
|
5
|
+
export const NAME = "LobsterAI"
|
|
6
|
+
export const BASE = "https://lobsterai-server.youdao.com"
|
|
7
|
+
export const API = BASE + "/api"
|
|
8
|
+
export const CHAT_BASE = BASE + "/api/proxy/v1"
|
|
9
|
+
export const NPM = "@ai-sdk/openai-compatible"
|
|
10
|
+
|
|
11
|
+
export const LOGIN_URL = "https://lobsterai.youdao.com/portal#/login"
|
|
12
|
+
export const ICON = "https://lobsterai.youdao.com/portal/lobsterai-logo.png"
|
|
13
|
+
|
|
14
|
+
// 桌面端 2026.9.23 发的标识头;上游不强制,但可能影响特性开关
|
|
15
|
+
export const CLIENT_VERSION = "2026.9.23"
|
|
16
|
+
export const CAPABILITIES = "kimi-k3-agentic-v1,thinking-level-control-v1"
|
|
17
|
+
|
|
18
|
+
// 输出上限。接口的 maxTokens 字段恒为 null,所以靠实测:
|
|
19
|
+
// 2026-11-02 逐模型打 max_tokens=131072,18 个可用模型全部通过;glm-5.3 自己
|
|
20
|
+
// 报的上限就是 [1, 131072]。524288 才 HTTP 500。原来的 32000 会把长回答截掉,
|
|
21
|
+
// 而 magpie 会把这个数当 max_output_tokens 广告给 agent。
|
|
22
|
+
export const DEFAULT_OUTPUT = 131072
|
|
23
|
+
export const DEFAULT_CONTEXT = 1000000
|
|
24
|
+
|
|
25
|
+
// 没有 thinkingConfig 的模型:上游照样认 reasoning_effort,但**每个模型接受的
|
|
26
|
+
// 档位不一样**,2026-11-02 逐模型实测(十模型 × 七档,见设计文档 §5 补测):
|
|
27
|
+
// MiniMax-M3.1-Flash 拒收 none("requires adaptive thinking")
|
|
28
|
+
// qwen3.7-max 拒收 max(只到 xhigh)
|
|
29
|
+
// qwen3.7-plus max 偶发 500
|
|
30
|
+
// MiniMax-M3 思考写在 content 的 <think> 里,reasoning_content 恒 0,
|
|
31
|
+
// 上游不分档,只有「关 / 开」
|
|
32
|
+
// 只列**实测接受且确实有区别**的档位。没列进来的模型一律不声明(显示成
|
|
33
|
+
// 「thinks, no levels」),宁少不多:声明了却选不动,用户以为能控,实际被忽略或 400。
|
|
34
|
+
export const MEASURED_LEVELS = {
|
|
35
|
+
"kimi-k3.1-pro": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
36
|
+
"kimi-k2.8-preview": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
37
|
+
"qwen3.8-max": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
38
|
+
"qwen3.8-flash": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
39
|
+
"qwen3.8-omni-flash": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
40
|
+
"doubao-seed-2-1-pro-260915": ["none", "low", "medium", "high", "xhigh", "max"],
|
|
41
|
+
"MiniMax-M3.1-Flash": ["medium", "high", "xhigh", "max"],
|
|
42
|
+
"qwen3.7-max": ["none", "low", "medium", "high", "xhigh"],
|
|
43
|
+
"qwen3.7-plus": ["none", "low", "medium", "high", "xhigh"],
|
|
44
|
+
// 只有「关」与「开」两种真实状态
|
|
45
|
+
"MiniMax-M3": ["none", "max"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function configModel({ name, context, output, reasoning, image }) {
|
|
49
|
+
const input = ["text"]
|
|
50
|
+
if (image) input.push("image")
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
limit: { context: context || DEFAULT_CONTEXT, output: output || DEFAULT_OUTPUT },
|
|
54
|
+
reasoning: !!reasoning,
|
|
55
|
+
tool_call: true,
|
|
56
|
+
modalities: { input, output: ["text"] },
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 兜底:未登录时让用户看到 provider 存在。字段来自实测的模型目录。
|
|
61
|
+
export const FALLBACK_MODELS = {
|
|
62
|
+
"deepseek-v4-pro": configModel({ name: "DeepSeek V4 Pro", reasoning: true }),
|
|
63
|
+
"deepseek-flash": configModel({ name: "DeepSeek Flash", reasoning: true }),
|
|
64
|
+
"glm-5.3": configModel({ name: "GLM-5.3", reasoning: true }),
|
|
65
|
+
"kimi-k3": configModel({ name: "Kimi K3", reasoning: true, image: true }),
|
|
66
|
+
"qwen3.8-max": configModel({ name: "Qwen3.8 Max", reasoning: true }),
|
|
67
|
+
}
|