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/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 hasThinking = !!entry.supportsThinking && Array.isArray(profile?.options) && profile.options.length > 0
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: hasThinking,
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
- variants: hasThinking ? variantsOf(profile) : {},
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. 把错误塞进 SSE 的 event:error 且状态码仍是 200,原样透传会让 magpie
4
- // 永不故障切换。
3
+ // 2. 把错误塞进 200 的 SSE 里,原样透传会让 magpie 永不故障切换。
4
+ // 错误有两种帧形态,见下面 errorFrame —— 只认一种会把它吞成一个 200 空回复。
5
5
  // 另外思考档位要翻译成私有字段(见 lib/thinking.mjs)。
6
6
 
7
- import { sse, first } from "./sse.mjs"
7
+ import { sse, chain } from "./sse.mjs"
8
8
  import { respond } from "./assemble.mjs"
9
- import { applyThinking, applyKimiK3, isKimiK3 } from "./thinking.mjs"
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
- if (k3) applyKimiK3(req)
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
- 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 返回了一个错误")
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* 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
- }
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)) return profile.defaultLevel
57
- return "off"
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 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"]
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(?:$|[-.])/i.test(String(modelId ?? ""))
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
- if (!Array.isArray(req.messages)) return
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
- out.balance = Number.isFinite(left) && left > 0 ? `${round(left)} 点数` : ""
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.1.1",
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
+