opencode-lobster-auth 0.1.2 → 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 +117 -43
- package/README.zh-CN.md +90 -35
- package/index.mjs +18 -2
- package/lib/assemble.mjs +7 -1
- package/lib/auth-import.mjs +70 -5
- package/lib/checkin.mjs +240 -0
- package/lib/constants.mjs +28 -2
- package/lib/models.mjs +11 -5
- package/lib/proxy.mjs +51 -19
- package/lib/sse.mjs +10 -14
- package/lib/stream-error.mjs +34 -0
- package/lib/thinking.mjs +58 -12
- package/lib/usage.mjs +83 -41
- package/package.json +38 -37
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
|
@@ -15,10 +15,36 @@ export const ICON = "https://lobsterai.youdao.com/portal/lobsterai-logo.png"
|
|
|
15
15
|
export const CLIENT_VERSION = "2026.9.23"
|
|
16
16
|
export const CAPABILITIES = "kimi-k3-agentic-v1,thinking-level-control-v1"
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
|
|
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
|
|
20
23
|
export const DEFAULT_CONTEXT = 1000000
|
|
21
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
|
+
|
|
22
48
|
export function configModel({ name, context, output, reasoning, image }) {
|
|
23
49
|
const input = ["text"]
|
|
24
50
|
if (image) input.push("image")
|
package/lib/models.mjs
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// /api/models/available 的条目 → magpie 的运行时模型。
|
|
2
2
|
// 运行时模型用嵌套的 capabilities;config 钩子用扁平字段(见 constants.configModel)。
|
|
3
3
|
|
|
4
|
-
import { NPM, DEFAULT_OUTPUT, DEFAULT_CONTEXT, configModel } from "./constants.mjs"
|
|
5
|
-
import { variantsOf } from "./thinking.mjs"
|
|
4
|
+
import { NPM, DEFAULT_OUTPUT, DEFAULT_CONTEXT, configModel, MEASURED_LEVELS } from "./constants.mjs"
|
|
5
|
+
import { variantsOf, variantsFromLevels } from "./thinking.mjs"
|
|
6
6
|
|
|
7
7
|
export function runtimeModel(entry, base = {}) {
|
|
8
8
|
const id = String(entry.modelId ?? "")
|
|
9
9
|
const image = !!entry.supportsImage
|
|
10
10
|
const profile = entry.thinkingConfig
|
|
11
|
-
const
|
|
11
|
+
const hasProfile = Array.isArray(profile?.options) && profile.options.length > 0
|
|
12
|
+
// 上游自报「会思考」就认:10 个没有 thinkingConfig 的模型 supportsThinking 也是
|
|
13
|
+
// true,上游同样认 reasoning_effort。原来按「有没有档位表」判,把这 10 个
|
|
14
|
+
// 报成不思考,界面上连 reasoning 都不显示。
|
|
15
|
+
const reasoning = !!entry.supportsThinking
|
|
12
16
|
|
|
13
17
|
return {
|
|
14
18
|
...base,
|
|
@@ -23,7 +27,7 @@ export function runtimeModel(entry, base = {}) {
|
|
|
23
27
|
limit: { context: Number(entry.contextWindow) || DEFAULT_CONTEXT, output: DEFAULT_OUTPUT },
|
|
24
28
|
capabilities: {
|
|
25
29
|
temperature: true,
|
|
26
|
-
reasoning
|
|
30
|
+
reasoning,
|
|
27
31
|
attachment: image,
|
|
28
32
|
toolcall: true,
|
|
29
33
|
input: { text: true, image, audio: false, video: false, pdf: false },
|
|
@@ -31,7 +35,9 @@ export function runtimeModel(entry, base = {}) {
|
|
|
31
35
|
interleaved: false,
|
|
32
36
|
},
|
|
33
37
|
release_date: "",
|
|
34
|
-
|
|
38
|
+
// 当前 profile 决定 lobsterai_options 的合法值,不能与旧 reasoning_effort
|
|
39
|
+
// 通道的实测档位做并集。只有没有 profile 时才使用实测表。
|
|
40
|
+
variants: !reasoning ? {} : hasProfile ? variantsOf(profile) : variantsFromLevels(MEASURED_LEVELS[id]),
|
|
35
41
|
// magpie 自己的字段:一次请求消耗的点数倍率
|
|
36
42
|
rate: Number(entry.costMultiplier) || 0,
|
|
37
43
|
}
|
package/lib/proxy.mjs
CHANGED
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
// 请求管线。上游有两个必须处理的行为:
|
|
2
2
|
// 1. 永远以 SSE 作答,即使请求写了 stream:false;
|
|
3
|
-
// 2. 把错误塞进
|
|
4
|
-
//
|
|
3
|
+
// 2. 把错误塞进 200 的 SSE 里,原样透传会让 magpie 永不故障切换。
|
|
4
|
+
// 错误有两种帧形态,见下面 errorFrame —— 只认一种会把它吞成一个 200 空回复。
|
|
5
5
|
// 另外思考档位要翻译成私有字段(见 lib/thinking.mjs)。
|
|
6
6
|
|
|
7
|
-
import { sse,
|
|
7
|
+
import { sse, chain } from "./sse.mjs"
|
|
8
8
|
import { respond } from "./assemble.mjs"
|
|
9
|
-
import {
|
|
9
|
+
import { errorFrame, StreamError } from "./stream-error.mjs"
|
|
10
|
+
export { errorOf } from "./stream-error.mjs"
|
|
11
|
+
import { applyThinking, applyKimi, isKimiK3 } from "./thinking.mjs"
|
|
10
12
|
import { CLIENT_VERSION, CAPABILITIES } from "./constants.mjs"
|
|
11
13
|
|
|
12
14
|
const LIMIT_WORDS = /限流|频率|过快|过于频繁|rate ?limit|too many/i
|
|
13
15
|
|
|
14
|
-
export function errorOf(data) {
|
|
15
|
-
let v
|
|
16
|
-
try { v = JSON.parse(data) } catch { return { code: 0, message: "" } }
|
|
17
|
-
const e = v?.error && typeof v.error === "object" ? v.error : v
|
|
18
|
-
const code = Number(e?.code ?? v?.code ?? 0) || 0
|
|
19
|
-
const message = String(e?.message ?? v?.message ?? v?.msg ?? "")
|
|
20
|
-
return { code, message }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
16
|
export function statusFor(code, message) {
|
|
24
17
|
if (code === 40100) return 401
|
|
25
18
|
if (code === 40300) return 403
|
|
@@ -38,6 +31,33 @@ function errorResponse(status, code, message) {
|
|
|
38
31
|
)
|
|
39
32
|
}
|
|
40
33
|
|
|
34
|
+
// 窥探到第一条带 choices 的 chunk(包括 role 空帧)为止。
|
|
35
|
+
// 后续错误由非流式重组处理,流式则保留错误事件,不能追改已发送的状态码。
|
|
36
|
+
// DONE 是逻辑终止,不必等传输 EOF;提前报错/终止必须关闭上游。
|
|
37
|
+
async function sniffError(records) {
|
|
38
|
+
const held = []
|
|
39
|
+
let handedOff = false
|
|
40
|
+
try {
|
|
41
|
+
for (;;) {
|
|
42
|
+
const n = await records.next()
|
|
43
|
+
if (n.done) return { bad: null, head: chain(held, records) }
|
|
44
|
+
const rec = n.value
|
|
45
|
+
held.push(rec)
|
|
46
|
+
const bad = errorFrame(rec)
|
|
47
|
+
if (bad) return { bad, head: null }
|
|
48
|
+
if (rec.data === "[DONE]") return { bad: null, head: chain(held, records) }
|
|
49
|
+
let v
|
|
50
|
+
try { v = JSON.parse(rec.data) } catch {}
|
|
51
|
+
if (v && typeof v === "object" && Object.prototype.hasOwnProperty.call(v, "choices")) {
|
|
52
|
+
handedOff = true
|
|
53
|
+
return { bad: null, head: chain(held, records) }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
if (!handedOff) await records.return?.()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
// tokenOf 由调用方注入:桌面端导入的账号自己不存 token,得每次现读,
|
|
42
62
|
// 因此这里不能写死「oauth 读 access、api 读 key」。
|
|
43
63
|
export function makeFetch({ getAuth, tokenOf, profileOf, call }) {
|
|
@@ -61,7 +81,9 @@ export function makeFetch({ getAuth, tokenOf, profileOf, call }) {
|
|
|
61
81
|
// 请求体改写
|
|
62
82
|
const profile = profileOf(req.model)
|
|
63
83
|
const k3 = isKimiK3(req.model)
|
|
64
|
-
|
|
84
|
+
// Kimi 家族:采样参数必须删(服务端固定);仅 kimi-k3 本身强制 max。
|
|
85
|
+
// 拆成两个契约是因为 k3.1-pro 认完整档位,压成 max 会改掉用户选的档位。
|
|
86
|
+
applyKimi(req, req.model)
|
|
65
87
|
const translated = applyThinking(req, profile)
|
|
66
88
|
// 翻译成功才把 magpie 下发的档位名删掉:它下发的是它阶梯里的名字
|
|
67
89
|
// (最低档叫 none),未必是上游的叫法。没翻译成功(模型没有档位表)
|
|
@@ -92,12 +114,22 @@ export function makeFetch({ getAuth, tokenOf, profileOf, call }) {
|
|
|
92
114
|
// 上游理论上永远给 SSE;真给了 JSON 就直通,不做重组
|
|
93
115
|
if (!ctype.includes("event-stream")) return res
|
|
94
116
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
// 错误不一定在第一帧:先窥探到第一条真正带 choices 的 chunk 为止
|
|
118
|
+
const { bad, head } = await sniffError(sse(res.body))
|
|
119
|
+
if (bad) {
|
|
120
|
+
const { code, message } = bad
|
|
121
|
+
return errorResponse(statusFor(code, message), code, message)
|
|
122
|
+
}
|
|
123
|
+
let out
|
|
124
|
+
try {
|
|
125
|
+
out = await respond(req, head)
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// 非流式请求整条流都在手里,中途出现的错误帧也能还原成真正的状态码
|
|
128
|
+
if (e instanceof StreamError) {
|
|
129
|
+
return errorResponse(statusFor(e.payload.code, e.payload.message), e.payload.code, e.payload.message)
|
|
130
|
+
}
|
|
131
|
+
throw e
|
|
99
132
|
}
|
|
100
|
-
const out = await respond(req, rest)
|
|
101
133
|
// 成功也要带 kept:插件不自己续期,用的就是存下来的 token。
|
|
102
134
|
// magpie 靠这个头把账号上的失效标记清掉。
|
|
103
135
|
out.headers.set("X-Magpie-Sign-In", "kept")
|
package/lib/sse.mjs
CHANGED
|
@@ -48,20 +48,16 @@ export async function* sse(body) {
|
|
|
48
48
|
if (last) yield last
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
async function* empty() {}
|
|
52
|
-
|
|
53
|
-
// first 读一条但把它放回去:上游把错误藏在流里,必须先看一眼首条
|
|
54
|
-
export async function first(it) {
|
|
55
|
-
const n = await it.next()
|
|
56
|
-
if (n.done) return { head: null, rest: empty() }
|
|
57
|
-
return { head: n.value, rest: chain([n.value], it) }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
51
|
export async function* chain(held, it) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
52
|
+
try {
|
|
53
|
+
yield* held
|
|
54
|
+
for (;;) {
|
|
55
|
+
const n = await it.next()
|
|
56
|
+
if (n.done) return
|
|
57
|
+
yield n.value
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
// DONE、合并时抛错或下游取消,都必须继续关闭底层 SSE/HTTP 流。
|
|
61
|
+
await it.return?.()
|
|
66
62
|
}
|
|
67
63
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SSE 的两种错误形态共用同一判据,避免首帧与非流式重组的处理分叉。
|
|
2
|
+
// event:error 的 data 可以是纯文本;无 event 的错误必须是没有 choices 的 error 信封。
|
|
3
|
+
|
|
4
|
+
export function errorOf(data) {
|
|
5
|
+
let v
|
|
6
|
+
try { v = JSON.parse(data) } catch { return { code: 0, message: String(data ?? "") } }
|
|
7
|
+
const raw = v?.error ?? v
|
|
8
|
+
const message = typeof raw === "string"
|
|
9
|
+
? raw
|
|
10
|
+
: String(raw?.message ?? raw?.msg ?? v?.message ?? v?.msg ?? "")
|
|
11
|
+
const code = Number((raw && typeof raw === "object" ? raw.code : undefined) ?? v?.code ?? 0) || 0
|
|
12
|
+
return { code, message }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function errorFrame(rec) {
|
|
16
|
+
if (!rec) return null
|
|
17
|
+
// 显式事件优先于 JSON 解析和 choices;标量/null/畸形的普通记录则不算错误。
|
|
18
|
+
if (rec.event !== "error") {
|
|
19
|
+
let v
|
|
20
|
+
try { v = JSON.parse(rec.data) } catch { return null }
|
|
21
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null
|
|
22
|
+
if (Object.prototype.hasOwnProperty.call(v, "choices") || !v.error) return null
|
|
23
|
+
}
|
|
24
|
+
const payload = errorOf(rec.data)
|
|
25
|
+
return { ...payload, message: payload.message || "LobsterAI 返回了一个错误" }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class StreamError extends Error {
|
|
29
|
+
constructor(payload) {
|
|
30
|
+
super(payload.message)
|
|
31
|
+
this.name = "StreamError"
|
|
32
|
+
this.payload = payload
|
|
33
|
+
}
|
|
34
|
+
}
|
package/lib/thinking.mjs
CHANGED
|
@@ -33,6 +33,14 @@ export function variantsOf(profile) {
|
|
|
33
33
|
return Object.fromEntries(opts.map((o) => [nameForMagpie(o.level), { [MARKER]: o.level }]))
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// 没有 thinkingConfig 的模型:档位名就是上游要的 reasoning_effort 原名
|
|
37
|
+
// (magpie 用「声明过的那个名字」下发,插件再原样透传),所以直接用实测到的
|
|
38
|
+
// 档位名,不必翻译。值对象仍只是占位,magpie 只取键名。
|
|
39
|
+
export function variantsFromLevels(levels) {
|
|
40
|
+
if (!Array.isArray(levels) || levels.length === 0) return {}
|
|
41
|
+
return Object.fromEntries(levels.map((lv) => [lv, { [MARKER]: lv }]))
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
// 档位可能以多种形式到达:插件自己的标记、reasoning_effort、reasoningEffort、
|
|
37
45
|
// thinking.level。按 level 或 openclawLevel 查表,未命中用 defaultLevel。
|
|
38
46
|
export function levelOf(req, profile) {
|
|
@@ -51,10 +59,11 @@ export function levelOf(req, profile) {
|
|
|
51
59
|
const byOpenclaw =
|
|
52
60
|
opts.find((o) => o.openclawLevel === wanted) ?? opts.find((o) => o.openclawLevel === level)
|
|
53
61
|
if (byOpenclaw) return byOpenclaw.level
|
|
54
|
-
if (isLevel(level)) return level
|
|
55
62
|
}
|
|
56
|
-
if (profile?.defaultLevel && isLevel(profile.defaultLevel))
|
|
57
|
-
|
|
63
|
+
if (profile?.defaultLevel && isLevel(profile.defaultLevel) && opts.some((o) => o.level === profile.defaultLevel)) {
|
|
64
|
+
return profile.defaultLevel
|
|
65
|
+
}
|
|
66
|
+
return opts[0]?.level && isLevel(opts[0].level) ? opts[0].level : "off"
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
// 上游只在模型自带 thinkingConfig 时才接受 lobsterai_options。对没有档位表的
|
|
@@ -75,24 +84,61 @@ export function applyThinking(req, profile) {
|
|
|
75
84
|
return true
|
|
76
85
|
}
|
|
77
86
|
|
|
78
|
-
// Kimi
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
87
|
+
// Kimi 家族有两个互不相干的契约,实测要分开(2026-11-02):
|
|
88
|
+
//
|
|
89
|
+
// ① 采样参数由服务端固定 —— 覆盖**整个家族**。kimi-k3 / kimi-k3.1-pro /
|
|
90
|
+
// kimi-k2.8-preview 带 temperature=0.7 全部报
|
|
91
|
+
// 50204 "invalid temperature: only 1 is allowed for this model",
|
|
92
|
+
// 带 top_p=0.5 报 "only 0.95 is allowed",带 presence_penalty 报 "only 0 is allowed"。
|
|
93
|
+
// 原正则只匹配 kimi-k3*,kimi-k2.8-preview 是 19 个可用模型之一,必然踩雷。
|
|
94
|
+
//
|
|
95
|
+
// ② 强制 reasoning_effort=max —— 只属于 kimi-k3 本身。
|
|
96
|
+
// 工具历史补 reasoning_content 是独立的兼容修复,仍覆盖原来的 kimi-k3 前缀家族;
|
|
97
|
+
// kimi-k3.1-pro 实测认完整档位,不能因此把它压成 max。
|
|
98
|
+
const SAMPLING_FIXED = ["temperature", "top_p", "n", "presence_penalty", "frequency_penalty"]
|
|
99
|
+
|
|
100
|
+
// 上游按模型 id 前缀判定 Kimi 家族,这里跟着它走
|
|
101
|
+
export function isKimi(modelId) {
|
|
102
|
+
return /^kimi-/i.test(String(modelId ?? ""))
|
|
103
|
+
}
|
|
82
104
|
|
|
105
|
+
export function stripKimiSampling(req) {
|
|
106
|
+
for (const f of SAMPLING_FIXED) delete req[f]
|
|
107
|
+
return req
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 只匹配 kimi-k3 本身。曾经是 /^kimi-k3(?:$|[-.])/,把 kimi-k3.1-pro 也框进来,
|
|
111
|
+
// 于是它刚声明出来的档位被强制 max 抹掉 —— 实测 k3.1-pro 认完整档位。
|
|
112
|
+
// 收窄后若上游真出 kimi-k3-xxx 变体,它不会被强制 max,这是安全的方向
|
|
113
|
+
// (不会悄悄改掉用户选的档位)。
|
|
83
114
|
export function isKimiK3(modelId) {
|
|
84
|
-
return /^kimi-k3
|
|
115
|
+
return /^kimi-k3$/i.test(String(modelId ?? ""))
|
|
85
116
|
}
|
|
86
117
|
|
|
118
|
+
// 只做 k3 自己那部分;采样参数的删除由 stripKimiSampling 负责
|
|
87
119
|
export function applyKimiK3(req) {
|
|
88
120
|
delete req.thinking
|
|
89
121
|
delete req.reasoningEffort
|
|
90
|
-
for (const f of K3_FIXED) delete req[f]
|
|
91
122
|
req.reasoning_effort = "max"
|
|
92
|
-
|
|
123
|
+
return req
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 历史中的 Kimi assistant tool calls 缺 reasoning_content 时补空串;保留
|
|
127
|
+
// 调用方提供的内容。它与只有 kimi-k3 才强制 max 是两条独立规则。
|
|
128
|
+
export function repairKimiToolHistory(req, modelId = req?.model) {
|
|
129
|
+
if (!/^kimi-k3(?:$|[-.])/i.test(String(modelId ?? "")) || !Array.isArray(req.messages)) return req
|
|
93
130
|
for (const m of req.messages) {
|
|
94
|
-
if (m?.role !== "assistant") continue
|
|
95
|
-
if (!Array.isArray(m.tool_calls) || m.tool_calls.length === 0) continue
|
|
131
|
+
if (m?.role !== "assistant" || !Array.isArray(m.tool_calls) || m.tool_calls.length === 0) continue
|
|
96
132
|
if (!("reasoning_content" in m)) m.reasoning_content = ""
|
|
97
133
|
}
|
|
134
|
+
return req
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 请求管线只调这一个入口,免得漏掉其中一步
|
|
138
|
+
export function applyKimi(req, modelId) {
|
|
139
|
+
const model = modelId ?? req?.model
|
|
140
|
+
if (isKimi(model)) stripKimiSampling(req)
|
|
141
|
+
repairKimiToolHistory(req, model)
|
|
142
|
+
if (isKimiK3(model)) applyKimiK3(req)
|
|
143
|
+
return req
|
|
98
144
|
}
|