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/jwt.mjs
CHANGED
|
@@ -1,34 +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
|
-
}
|
|
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
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
|
@@ -1,67 +1,63 @@
|
|
|
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*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (n.done) return
|
|
65
|
-
yield n.value
|
|
66
|
-
}
|
|
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
|
+
export async function* chain(held, it) {
|
|
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?.()
|
|
62
|
+
}
|
|
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
|
}
|
package/lib/usage.mjs
CHANGED
|
@@ -7,6 +7,30 @@ const round = (n, d = 2) => {
|
|
|
7
7
|
return Math.round(Number(n) * p) / p
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// 上游的时间不带时区(如 2026-11-11T09:11:24)。magpie 用 new Date(...) 读它,
|
|
11
|
+
// 裸时间会被当成本机本地时间 —— magpie 跑在 UTC 的容器 / NAS 上就差 8 小时。
|
|
12
|
+
// 上游是北京时间(签到 context 的 state.timezone 是 Asia/Shanghai),补上 +08:00。
|
|
13
|
+
//
|
|
14
|
+
// 形态不止一种(带秒/不带秒/带毫秒/空格分隔),漏掉哪一种就等于那个 bug 换了
|
|
15
|
+
// 形状还在。所以:先把空格分隔规整成 T,再判断「有没有已经写明的偏移量」;
|
|
16
|
+
// 只有日期(2026-11-01)保持原样,那是整天语义、没有时刻可言。
|
|
17
|
+
// 认不出的原样透传,绝不编一个时间出来。
|
|
18
|
+
const rfc3339 = (s) => {
|
|
19
|
+
let v = String(s ?? "").trim()
|
|
20
|
+
if (!v) return v
|
|
21
|
+
// 2026-11-11 09:11:24 → 2026-11-11T09:11:24
|
|
22
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(v)) v = v.replace(" ", "T")
|
|
23
|
+
// 只有日期:整天语义
|
|
24
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v
|
|
25
|
+
// 有时刻但没写偏移量(Z 或 ±hh:mm 都算写了)
|
|
26
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(v) && !/(?:Z|[+-]\d{2}:?\d{2})$/.test(v)) {
|
|
27
|
+
// 补足秒位,magpie 的 Date 解析对 `T09:11` 的支持不统一
|
|
28
|
+
const withSeconds = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(v) ? `${v}:00` : v
|
|
29
|
+
return `${withSeconds}+08:00`
|
|
30
|
+
}
|
|
31
|
+
return v
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
export function usageFrom(quota, profile, signIn) {
|
|
11
35
|
const q = quota ?? {}
|
|
12
36
|
const p = profile ?? {}
|
|
@@ -15,18 +39,36 @@ export function usageFrom(quota, profile, signIn) {
|
|
|
15
39
|
if (q.planName) out.plan = String(q.planName)
|
|
16
40
|
if (p.nickname) out.user = String(p.nickname)
|
|
17
41
|
|
|
42
|
+
// 钱包窗口。积分是一批批的余额,不是「用了几成」的额度:magpie 的匹配规则里
|
|
43
|
+
// 窗口带 balance 才被当钱包(不当 Text/Aside),路由才会按余额排序、先花快
|
|
44
|
+
// 到期的。原来把它塞进顶层 balance 字符串,magpie 只能当文字显示,路由读不到。
|
|
18
45
|
const left = Number(p.totalCreditsRemaining)
|
|
19
|
-
|
|
46
|
+
if (Number.isFinite(left)) {
|
|
47
|
+
// 名字只当标签用。magpie 把它渲染成「<窗口名> <余额> <单位> left」,
|
|
48
|
+
// 叫「点数」会读成「点数 2,487.54 点数 left」,所以用「余额」。
|
|
49
|
+
const w = { name: "余额", balance: round(left), unit: "点数" }
|
|
50
|
+
// 每天领的 100 点各是一批、逐日到期,先到期先扣;不报出来就白作废。
|
|
51
|
+
// 只剩 0 的批次不算「将要到期」,否则永远指向一批已经用完的。
|
|
52
|
+
const soonest = (Array.isArray(p.creditItems) ? p.creditItems : [])
|
|
53
|
+
.filter((c) => Number(c?.creditsRemaining) > 0 && typeof c?.expiresAt === "string" && c.expiresAt !== "")
|
|
54
|
+
.sort((a, b) => String(a.expiresAt).localeCompare(String(b.expiresAt)))[0]
|
|
55
|
+
if (soonest) {
|
|
56
|
+
w.runsOutAt = rfc3339(soonest.expiresAt)
|
|
57
|
+
w.runsOut = round(soonest.creditsRemaining)
|
|
58
|
+
}
|
|
59
|
+
out.windows.push(w)
|
|
60
|
+
}
|
|
20
61
|
|
|
62
|
+
// 免费额度只在还没过期时才算一笔有效额度。免费期 2026-08 就结束了,
|
|
63
|
+
// 过了还按「6% used」显示,看起来像还有一笔可用额度。
|
|
21
64
|
const total = Number(q.freeCreditsTotal)
|
|
22
65
|
const used = Number(q.freeCreditsUsed)
|
|
23
|
-
if (Number.isFinite(total) && total > 0) {
|
|
66
|
+
if (Number.isFinite(total) && total > 0 && !q.freeCreditsExpired) {
|
|
24
67
|
const u = Number.isFinite(used) ? used : 0
|
|
25
68
|
out.windows.push({
|
|
26
69
|
name: "免费额度",
|
|
27
70
|
used: round((u / total) * 100),
|
|
28
71
|
display: `${round(u, 1)} / ${round(total, 1)} 免费积分`,
|
|
29
|
-
aside: !!q.freeCreditsExpired,
|
|
30
72
|
})
|
|
31
73
|
}
|
|
32
74
|
|
|
@@ -34,7 +76,7 @@ export function usageFrom(quota, profile, signIn) {
|
|
|
34
76
|
.map((c) => c?.expiresAt)
|
|
35
77
|
.filter((d) => typeof d === "string" && d !== "")
|
|
36
78
|
.sort()
|
|
37
|
-
if (dates.length > 0) out.until = dates[0]
|
|
79
|
+
if (dates.length > 0) out.until = rfc3339(dates[0])
|
|
38
80
|
|
|
39
81
|
if (signIn) out.signIn = signIn
|
|
40
82
|
return out
|
package/package.json
CHANGED
|
@@ -1,38 +1,39 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "opencode-lobster-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "OpenCode provider plugin: NetEase Youdao LobsterAI (有道龙虾)",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "./index.mjs",
|
|
7
|
-
"exports": "./index.mjs",
|
|
8
|
-
"files": [
|
|
9
|
-
"index.mjs",
|
|
10
|
-
"lib",
|
|
11
|
-
"README.md",
|
|
12
|
-
"README.zh-CN.md",
|
|
13
|
-
"LICENSE"
|
|
14
|
-
],
|
|
15
|
-
"keywords": [
|
|
16
|
-
"opencode",
|
|
17
|
-
"opencode-plugin",
|
|
18
|
-
"magpie",
|
|
19
|
-
"lobster",
|
|
20
|
-
"lobsterai",
|
|
21
|
-
"youdao"
|
|
22
|
-
],
|
|
23
|
-
"license": "MIT",
|
|
24
|
-
"author": "Jasery <jaserydev@163.com>",
|
|
25
|
-
"repository": {
|
|
26
|
-
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/Jasery/opencode-lobster-auth.git"
|
|
28
|
-
},
|
|
29
|
-
"homepage": "https://github.com/Jasery/opencode-lobster-auth#readme",
|
|
30
|
-
"bugs": "https://github.com/Jasery/opencode-lobster-auth/issues",
|
|
31
|
-
"scripts": {
|
|
32
|
-
"test": "bun test"
|
|
33
|
-
},
|
|
34
|
-
"magpie": {
|
|
35
|
-
"maxConcurrency": 8,
|
|
36
|
-
"icon": "https://lobsterai.youdao.com/portal/lobsterai-logo.png"
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-lobster-auth",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenCode provider plugin: NetEase Youdao LobsterAI (有道龙虾)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"exports": "./index.mjs",
|
|
8
|
+
"files": [
|
|
9
|
+
"index.mjs",
|
|
10
|
+
"lib",
|
|
11
|
+
"README.md",
|
|
12
|
+
"README.zh-CN.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"opencode",
|
|
17
|
+
"opencode-plugin",
|
|
18
|
+
"magpie",
|
|
19
|
+
"lobster",
|
|
20
|
+
"lobsterai",
|
|
21
|
+
"youdao"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Jasery <jaserydev@163.com>",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Jasery/opencode-lobster-auth.git"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/Jasery/opencode-lobster-auth#readme",
|
|
30
|
+
"bugs": "https://github.com/Jasery/opencode-lobster-auth/issues",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test"
|
|
33
|
+
},
|
|
34
|
+
"magpie": {
|
|
35
|
+
"maxConcurrency": 8,
|
|
36
|
+
"icon": "https://lobsterai.youdao.com/portal/lobsterai-logo.png"
|
|
37
|
+
}
|
|
38
38
|
}
|
|
39
|
+
|