opencode-lobster-auth 0.0.0-stage → 0.1.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.
@@ -0,0 +1,100 @@
1
+ // 从本机 LobsterAI 桌面端的 sqlite 里读出登录信息。
2
+ // 表:CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL)
3
+ // auth_tokens -> {"accessToken":"…","refreshToken":"…"}
4
+ // auth_user -> {"yid","id","nickname"}
5
+ //
6
+ // 必须只读:桌面端可能正在运行,插件绝不写入它的库。
7
+
8
+ import { Database } from "bun:sqlite"
9
+ import { existsSync } from "node:fs"
10
+ import { join } from "node:path"
11
+ import { jwtExp, accountOf } from "./jwt.mjs"
12
+
13
+ export function dbPath(env = process.env) {
14
+ const home = env.HOME || env.USERPROFILE || ""
15
+ if (process.platform === "win32") {
16
+ const appdata = env.APPDATA || (home ? join(home, "AppData", "Roaming") : "")
17
+ return join(appdata, "LobsterAI", "lobsterai.sqlite")
18
+ }
19
+ if (process.platform === "darwin") {
20
+ return join(home, "Library", "Application Support", "LobsterAI", "lobsterai.sqlite")
21
+ }
22
+ const cfg = env.XDG_CONFIG_HOME || join(home, ".config")
23
+ return join(cfg, "LobsterAI", "lobsterai.sqlite")
24
+ }
25
+
26
+ const parse = (s) => {
27
+ try {
28
+ const v = JSON.parse(String(s ?? ""))
29
+ return v && typeof v === "object" ? v : null
30
+ } catch { return null }
31
+ }
32
+
33
+ export function readCredentials(path) {
34
+ if (!path || !existsSync(path)) return null
35
+ let db
36
+ try {
37
+ // 只读打开:绝不改动桌面端的库
38
+ db = new Database(path, { readonly: true })
39
+ const rows = db.query("SELECT key, value FROM kv WHERE key IN ('auth_tokens','auth_user')").all()
40
+ const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]))
41
+ const tokens = parse(byKey.auth_tokens)
42
+ const accessToken = String(tokens?.accessToken ?? "")
43
+ if (!accessToken) return null
44
+ const user = parse(byKey.auth_user) ?? {}
45
+ const claims = accountOf(accessToken)
46
+ return {
47
+ accessToken,
48
+ refreshToken: String(tokens?.refreshToken ?? ""),
49
+ yid: user.yid ?? claims.yid,
50
+ id: user.id ?? claims.id,
51
+ nickname: user.nickname,
52
+ expires: jwtExp(accessToken),
53
+ }
54
+ } catch {
55
+ return null
56
+ } finally {
57
+ try { db?.close() } catch {}
58
+ }
59
+ }
60
+
61
+ // 桌面端这条路只借一个身份,**不存 token**:
62
+ // access/refresh 都留空,expires 为 0,靠 source:"desktop" 标记来源。
63
+ // token 每次用的时候现读(desktopToken),所以桌面端续期后插件自动跟着用新的。
64
+ //
65
+ // 为什么不把 token 抄一份:抄下来的 refreshToken 会是桌面端和插件共用的,
66
+ // 谁先续期谁就把对方踢下线。留空则 magpie 永不续期(expires 为 0),
67
+ // 桌面端的会话不受任何影响。
68
+ export function desktopSignIn(env = process.env) {
69
+ const path = dbPath(env)
70
+ const c = readCredentials(path)
71
+ if (!c) {
72
+ throw new Error(
73
+ `没有在本机找到 LobsterAI 的登录信息(${path})。` +
74
+ "请先在 LobsterAI 桌面端登录,或改用「浏览器登录」。",
75
+ )
76
+ }
77
+ return {
78
+ type: "success",
79
+ refresh: "",
80
+ access: "",
81
+ expires: 0,
82
+ source: "desktop",
83
+ accountId: c.yid ?? (c.id !== undefined ? String(c.id) : undefined),
84
+ uid: c.id,
85
+ }
86
+ }
87
+
88
+ // 桌面端账号的 token:每次现读,桌面端换了 token 插件立刻跟上。
89
+ // 读不到就是账号不可用(桌面端退出登录、库被删、文件被加密),
90
+ // 报 signIn:"expired" 让 magpie 给账号打上失效标记,提示用户重新登录。
91
+ export function desktopToken(env = process.env) {
92
+ const c = readCredentials(dbPath(env))
93
+ if (!c) {
94
+ throw Object.assign(
95
+ new Error("LobsterAI 桌面端未登录,或它的登录信息已不可读"),
96
+ { signIn: "expired" },
97
+ )
98
+ }
99
+ return c.accessToken
100
+ }
@@ -0,0 +1,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
+ export function authorizeUrl(loginUrl, callbackUrl, state) {
17
+ const base = loginUrl || LOGIN_URL
18
+ const hashAt = base.indexOf("#")
19
+ const head = hashAt === -1 ? base : base.slice(0, hashAt)
20
+ const hash = hashAt === -1 ? "" : base.slice(hashAt + 1)
21
+ const [route, query = ""] = hash.split("?")
22
+ const q = new URLSearchParams(query)
23
+ q.set("source", "electron")
24
+ q.set("redirect_uri", callbackUrl)
25
+ q.set("state", state)
26
+ return `${head}#${route}?${q.toString()}`
27
+ }
28
+
29
+ // 起一个只监听 127.0.0.1 的临时服务;端口由系统分配(listen 0)。
30
+ // 返回 { port, result(ms), close() },把「等回调」和「关服务」分开,
31
+ // 这样调用方可以先拿到端口拼 URL,再去等结果。
32
+ function listenOnce() {
33
+ return new Promise((resolve, reject) => {
34
+ let deliver = () => {}
35
+ const got = new Promise((r) => { deliver = r })
36
+ let timer = null
37
+ const server = createServer((req, res) => {
38
+ const u = new URL(req.url, "http://127.0.0.1")
39
+ if (u.pathname !== "/auth/callback") { res.writeHead(404).end(); return }
40
+ const code = u.searchParams.get("code")
41
+ const state = u.searchParams.get("state")
42
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
43
+ res.end(code
44
+ ? "<h2>登录成功,可以关闭此页面。</h2>"
45
+ : "<h2>登录失败:没有拿到授权码。</h2>")
46
+ deliver({ code, state })
47
+ })
48
+ server.on("error", reject)
49
+ server.listen(0, "127.0.0.1", () => {
50
+ const { port } = server.address()
51
+ resolve({
52
+ port,
53
+ close() {
54
+ if (timer) clearTimeout(timer)
55
+ try { server.close() } catch {}
56
+ },
57
+ result(ms) {
58
+ const late = new Promise((r) => {
59
+ timer = setTimeout(() => r({ error: new Error("登录超时(5 分钟)") }), ms)
60
+ })
61
+ return Promise.race([got, late]).then((v) => {
62
+ if (v?.error) throw v.error
63
+ return v
64
+ })
65
+ },
66
+ })
67
+ })
68
+ })
69
+ }
70
+
71
+ async function exchangeCode(code) {
72
+ const res = await fetch(`${API}/auth/exchange`, {
73
+ method: "POST",
74
+ headers: { "content-type": "application/json" },
75
+ body: JSON.stringify({ authCode: code, firstKeyfrom: "official", latestKeyfrom: "official" }),
76
+ signal: AbortSignal.timeout(30_000),
77
+ })
78
+ const text = await res.text()
79
+ let v
80
+ try { v = JSON.parse(text) } catch { throw new Error(`LobsterAI 的登录响应无法解析:${text.slice(0, 200)}`) }
81
+ // /api/* 用 {code, message, data} 信封,业务错误也是 HTTP 200
82
+ if (v?.code !== 0 || !v?.data) {
83
+ throw new Error(`LobsterAI 拒绝了登录:${v?.message || `code ${v?.code}`}`)
84
+ }
85
+ return v.data
86
+ }
87
+
88
+ // beginSignIn 起回调服务并拼出授权地址,然后把「等回调并换 token」交给
89
+ // finish()。分成两半是因为由 magpie 负责打开 authorize 返回的 url ——
90
+ // 插件自己再开一次就会开两个标签页。
91
+ export async function beginSignIn({ timeoutMs = TIMEOUT_MS } = {}) {
92
+ const state = randomBytes(24).toString("base64url")
93
+ const listening = await listenOnce()
94
+ const callbackUrl = `http://127.0.0.1:${listening.port}/auth/callback`
95
+ const url = authorizeUrl(LOGIN_URL, callbackUrl, state)
96
+
97
+ const finish = async () => {
98
+ try {
99
+ const got = await listening.result(timeoutMs)
100
+ if (got.state !== state) throw new Error("登录回调的 state 不匹配,已忽略")
101
+ const data = await exchangeCode(got.code)
102
+ const access = String(data.accessToken ?? "")
103
+ if (!access) throw new Error("LobsterAI 没有返回 accessToken")
104
+ const who = accountOf(access)
105
+ return {
106
+ type: "success",
107
+ refresh: String(data.refreshToken ?? ""),
108
+ access,
109
+ expires: jwtExp(access) || Date.now() + 30 * 24 * 3600 * 1000,
110
+ accountId: who.yid ?? (who.id !== undefined ? String(who.id) : undefined),
111
+ }
112
+ } finally {
113
+ listening.close()
114
+ }
115
+ }
116
+
117
+ return { url, finish, close: () => listening.close() }
118
+ }
119
+
120
+ // signIn 是 beginSignIn 的「自己开浏览器」封装:给命令行与测试用。
121
+ // open 可注入,因此测试里不必真的弹浏览器。
122
+ export async function signIn({ open, timeoutMs = TIMEOUT_MS } = {}) {
123
+ const pending = await beginSignIn({ timeoutMs })
124
+ try {
125
+ await open(pending.url)
126
+ return await pending.finish()
127
+ } catch (e) {
128
+ pending.close()
129
+ throw e
130
+ }
131
+ }
@@ -0,0 +1,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
+ // 接口不提供输出上限,取一个保守值(见设计文档 §8)
19
+ export const DEFAULT_OUTPUT = 32000
20
+ export const DEFAULT_CONTEXT = 1000000
21
+
22
+ export function configModel({ name, context, output, reasoning, image }) {
23
+ const input = ["text"]
24
+ if (image) input.push("image")
25
+ return {
26
+ name,
27
+ limit: { context: context || DEFAULT_CONTEXT, output: output || DEFAULT_OUTPUT },
28
+ reasoning: !!reasoning,
29
+ tool_call: true,
30
+ modalities: { input, output: ["text"] },
31
+ }
32
+ }
33
+
34
+ // 兜底:未登录时让用户看到 provider 存在。字段来自实测的模型目录。
35
+ export const FALLBACK_MODELS = {
36
+ "deepseek-v4-pro": configModel({ name: "DeepSeek V4 Pro", reasoning: true }),
37
+ "deepseek-flash": configModel({ name: "DeepSeek Flash", reasoning: true }),
38
+ "glm-5.3": configModel({ name: "GLM-5.3", reasoning: true }),
39
+ "kimi-k3": configModel({ name: "Kimi K3", reasoning: true, image: true }),
40
+ "qwen3.8-max": configModel({ name: "Qwen3.8 Max", reasoning: true }),
41
+ }
package/lib/jwt.mjs ADDED
@@ -0,0 +1,34 @@
1
+ // LobsterAI 的 accessToken 是标准 JWT(未加密,仅签名)。
2
+ // 插件只读它的载荷,不校验签名——校验由上游负责。
3
+
4
+ function decode(part) {
5
+ try {
6
+ const s = Buffer.from(String(part).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
7
+ const v = JSON.parse(s)
8
+ return v && typeof v === "object" ? v : null
9
+ } catch {
10
+ return null
11
+ }
12
+ }
13
+
14
+ export function jwtPayload(token) {
15
+ const parts = String(token ?? "").split(".")
16
+ if (parts.length < 2) return null
17
+ return decode(parts[1])
18
+ }
19
+
20
+ // exp 以秒计;返回毫秒,读不到就是 0(magpie 把 0 当作「不续期」)
21
+ export function jwtExp(token) {
22
+ const p = jwtPayload(token)
23
+ const exp = Number(p?.exp)
24
+ return Number.isFinite(exp) && exp > 0 ? exp * 1000 : 0
25
+ }
26
+
27
+ export function accountOf(token) {
28
+ const p = jwtPayload(token)
29
+ if (!p) return {}
30
+ const out = {}
31
+ if (p.yid) out.yid = p.yid
32
+ if (p.sub !== undefined) out.id = p.sub
33
+ return out
34
+ }
package/lib/models.mjs ADDED
@@ -0,0 +1,77 @@
1
+ // /api/models/available 的条目 → magpie 的运行时模型。
2
+ // 运行时模型用嵌套的 capabilities;config 钩子用扁平字段(见 constants.configModel)。
3
+
4
+ import { NPM, DEFAULT_OUTPUT, DEFAULT_CONTEXT, configModel } from "./constants.mjs"
5
+ import { variantsOf } from "./thinking.mjs"
6
+
7
+ export function runtimeModel(entry, base = {}) {
8
+ const id = String(entry.modelId ?? "")
9
+ const image = !!entry.supportsImage
10
+ const profile = entry.thinkingConfig
11
+ const hasThinking = !!entry.supportsThinking && Array.isArray(profile?.options) && profile.options.length > 0
12
+
13
+ return {
14
+ ...base,
15
+ id,
16
+ providerID: base.providerID,
17
+ name: String(entry.modelName ?? id),
18
+ api: { id, url: "", npm: NPM },
19
+ status: "active",
20
+ headers: {},
21
+ options: {},
22
+ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
23
+ limit: { context: Number(entry.contextWindow) || DEFAULT_CONTEXT, output: DEFAULT_OUTPUT },
24
+ capabilities: {
25
+ temperature: true,
26
+ reasoning: hasThinking,
27
+ attachment: image,
28
+ toolcall: true,
29
+ input: { text: true, image, audio: false, video: false, pdf: false },
30
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
31
+ interleaved: false,
32
+ },
33
+ release_date: "",
34
+ variants: hasThinking ? variantsOf(profile) : {},
35
+ // magpie 自己的字段:一次请求消耗的点数倍率
36
+ rate: Number(entry.costMultiplier) || 0,
37
+ }
38
+ }
39
+
40
+ export function modelsFromCatalog(list, provider) {
41
+ const out = {}
42
+ for (const entry of Array.isArray(list) ? list : []) {
43
+ if (!entry || entry.accessible === false) continue
44
+ const id = String(entry.modelId ?? "")
45
+ if (!id) continue
46
+ const was = provider?.models?.[id] ?? {}
47
+ out[id] = runtimeModel(entry, { ...was, providerID: provider?.id })
48
+ }
49
+ return out
50
+ }
51
+
52
+ export function profileOfFrom(list) {
53
+ const out = {}
54
+ for (const entry of Array.isArray(list) ? list : []) {
55
+ const id = String(entry?.modelId ?? "")
56
+ if (id && entry.thinkingConfig) out[id] = entry.thinkingConfig
57
+ }
58
+ return out
59
+ }
60
+
61
+ // 未登录时 config 钩子用的扁平形状
62
+ export function configModels(list) {
63
+ const out = {}
64
+ for (const entry of Array.isArray(list) ? list : []) {
65
+ if (!entry || entry.accessible === false) continue
66
+ const id = String(entry.modelId ?? "")
67
+ if (!id) continue
68
+ out[id] = configModel({
69
+ name: String(entry.modelName ?? id),
70
+ context: Number(entry.contextWindow) || DEFAULT_CONTEXT,
71
+ output: DEFAULT_OUTPUT,
72
+ reasoning: !!entry.supportsThinking,
73
+ image: !!entry.supportsImage,
74
+ })
75
+ }
76
+ return out
77
+ }
package/lib/proxy.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // 请求管线。上游有两个必须处理的行为:
2
+ // 1. 永远以 SSE 作答,即使请求写了 stream:false;
3
+ // 2. 把错误塞进 SSE 的 event:error 且状态码仍是 200,原样透传会让 magpie
4
+ // 永不故障切换。
5
+ // 另外思考档位要翻译成私有字段(见 lib/thinking.mjs)。
6
+
7
+ import { sse, first } from "./sse.mjs"
8
+ import { respond } from "./assemble.mjs"
9
+ import { applyThinking, applyKimiK3, isKimiK3 } from "./thinking.mjs"
10
+ import { CLIENT_VERSION, CAPABILITIES } from "./constants.mjs"
11
+
12
+ const LIMIT_WORDS = /限流|频率|过快|过于频繁|rate ?limit|too many/i
13
+
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
+ export function statusFor(code, message) {
24
+ if (code === 40100) return 401
25
+ if (code === 40300) return 403
26
+ if (code === 42900) return 429
27
+ if (LIMIT_WORDS.test(String(message ?? ""))) return 429
28
+ return 400
29
+ }
30
+
31
+ // 401 带 expired 让 magpie 给账号打上失效标记;其余状态不带这个头
32
+ function errorResponse(status, code, message) {
33
+ const headers = { "content-type": "application/json" }
34
+ if (status === 401) headers["X-Magpie-Sign-In"] = "expired"
35
+ return new Response(
36
+ JSON.stringify({ error: { message, type: status === 429 ? "rate_limit_error" : "api_error", code: code || null } }),
37
+ { status, headers },
38
+ )
39
+ }
40
+
41
+ // tokenOf 由调用方注入:桌面端导入的账号自己不存 token,得每次现读,
42
+ // 因此这里不能写死「oauth 读 access、api 读 key」。
43
+ export function makeFetch({ getAuth, tokenOf, profileOf, call }) {
44
+ return async function lobsterFetch(input, init) {
45
+ const auth = await getAuth()
46
+ if (auth?.type !== "api" && auth?.type !== "oauth") {
47
+ return errorResponse(401, 40100, "LobsterAI: 尚未登录")
48
+ }
49
+ let token = ""
50
+ try {
51
+ token = await tokenOf(auth)
52
+ } catch (e) {
53
+ // 桌面端退出登录 / 库读不到:带上失效标记,让 magpie 提示重新登录
54
+ return errorResponse(401, 40100, e?.message ?? "LobsterAI: 登录信息不可用")
55
+ }
56
+ if (!token) return errorResponse(401, 40100, "LobsterAI: 登录信息不完整")
57
+
58
+ let req
59
+ try { req = JSON.parse(typeof init?.body === "string" ? init.body : "{}") } catch { req = {} }
60
+
61
+ // 请求体改写
62
+ const profile = profileOf(req.model)
63
+ const k3 = isKimiK3(req.model)
64
+ if (k3) applyKimiK3(req)
65
+ const translated = applyThinking(req, profile)
66
+ // 翻译成功才把 magpie 下发的档位名删掉:它下发的是它阶梯里的名字
67
+ // (最低档叫 none),未必是上游的叫法。没翻译成功(模型没有档位表)
68
+ // 时保留原字段 —— 上游自己就认 reasoning_effort,删掉等于白扔一个控制。
69
+ // K3 例外:reasoning_effort 由服务端固定为 max,必须留着。
70
+ if (!k3 && translated) {
71
+ delete req.reasoning_effort
72
+ delete req.reasoningEffort
73
+ }
74
+
75
+ const headers = new Headers(init?.headers)
76
+ headers.set("authorization", `Bearer ${token}`)
77
+ headers.set("content-type", "application/json")
78
+ headers.set("X-LobsterAI-Client-Version", CLIENT_VERSION)
79
+ headers.set("X-LobsterAI-Client-Capabilities", CAPABILITIES)
80
+ headers.delete("content-length")
81
+
82
+ const res = await call(input, { ...init, method: init?.method ?? "POST", headers, body: JSON.stringify(req) })
83
+
84
+ // 上游的鉴权失败是真 401,原样透传但要补上失效标记
85
+ if (res.status === 401) {
86
+ const headers = new Headers(res.headers)
87
+ headers.set("X-Magpie-Sign-In", "expired")
88
+ return new Response(res.body, { status: 401, statusText: res.statusText, headers })
89
+ }
90
+ if (!res.ok) return res
91
+ const ctype = res.headers.get("content-type") ?? ""
92
+ // 上游理论上永远给 SSE;真给了 JSON 就直通,不做重组
93
+ if (!ctype.includes("event-stream")) return res
94
+
95
+ const { head, rest } = await first(sse(res.body))
96
+ if (head && head.event === "error") {
97
+ const { code, message } = errorOf(head.data)
98
+ return errorResponse(statusFor(code, message), code, message || "LobsterAI 返回了一个错误")
99
+ }
100
+ const out = await respond(req, rest)
101
+ // 成功也要带 kept:插件不自己续期,用的就是存下来的 token。
102
+ // magpie 靠这个头把账号上的失效标记清掉。
103
+ out.headers.set("X-Magpie-Sign-In", "kept")
104
+ return out
105
+ }
106
+ }
package/lib/sse.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // LobsterAI 的推理端点永远以 SSE 作答,即使请求写了 stream:false。
2
+ // 这里把字节流切成 {event, data} 记录,两种情形都由上层决定怎么用。
3
+
4
+ const decoder = new TextDecoder()
5
+
6
+ export async function* sse(body) {
7
+ let buf = ""
8
+ let event = ""
9
+ let data = []
10
+
11
+ // 一条空行结束一个事件;没有 data 的事件(只有注释)不产出
12
+ const flush = () => {
13
+ if (data.length === 0) { event = ""; return null }
14
+ const rec = { event, data: data.join("\n") }
15
+ event = ""
16
+ data = []
17
+ return rec
18
+ }
19
+
20
+ const feed = (raw) => {
21
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
22
+ if (line === "") return flush()
23
+ if (line.startsWith(":")) return null // 注释 / keep-alive
24
+ const i = line.indexOf(":")
25
+ const field = i === -1 ? line : line.slice(0, i)
26
+ let value = i === -1 ? "" : line.slice(i + 1)
27
+ if (value.startsWith(" ")) value = value.slice(1)
28
+ if (field === "event") event = value
29
+ else if (field === "data") data.push(value)
30
+ return null
31
+ }
32
+
33
+ for await (const chunk of body) {
34
+ buf += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
35
+ let nl
36
+ while ((nl = buf.indexOf("\n")) !== -1) {
37
+ const rec = feed(buf.slice(0, nl))
38
+ buf = buf.slice(nl + 1)
39
+ if (rec) yield rec
40
+ }
41
+ }
42
+ buf += decoder.decode()
43
+ if (buf !== "") {
44
+ const rec = feed(buf)
45
+ if (rec) yield rec
46
+ }
47
+ const last = flush()
48
+ if (last) yield last
49
+ }
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
+ export async function* chain(held, it) {
61
+ yield* held
62
+ for (;;) {
63
+ const n = await it.next()
64
+ if (n.done) return
65
+ yield n.value
66
+ }
67
+ }
@@ -0,0 +1,98 @@
1
+ // 上游的思考档位走私有字段,不是 reasoning_effort:
2
+ // {"lobsterai_options":{"version":1,"thinking":{"level":"off"|"high"|"max"}}}
3
+ // 协议来自 LobsterAI 自带的 lobsterai-model-compat 插件
4
+ // (resources/cfmind/third-party-extensions/lobsterai-model-compat)。
5
+ //
6
+ // 实测(deepseek-flash):level=off 时 reasoning_content 精确为 0 字节,
7
+ // 不带该字段约 300~900 字节 —— 即 off 会真正关掉思考。
8
+ //
9
+ // 档位怎么从用户到达这里:magpie 不用 variants 的值对象,而是维护一条自己的
10
+ // 档位阶梯(none/minimal/low/medium/high/xhigh/max),把入参 reasoning_effort
11
+ // 映射到「模型声明过的那个档位名」,再以 reasoning_effort 下发。阶梯外的名字
12
+ // (off/disabled/enabled/adaptive/空串)会被直接丢掉 —— 所以声明里用 "off"
13
+ // 当档位名,那一档就永远够不着(实测:发 off 被丢弃,发 none 才送得到)。
14
+ // 因此对外一律用 magpie 的 "none" 表示最低档,写给上游时再换回 LobsterAI 的 "off"。
15
+
16
+ export const OPTIONS_FIELD = "lobsterai_options"
17
+ export const OPTIONS_VERSION = 1
18
+ export const MARKER = "lobsterai_thinking"
19
+
20
+ const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]
21
+ const isLevel = (s) => LEVELS.includes(s)
22
+
23
+ // 上游叫 off,magpie 的阶梯里只有 none
24
+ const WIRE_TO_MAGPIE = { off: "none" }
25
+ const MAGPIE_TO_WIRE = { none: "off" }
26
+ const nameForMagpie = (level) => WIRE_TO_MAGPIE[level] ?? level
27
+ const levelForWire = (name) => MAGPIE_TO_WIRE[name] ?? name
28
+
29
+ // variants 只用到「键名」,值对象不会到达插件(magpie 的 host 只上报
30
+ // Object.keys)。键名必须是 magpie 阶梯内的名字,否则那一档选不中。
31
+ export function variantsOf(profile) {
32
+ const opts = profile?.options ?? []
33
+ return Object.fromEntries(opts.map((o) => [nameForMagpie(o.level), { [MARKER]: o.level }]))
34
+ }
35
+
36
+ // 档位可能以多种形式到达:插件自己的标记、reasoning_effort、reasoningEffort、
37
+ // thinking.level。按 level 或 openclawLevel 查表,未命中用 defaultLevel。
38
+ export function levelOf(req, profile) {
39
+ const opts = profile?.options ?? []
40
+ const wanted = [
41
+ req?.[MARKER],
42
+ req?.reasoning_effort,
43
+ req?.reasoningEffort,
44
+ req?.thinking?.level,
45
+ ].find((v) => typeof v === "string" && v !== "")
46
+
47
+ if (wanted) {
48
+ // magpie 下发的是它阶梯里的名字(最低档叫 none),先换回上游的叫法
49
+ const level = levelForWire(wanted)
50
+ if (isLevel(level) && opts.some((o) => o.level === level)) return level
51
+ const byOpenclaw =
52
+ opts.find((o) => o.openclawLevel === wanted) ?? opts.find((o) => o.openclawLevel === level)
53
+ if (byOpenclaw) return byOpenclaw.level
54
+ if (isLevel(level)) return level
55
+ }
56
+ if (profile?.defaultLevel && isLevel(profile.defaultLevel)) return profile.defaultLevel
57
+ return "off"
58
+ }
59
+
60
+ // 上游只在模型自带 thinkingConfig 时才接受 lobsterai_options。对没有档位表的
61
+ // 模型带上该字段是硬错误,实测 code 4000:
62
+ // "lobsterai_options: model does not have a valid thinkingConfig"
63
+ // 29 个模型里有 21 个 thinkingConfig 是 null,因此必须按「有没有档位表」来
64
+ // 决定写不写 —— 桌面端的 lobsterai-model-compat 插件也是这么做的
65
+ // (index.ts 用 thinkingProfile.requestOptionsVersion 当闸门)。
66
+ // 返回是否真的写了:调用方据此决定要不要把 magpie 下发的 reasoning_effort
67
+ // 删掉(没翻译成功时留着它,上游自己认这个字段,删掉等于白扔一个控制)。
68
+ export function applyThinking(req, profile) {
69
+ // 先读再删:levelOf 的第一优先级就是插件自己的标记。
70
+ const level = levelOf(req, profile)
71
+ delete req[MARKER]
72
+ const opts = profile?.options
73
+ if (!Array.isArray(opts) || opts.length === 0) return false
74
+ req[OPTIONS_FIELD] = { version: OPTIONS_VERSION, thinking: { level } }
75
+ return true
76
+ }
77
+
78
+ // Kimi K3 的额外契约(LobsterAI 的 kimiK3StreamWrapper.ts):
79
+ // 采样参数由服务端固定,必须删掉;reasoning_effort 固定为 max;
80
+ // 历史里缺 reasoning_content 的 assistant tool_call 消息会被拒答。
81
+ const K3_FIXED = ["temperature", "top_p", "n", "presence_penalty", "frequency_penalty"]
82
+
83
+ export function isKimiK3(modelId) {
84
+ return /^kimi-k3(?:$|[-.])/i.test(String(modelId ?? ""))
85
+ }
86
+
87
+ export function applyKimiK3(req) {
88
+ delete req.thinking
89
+ delete req.reasoningEffort
90
+ for (const f of K3_FIXED) delete req[f]
91
+ req.reasoning_effort = "max"
92
+ if (!Array.isArray(req.messages)) return
93
+ 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
96
+ if (!("reasoning_content" in m)) m.reasoning_content = ""
97
+ }
98
+ }