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 CHANGED
@@ -9,13 +9,21 @@ Kimi, Qwen, MiniMax and Doubao.
9
9
 
10
10
  ## Install
11
11
 
12
- A folder plugin — it is not published to npm:
12
+ From npm:
13
13
 
14
14
  ```sh
15
- magpie plugin add /path/to/magpie-lobster
15
+ magpie plugin add opencode-lobster-auth
16
16
  magpie plugin login lobster
17
17
  ```
18
18
 
19
+ `magpie plugin update` picks up later versions.
20
+
21
+ To run a checkout instead of the published package, point at the folder:
22
+
23
+ ```sh
24
+ magpie plugin add /path/to/magpie-lobster
25
+ ```
26
+
19
27
  ## Signing in
20
28
 
21
29
  Two ways, either one is enough. Both name the account by the Youdao account
@@ -41,7 +49,9 @@ first way replaces it in place.
41
49
  - Linux: `~/.config/LobsterAI/lobsterai.sqlite`
42
50
 
43
51
  A database that isn't there, or one the app keeps encrypted, fails this
44
- way with a message saying so; use the browser way instead.
52
+ way with a message saying so; use the browser way instead. The imported
53
+ identity is checked against the current token, and an incomplete or
54
+ conflicting identity fails closed rather than using another account's token.
45
55
 
46
56
  Both are kept in magpie's `plugin-auth.json`.
47
57
 
@@ -83,11 +93,14 @@ habits are handled:
83
93
  - **Every reply is SSE**, even one asked for with `stream: false`. A
84
94
  non-streaming caller gets one `chat.completion` reassembled from it; a
85
95
  streaming caller gets the events as they are.
86
- - **Failures ride inside a 200.** LobsterAI puts an error in the stream
87
- (`event: error`) and still answers 200, which would leave a caller with
88
- nothing to fail over on. The plugin reads the first record and answers the
89
- status it means: `40100` → 401, `40300` → 403, `42900` or rate-limit
90
- wording → 429, anything else → 400. A sign-in LobsterAI turns down is a
96
+ - **Failures ride inside a 200.** LobsterAI puts errors in the stream
97
+ (`event: error`, or an error JSON record) and still answers 200, which
98
+ would leave a caller with nothing to fail over on. The plugin inspects the
99
+ early records, preserves plain-text error messages, and answers the status
100
+ it means: `40100` → 401, `40300` → 403, `42900` or rate-limit wording →
101
+ 429, anything else → 400. Non-streaming requests also catch an error that
102
+ appears after an initial response chunk; streaming requests preserve such
103
+ an event after content has started. A sign-in LobsterAI turns down is a
91
104
  genuine 401, passed through with magpie's lapse mark so the account is
92
105
  flagged.
93
106
  - **Requests carry the desktop client's headers**
@@ -97,58 +110,109 @@ habits are handled:
97
110
 
98
111
  ### Thinking levels
99
112
 
100
- A model's `thinkingConfig` gives it levels — `off`, `high`, `max` in the
101
- current catalogue — and they reach the wire in LobsterAI's own field:
102
- `{"lobsterai_options":{"version":1,"thinking":{"level":"off"}}}`.
113
+ There are two kinds of model in LobsterAI's catalogue, and the plugin
114
+ speaks to each in its own way.
115
+
116
+ **8 models carry a `thinkingConfig`** (`deepseek-*`, `glm-*`), with the
117
+ levels `off`/`high`/`max`. Those go out in LobsterAI's own field:
118
+ `{"lobsterai_options":{"version":1,"thinking":{"level":"off"}}}`. Sending
119
+ that field to a model **without** a profile is a hard error (`code 4000`,
120
+ *model does not have a valid thinkingConfig*), so it is written only where
121
+ there is a profile to map it through. With a profile, only its declared
122
+ levels are offered; separately measured `reasoning_effort` levels apply only
123
+ while a model has no profile.
124
+
125
+ **10 models carry no profile** but still report `supportsThinking: true`.
126
+ For those the plugin declares levels and lets magpie's `reasoning_effort`
127
+ pass straight through, which LobsterAI honours.
103
128
 
104
129
  Two things are worth knowing:
105
130
 
106
- - **`lobsterai_options` is only for models that have a thinking profile.**
107
- 21 of the 29 models have none, and sending the field to one of those is a
108
- hard error (`code 4000`, *model does not have a valid thinkingConfig*), so
109
- the whole chat fails. A level is passed on only where there is a profile
110
- to map it through.
111
131
  - **The lowest level is offered as `none`, not `off`.** magpie doesn't use
112
132
  what a variant holds; it maps `reasoning_effort` onto a level name the
113
133
  model declared, from a ladder of its own
114
134
  (`none/minimal/low/medium/high/xhigh/max`). `off` is not on that ladder,
115
- so a level declared as `off` can never be chosen. The plugin declares
116
- `none` and turns it back into `off` for LobsterAI.
135
+ so a level declared as `off` can never be chosen. With a profile the
136
+ plugin declares `none` and turns it back into `off`; without one it sends
137
+ `none` as it is, since LobsterAI accepts it.
138
+ - **Levels differ per model and were measured, not assumed.** LobsterAI
139
+ refuses some of them outright: `MiniMax-M3.1-Flash` rejects `none`
140
+ (*requires adaptive thinking*), `qwen3.7-max` stops at `xhigh`, and
141
+ `qwen3.7-plus` answers `max` with an occasional 500. `MiniMax-M3` writes
142
+ its thinking into `<think>` tags inside the content and `reasoning_content`
143
+ stays empty; its effort isn't graded at all, so it offers only `none` and
144
+ `max`. A model whose levels were not measured declares none, and magpie
145
+ shows it as *thinks, no levels*.
117
146
 
118
147
  Measured on `deepseek-flash`: `none` leaves `reasoning_content` at exactly
119
- 0 bytes, while `high`, `max` and no level at all don't.
148
+ 0 bytes, while `high`, `max` and no level at all don't — and on
149
+ `kimi-k3.1-pro`, `none` leaves it at 0 bytes while `xhigh` produces some.
120
150
 
121
- ### Kimi K3
151
+ ### Kimi
122
152
 
123
- LobsterAI's `kimi-k3` takes its sampling parameters from the server, so
124
- `temperature`, `top_p`, `n` and the penalties are dropped, `reasoning_effort`
125
- is fixed to `max`, and an assistant tool call missing `reasoning_content` is
126
- given an empty one.
153
+ Every Kimi model takes its sampling parameters from the server:
154
+ `temperature` must be 1, `top_p` 0.95 and the penalties 0, so the plugin
155
+ drops `temperature`, `top_p`, `n` and both penalties for the whole family —
156
+ it measured that for `kimi-k3`, `kimi-k3.1-pro` and `kimi-k2.8-preview`
157
+ alike. A client that always sends `temperature` (most agents do) would
158
+ otherwise be answered `50204 invalid temperature: only 1 is allowed`.
127
159
 
128
- `kimi-k3` is one of the models LobsterAI serves but does not list:
129
- `/api/models/available` returns 29 models and none of them is K3, yet a chat
130
- against `kimi-k3` answers normally. It is kept in the list this package
131
- declares for a signed-out account, so `lobster/kimi-k3` is offered — and the
132
- handling above is live, not a precaution.
160
+ Only `kimi-k3` has `reasoning_effort` fixed to `max`. That forcing is
161
+ not extended to `kimi-k3.1-pro`, which honours the full ladder and would
162
+ otherwise have every level silently overridden. Separately, tool-call
163
+ history repair stays enabled for the `kimi-k3` family, including
164
+ `kimi-k3.1-pro`: an assistant tool call missing `reasoning_content` is given
165
+ an empty string without overwriting existing reasoning or the chosen effort.
166
+
167
+ `kimi-k3` is one of the models LobsterAI serves without listing it in every
168
+ catalogue revision. It is kept in the list this package declares for a
169
+ signed-out account, so `lobster/kimi-k3` stays offered.
133
170
 
134
171
  ## Models
135
172
 
136
- Signed in, the list is the account's own (`/api/models/available`): 29
137
- models, each with its context window, whether it takes images, and its
138
- thinking levels. Until then a static list of five is used, so the provider
139
- is visible before signing in.
173
+ Signed in, the list is the account's own (`/api/models/available`) — 18 or
174
+ 19 usable models, each with its context window, whether it takes images,
175
+ and its thinking levels. Until then a static list of five is used, so the
176
+ provider is visible before signing in.
140
177
 
141
178
  `costMultiplier` becomes magpie's `rate`. It is **time-of-day pricing**:
142
179
  `deepseek-flash` is 0.05 in LobsterAI's off-peak hours and 0.1 in the peak
143
180
  ones (09:00–12:00 and 14:00–18:00, Beijing time). The rate is read when the
144
181
  list is, so it can be a while out of date.
145
182
 
183
+ The reply limit is 131,072. The catalogue's `maxTokens` field is always
184
+ null, so this was measured: all 18 models answered `max_tokens=131072`,
185
+ `glm-5.3` names `[1, 131072]` as its own range, and 524,288 is where
186
+ LobsterAI starts answering 500.
187
+
146
188
  ## Usage
147
189
 
148
- `magpie quota` shows the free allowance, when it ends and the credits left,
149
- from `/api/user/quota` and `/api/user/profile-summary`. The plugin reports
150
- `kept`: it renews nothing itself, so it must not claim a renewal it didn't
151
- do.
190
+ `magpie quota` shows the credits left and when the soonest batch expires,
191
+ from `/api/user/quota` and `/api/user/profile-summary`. The credits are
192
+ reported as a **wallet window** (`余额·点数` with `runsOut`/`runsOutAt`)
193
+ rather than a plain string, so magpie can show the lapse and routing can
194
+ spend the account that runs out first. Every day's check-in adds 100 points
195
+ that lapse 30 days later, first in first out, so knowing the date matters.
196
+ The plugin reports `kept`: it renews nothing itself, so it must not claim a
197
+ renewal it didn't do.
198
+
199
+ Expired free allowances are not reported as a live window — the free period
200
+ ended in August 2026, and showing "6% used" looked like a usable allowance.
201
+
202
+ ## Daily check-in
203
+
204
+ The plugin implements `auth.checkin`, so magpie can claim LobsterAI's daily
205
+ gift (100 points) for you. **It is off by default**: turn it on under
206
+ Settings → Usage → Plugins. Once on, every signed-in LobsterAI account is
207
+ checked in once a day (Beijing time).
208
+
209
+ This is the only way to collect those points without opening LobsterAI's own
210
+ desktop app, whose sidebar holds the check-in. The claim is idempotent — a
211
+ day already claimed answers `51104`, and the plugin reports `done` rather
212
+ than claiming again — and every claim is confirmed by re-reading the state
213
+ before it is reported as `claimed`. HTTP failures, malformed business
214
+ responses and invalid state are reported as `failed`, not as an inactive
215
+ activity or an ineligible account, so magpie can retry later that day.
152
216
 
153
217
  ## Not here
154
218
 
@@ -157,14 +221,24 @@ do.
157
221
  - **A verified `/api/auth/refresh`.** Calling it may spend the account's
158
222
  real refresh token, so it is not exercised. The renewal path is written
159
223
  from the app's own code and has not been confirmed against the service.
160
- - **An exact output limit.** The catalogue has no such field. The plugin
161
- declares a conservative 32,000; measured, LobsterAI takes 262,144 and
162
- answers 500 at 524,288, so the real ceiling is somewhere between.
163
224
  - **OpenCode.** The plugin is written in OpenCode's provider-plugin format —
164
225
  that is the format magpie loads — but it has only been exercised in magpie.
165
- `auth.refresh`, `auth.refreshLead` and the `magpie` field in `package.json`
166
- are magpie's own, which OpenCode ignores.
167
- - **Several accounts at once.** magpie keeps one sign-in per provider.
226
+ `auth.refresh`, `auth.refreshLead`, `auth.checkin` and the `magpie` field
227
+ in `package.json` are magpie's own, which OpenCode ignores.
228
+
229
+ ## Several accounts
230
+
231
+ magpie can hold more than one sign-in per provider, and this plugin supports
232
+ that: sign in to a second Youdao account through the browser and both are
233
+ listed.
234
+
235
+ The desktop import is the exception to watch. LobsterAI's desktop app has one
236
+ signed-in account at a time, and the plugin reads that app's token on every
237
+ request. So an account imported from the desktop is tied to the account that
238
+ was signed in when it was imported: switch the desktop to another account,
239
+ and the imported one is marked *signed out · sign in again* rather than
240
+ quietly spending the other account's credits. Sign it in again, or use the
241
+ browser sign-in for accounts you want to keep independent.
168
242
 
169
243
  ## Development
170
244
 
package/README.zh-CN.md CHANGED
@@ -9,13 +9,21 @@ MiniMax、豆包。
9
9
 
10
10
  ## 安装
11
11
 
12
- 这是一个文件夹插件,没有发布到 npm:
12
+ 从 npm 安装:
13
13
 
14
14
  ```sh
15
- magpie plugin add /path/to/magpie-lobster
15
+ magpie plugin add opencode-lobster-auth
16
16
  magpie plugin login lobster
17
17
  ```
18
18
 
19
+ 之后用 `magpie plugin update` 升级。
20
+
21
+ 想跑本地 checkout 而不是已发布的包,就把路径指过去:
22
+
23
+ ```sh
24
+ magpie plugin add /path/to/magpie-lobster
25
+ ```
26
+
19
27
  ## 登录
20
28
 
21
29
  两种方式,任选其一。两者都用有道账号标识(`yid`)给账号命名,所以对一个
@@ -37,6 +45,7 @@ magpie plugin login lobster
37
45
  - Linux:`~/.config/LobsterAI/lobsterai.sqlite`
38
46
 
39
47
  库不存在,或者被桌面端加密存放,这条路会带着说明失败,改用浏览器登录即可。
48
+ 导入身份会与当前 token 交叉核对;身份缺失或冲突时会拒绝使用,避免误用别的账号。
40
49
 
41
50
  两种方式都保存在 magpie 的 `plugin-auth.json`。
42
51
 
@@ -69,64 +78,103 @@ completions。LobsterAI 有三个习惯被处理掉了:
69
78
 
70
79
  - **所有回复都是 SSE**,即使请求里写了 `stream: false`。非流式调用方会拿到
71
80
  由它重组出的一个完整 `chat.completion`;流式调用方拿到的就是原样的事件。
72
- - **错误藏在 200 里。** LobsterAI 把错误放进流里(`event: error`),状态码
73
- 仍是 200 —— 这样调用方没有可故障切换的依据。插件读首条记录,还原成它真正
74
- 表示的状态:`40100` → 401,`40300` → 403,`42900` 或限流措辞 → 429,其余
75
- → 400。被 LobsterAI 拒绝的登录是真 401,原样透传并带上 magpie 的失效标记,
76
- 账号因此会被标出来。
81
+ - **错误藏在 200 里。** LobsterAI 把错误放进流里(`event: error` 或 JSON 错误帧),
82
+ 状态码仍是 200 —— 这样调用方没有可故障切换的依据。插件检查开头的帧并保留
83
+ 纯文本错误;非流式请求还会捕获首个回复分片之后出现的错误。它会还原成真正的
84
+ 状态:`40100` → 401,`40300` → 403,`42900` 或限流措辞 → 429,其余 → 400。
85
+ 流式请求开始输出后无法再改 HTTP 状态,因此中途错误事件会原样保留。被 LobsterAI
86
+ 拒绝的登录是真 401,带上 magpie 的失效标记,账号因此会被标出来。
77
87
  - **请求带上桌面端的标识头**(`X-LobsterAI-Client-Version`、
78
88
  `X-LobsterAI-Client-Capabilities`)。LobsterAI 不强制要求,但它们标明了
79
89
  插件所说的特性集。
80
90
 
81
91
  ### 思考档位
82
92
 
83
- 模型的 `thinkingConfig` 决定它有哪些档位 —— 当前目录里是 `off`、`high`、
84
- `max` —— 它们通过 LobsterAI 自己的字段上线:
85
- `{"lobsterai_options":{"version":1,"thinking":{"level":"off"}}}`。
93
+ 目录里的模型分两种,插件用两种方式对待。
94
+
95
+ **8 个模型自带 `thinkingConfig`**(`deepseek-*`、`glm-*`),档位是
96
+ `off`/`high`/`max`。它们走 LobsterAI 自己的字段:
97
+ `{"lobsterai_options":{"version":1,"thinking":{"level":"off"}}}`。把该字段发给
98
+ **没有**档位表的模型是硬错误(`code 4000`,*model does not have a valid
99
+ thinkingConfig*),所以只在有档位表可映射时才写。有档位表时只声明当前 profile
100
+ 提供的档位;单独实测的 `reasoning_effort` 档位只用于尚无 profile 的模型。
101
+
102
+ **10 个模型没有档位表**,但同样报 `supportsThinking: true`。对这些模型,插件
103
+ 声明档位、让 magpie 的 `reasoning_effort` 原样透传,LobsterAI 认这个字段。
86
104
 
87
105
  有两件事值得知道:
88
106
 
89
- - **`lobsterai_options` 只发给自带档位表的模型。** 29 个模型里有 21 个没有
90
- 档位表,给这些模型带上该字段是硬错误(`code 4000`,*model does not have a
91
- valid thinkingConfig*),整轮对话直接失败。只有在有档位表可映射时,档位才会
92
- 被传下去。
93
107
  - **最低档对外叫 `none`,不叫 `off`。** magpie 不使用 variant 里存的东西,它
94
108
  按自己的一条阶梯(`none/minimal/low/medium/high/xhigh/max`)把
95
109
  `reasoning_effort` 映射到「模型声明过的那个档位名」。`off` 不在那条阶梯上,
96
- 所以声明成 `off` 的那一档永远选不中。插件对外声明 `none`,写给 LobsterAI
97
- 时再换回 `off`。
110
+ 所以声明成 `off` 的那一档永远选不中。有档位表时插件声明 `none`、写给
111
+ LobsterAI 时换回 `off`;没有档位表时直接发 `none`,LobsterAI 就认它。
112
+ - **每个模型的档位不一样,是实测出来的,不是假设的。** LobsterAI 会直接拒收
113
+ 一部分:`MiniMax-M3.1-Flash` 拒收 `none`(*requires adaptive thinking*),
114
+ `qwen3.7-max` 只到 `xhigh`,`qwen3.7-plus` 的 `max` 偶发 500。`MiniMax-M3`
115
+ 的思考写在 content 里的 `<think>` 标签中、`reasoning_content` 恒为空,上游
116
+ 也不分档,所以只给 `none` 和 `max`。没实测过的模型一律不声明档位,magpie 会
117
+ 显示成 *thinks, no levels*。
98
118
 
99
119
  在 `deepseek-flash` 上实测:`none` 会让 `reasoning_content` 恰好为 0 字节,
100
- 而 `high`、`max` 和不指定档位都不会。
120
+ 而 `high`、`max` 和不指定档位都不会。在 `kimi-k3.1-pro` 上实测:`none` 为
121
+ 0 字节,`xhigh` 有输出。
101
122
 
102
- ### Kimi K3
123
+ ### Kimi
103
124
 
104
- LobsterAI 的 `kimi-k3` 采样参数由服务端固定,因此 `temperature`、`top_p`、
105
- `n` 和两个 penalty 会被删掉,`reasoning_effort` 固定为 `max`,缺少
106
- `reasoning_content` 的 assistant 工具调用会被补一个空串。
125
+ 所有 Kimi 模型的采样参数都由服务端固定:`temperature` 必须是 1、`top_p` 是
126
+ 0.95、两个 penalty 是 0。所以插件对整个家族删掉 `temperature`、`top_p`、
127
+ `n` 和两个 penalty —— `kimi-k3`、`kimi-k3.1-pro`、`kimi-k2.8-preview` 三个
128
+ 都实测过。客户端只要带了非 1 的 `temperature`(多数 agent 都带),否则会被
129
+ 回一句 `50204 invalid temperature: only 1 is allowed`。
107
130
 
108
- `kimi-k3` 属于 LobsterAI「能用但没列出」的模型:`/api/models/available`
109
- 返回 29 个模型,其中没有 K3,但对 `kimi-k3` 发请求能正常作答。它保留在
110
- 本插件为未登录账号声明的兜底表里,所以 `lobster/kimi-k3` 是可选的 ——
111
- 上面那段处理也因此是**实际生效的**,不是预防性代码。
131
+ 只有 `kimi-k3` 本身将 `reasoning_effort` 固定为 `max`;这个规则**没有**扩展到
132
+ `kimi-k3.1-pro`:后者实测认完整档位,强制 max 会把每一档都悄悄覆盖掉。
133
+ 工具历史补齐是另一条独立规则:保留对 `kimi-k3` 前缀家族(含 `kimi-k3.1-pro`)
134
+ 的兼容处理,缺少 `reasoning_content` 的 assistant 工具调用补空串,但不覆盖已有
135
+ 思考内容,也不改变用户选择的档位。
136
+
137
+ `kimi-k3` 属于 LobsterAI「能用但某些版本目录里没列出」的模型。它保留在本插件
138
+ 为未登录账号声明的兜底表里,所以 `lobster/kimi-k3` 仍可选。
112
139
 
113
140
  ## 模型
114
141
 
115
- 登录后,列表来自账号自己(`/api/models/available`):29 个模型,各带上下文
116
- 窗口、是否收图片、以及它的思考档位。登录之前用一份 5 个模型的静态列表兜底,
117
- 这样 provider 在登录前就可见。
142
+ 登录后,列表来自账号自己(`/api/models/available`)—— 18 或 19 个可用模型,
143
+ 各带上下文窗口、是否收图片、以及它的思考档位。登录之前用一份 5 个模型的静态
144
+ 列表兜底,这样 provider 在登录前就可见。
118
145
 
119
146
  `costMultiplier` 成为 magpie 的 `rate`。它是**分时计价**:`deepseek-flash`
120
147
  在 LobsterAI 的空闲时段是 0.05,高峰时段是 0.1(高峰为北京时间
121
148
  09:00–12:00 与 14:00–18:00)。倍率是在拉列表时读的,所以可能有一阵子是过时
122
149
  的。
123
150
 
151
+ 输出上限是 131,072。目录里的 `maxTokens` 字段恒为 null,所以这是实测的:
152
+ 18 个模型全部接受 `max_tokens=131072`,`glm-5.3` 自己报的范围就是
153
+ `[1, 131072]`,要到 524,288 才开始 500。
154
+
124
155
  ## 用量
125
156
 
126
- `magpie quota` 显示免费额度、它的到期时间和剩余点数,数据来自
127
- `/api/user/quota` 与 `/api/user/profile-summary`。插件上报的是 `kept`:它自己
157
+ `magpie quota` 显示剩余点数与最早到期的那批积分,数据来自
158
+ `/api/user/quota` 与 `/api/user/profile-summary`。积分按**钱包窗口**上报
159
+ (`余额·点数`,带 `runsOut`/`runsOutAt`),而不是一个字符串 —— 这样 magpie
160
+ 能显示出到期时间,路由也能优先用快过期的账号。每天签到领的 100 点各是一批、
161
+ 30 天后到期、先到先用,所以知道日期是有意义的。插件上报的是 `kept`:它自己
128
162
  不续期,因此不该声称做过一次它没做的续期。
129
163
 
164
+ 已失效的免费额度不再作为有效窗口显示 —— 免费期 2026 年 8 月就结束了,
165
+ 之前还按「6% used」显示,看起来像还有一笔可用额度。
166
+
167
+ ## 每日签到
168
+
169
+ 插件实现了 `auth.checkin`,所以 magpie 可以替你领 LobsterAI 的每日积分礼
170
+ (100 点)。**默认关闭**:在 Settings → Usage → Plugins 打开。打开后,每个
171
+ 已登录的 LobsterAI 账号每天(北京时间)各签一次。
172
+
173
+ 这是不开 LobsterAI 桌面端时唯一能领到这些积分的办法 —— 签到入口在桌面端侧栏。
174
+ 领取是幂等的:当天已领会回 `51104`,插件报 `done` 而不是重复领;每次领取都会
175
+ 重新读一次状态复核,通过了才报 `claimed`。HTTP 故障、损坏的业务响应或无效
176
+ 状态统一报 `failed`,不冒充“无活动”或“无资格”,让 magpie 可以当天稍后重试。
177
+
130
178
  ## 不做的事
131
179
 
132
180
  - **企业账号。** LobsterAI 让它们走另一个页面(`EnterpriseIdentitySelect`)。
@@ -134,13 +182,20 @@ LobsterAI 的 `kimi-k3` 采样参数由服务端固定,因此 `temperature`、
134
182
  - **经过验证的 `/api/auth/refresh`。** 调用它可能消耗账号真实的 refresh
135
183
  token,因此没有实测。续期这条路是照着桌面端自己的代码写的,没有与服务端
136
184
  核对过。
137
- - **精确的输出上限。** 目录里没有这个字段。插件声明了保守的 32,000;实测
138
- LobsterAI 接受 262,144,在 524,288 时返回 500,所以真实上限在两者之间。
139
185
  - **OpenCode。** 这个插件是按 OpenCode 的 provider 插件格式写的 —— magpie
140
186
  加载的正是这个格式 —— 但只在 magpie 上实测过。`auth.refresh`、
141
- `auth.refreshLead` 以及 `package.json` 里的 `magpie` 字段是 magpie 自己的,
142
- OpenCode 会忽略它们。
143
- - **同时使用多个账号。** magpie 每个 provider 只保留一份登录。
187
+ `auth.refreshLead`、`auth.checkin` 以及 `package.json` 里的 `magpie` 字段
188
+ 是 magpie 自己的,OpenCode 会忽略它们。
189
+
190
+ ## 多个账号
191
+
192
+ magpie 允许一个 provider 挂多个登录,本插件支持这一点:用浏览器再登一个有道
193
+ 账号,两个都会列出来。
194
+
195
+ 桌面端导入是需要注意的那个。LobsterAI 桌面端同一时间只有一个账号登录,而插件
196
+ 每次请求都现读它的 token。所以桌面导入的账号绑在「导入那一刻登录的账号」上:
197
+ 把桌面端切到别的账号后,原账号会被标成 *需要重新登录*,而不是悄悄花掉另一个
198
+ 账号的积分。重新登录它,或者对需要独立存在的账号改用浏览器登录。
144
199
 
145
200
  ## 开发
146
201
 
package/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { modelsFromCatalog, profileOfFrom } from "./lib/models.mjs"
4
4
  import { usageFrom } from "./lib/usage.mjs"
5
5
  import { desktopSignIn, desktopToken } from "./lib/auth-import.mjs"
6
6
  import { beginSignIn, beginSignInAnnounced } from "./lib/auth-login.mjs"
7
+ import { checkinWith } from "./lib/checkin.mjs"
7
8
  import { jwtExp } from "./lib/jwt.mjs"
8
9
 
9
10
  const REFRESH_LEAD = 6 * 3600 * 1000
@@ -28,10 +29,11 @@ async function apiGet(path, token, timeout = 20_000) {
28
29
 
29
30
  // 取这次请求要用的 token。
30
31
  // 桌面端导入的账号自己不存 token(见 lib/auth-import.mjs),每次现读 ——
31
- // 桌面端续期后插件自动跟上。读不到就抛 signIn:"expired"。
32
+ // 桌面端续期后插件自动跟上。读不到、或桌面端已经换成别的账号,都抛
33
+ // signIn:"expired"(多账号时后者防止串号)。
32
34
  async function tokenOf(auth) {
33
35
  if (!auth) return ""
34
- if (auth.source === "desktop") return desktopToken()
36
+ if (auth.source === "desktop") return desktopToken(process.env, auth.accountId, auth.uid)
35
37
  return auth.type === "oauth" ? (auth.access ?? "") : (auth.key ?? "")
36
38
  }
37
39
 
@@ -177,6 +179,20 @@ export const LobsterAuthPlugin = async () => ({
177
179
  }
178
180
  }
179
181
  },
182
+
183
+ // 每日积分礼(签到)。magpie 在开关打开后,对每个已登录账号每天各调一次。
184
+ // 这是写操作,开关默认关,由用户在 Settings → Usage → Plugins 打开。
185
+ // 逻辑见 lib/checkin.mjs;这里只负责取 token 和兜住异常。
186
+ async checkin(getAuth) {
187
+ const auth = await getAuth()
188
+ let token = ""
189
+ try {
190
+ token = await tokenOf(auth)
191
+ } catch (e) {
192
+ return { outcome: "failed", message: e.message }
193
+ }
194
+ return checkinWith(token)
195
+ },
180
196
  },
181
197
 
182
198
  provider: {
package/lib/assemble.mjs CHANGED
@@ -2,6 +2,8 @@
2
2
  // 流式请求原样转发;非流式请求要把分片拼成一个完整的 chat.completion。
3
3
 
4
4
  import { randomBytes } from "node:crypto"
5
+ import { errorFrame, StreamError } from "./stream-error.mjs"
6
+ export { StreamError } from "./stream-error.mjs"
5
7
 
6
8
  const enc = new TextEncoder()
7
9
 
@@ -23,6 +25,8 @@ export async function mergeChunks(records, model) {
23
25
  let reasoning = ""
24
26
 
25
27
  for await (const rec of records) {
28
+ const bad = errorFrame(rec)
29
+ if (bad) throw new StreamError(bad)
26
30
  if (rec.data === "[DONE]") break
27
31
  let v
28
32
  try { v = JSON.parse(rec.data) } catch { continue }
@@ -61,7 +65,9 @@ function streamOut(records) {
61
65
  try {
62
66
  for await (const rec of records) {
63
67
  if (rec.data === "[DONE]") break
64
- controller.enqueue(enc.encode(`data: ${rec.data}\n\n`))
68
+ const event = rec.event ? `event: ${rec.event}\n` : ""
69
+ const data = rec.data.split("\n").map((line) => `data: ${line}`).join("\n")
70
+ controller.enqueue(enc.encode(`${event}${data}\n\n`))
65
71
  }
66
72
  controller.enqueue(enc.encode("data: [DONE]\n\n"))
67
73
  controller.close()
@@ -30,6 +30,12 @@ const parse = (s) => {
30
30
  } catch { return null }
31
31
  }
32
32
 
33
+ const identityValue = (value) => {
34
+ if (typeof value === "string") return value.trim() || undefined
35
+ if (typeof value === "number" && Number.isFinite(value)) return String(value)
36
+ return undefined
37
+ }
38
+
33
39
  export function readCredentials(path) {
34
40
  if (!path || !existsSync(path)) return null
35
41
  let db
@@ -43,11 +49,18 @@ export function readCredentials(path) {
43
49
  if (!accessToken) return null
44
50
  const user = parse(byKey.auth_user) ?? {}
45
51
  const claims = accountOf(accessToken)
52
+ // 两行可能分开更新。身份必须来自实际使用的 token;用户行只能在已有
53
+ // 共同标识吻合、且没有任何冲突时补全字段,不能让旧用户行替新 token 作证。
54
+ const fields = ["yid", "id"]
55
+ const shared = fields.filter((key) => identityValue(claims[key]) !== undefined && identityValue(user[key]) !== undefined)
56
+ const identityConflict = shared.some((key) => identityValue(claims[key]) !== identityValue(user[key]))
57
+ const verifiedUser = shared.length > 0 && !identityConflict
46
58
  return {
47
59
  accessToken,
48
60
  refreshToken: String(tokens?.refreshToken ?? ""),
49
- yid: user.yid ?? claims.yid,
50
- id: user.id ?? claims.id,
61
+ yid: identityValue(claims.yid) !== undefined ? claims.yid : verifiedUser ? user.yid : undefined,
62
+ id: identityValue(claims.id) !== undefined ? claims.id : verifiedUser ? user.id : undefined,
63
+ identityConflict,
51
64
  nickname: user.nickname,
52
65
  expires: jwtExp(accessToken),
53
66
  }
@@ -65,6 +78,24 @@ export function readCredentials(path) {
65
78
  // 为什么不把 token 抄一份:抄下来的 refreshToken 会是桌面端和插件共用的,
66
79
  // 谁先续期谁就把对方踢下线。留空则 magpie 永不续期(expires 为 0),
67
80
  // 桌面端的会话不受任何影响。
81
+ // 账号身份。magpie 支持一个插件挂多个账号,靠 accountId 区分,所以这个值
82
+ // 既是「这个号叫什么」,也是下次读桌面端时核对「还是不是同一个号」的依据。
83
+ // 优先用 yid(有道的账号标识),没有就退回数字 id 的字符串形式。
84
+ // yid 一律转成字符串:magpie 往返一轮后回来的是字符串,留着数字会让
85
+ // 同一个号在两侧比不相等,把好账号误判成「切换了账号」。
86
+ export function accountIdOf(c) {
87
+ return identityValue(c?.yid) ?? identityValue(c?.id)
88
+ }
89
+
90
+ function requireIdentity(c) {
91
+ if (c.identityConflict) {
92
+ throw Object.assign(new Error("LobsterAI 桌面端 token 与账号身份不一致(可能正在切换账号),请稍后重新登录"), { signIn: "expired" })
93
+ }
94
+ if (!accountIdOf(c)) {
95
+ throw Object.assign(new Error("LobsterAI 桌面端 token 的账号身份不可读,请重新登录本账号"), { signIn: "expired" })
96
+ }
97
+ }
98
+
68
99
  export function desktopSignIn(env = process.env) {
69
100
  const path = dbPath(env)
70
101
  const c = readCredentials(path)
@@ -74,21 +105,31 @@ export function desktopSignIn(env = process.env) {
74
105
  "请先在 LobsterAI 桌面端登录,或改用「浏览器登录」。",
75
106
  )
76
107
  }
108
+ requireIdentity(c)
77
109
  return {
78
110
  type: "success",
79
111
  refresh: "",
80
112
  access: "",
81
113
  expires: 0,
82
114
  source: "desktop",
83
- accountId: c.yid ?? (c.id !== undefined ? String(c.id) : undefined),
84
- uid: c.id,
115
+ accountId: accountIdOf(c),
116
+ // magpie 只认字符串 uid(源码 uidOf),存数字等于没存
117
+ uid: identityValue(c.id),
85
118
  }
86
119
  }
87
120
 
88
121
  // 桌面端账号的 token:每次现读,桌面端换了 token 插件立刻跟上。
89
122
  // 读不到就是账号不可用(桌面端退出登录、库被删、文件被加密),
90
123
  // 报 signIn:"expired" 让 magpie 给账号打上失效标记,提示用户重新登录。
91
- export function desktopToken(env = process.env) {
124
+ //
125
+ // expected 是这个账号签入时记下的身份,expectedUid 是当时记下的数字 id。
126
+ // 桌面端只有一个「当前登录」,换账号后现读到的就是别人的 token —— 不核对的话,
127
+ // 原账号会显示别人的余额、扣别人的积分、签到签在别人头上。
128
+ //
129
+ // 至少一个绑定标识要匹配,同时不能有其它可比较标识冲突。签入时只有数字 id、
130
+ // 后来补上 yid 的情况仍可按 id 识别;但 uid 相同不能掩盖已绑定 yid 的变化。
131
+ // 没保存绑定身份的老账号仍兼容;token 无身份或与用户行冲突时一律 fail-closed。
132
+ export function desktopToken(env = process.env, expected, expectedUid) {
92
133
  const c = readCredentials(dbPath(env))
93
134
  if (!c) {
94
135
  throw Object.assign(
@@ -96,5 +137,29 @@ export function desktopToken(env = process.env) {
96
137
  { signIn: "expired" },
97
138
  )
98
139
  }
140
+ requireIdentity(c)
141
+ const account = identityValue(expected)
142
+ const uid = identityValue(expectedUid)
143
+ if (account === undefined && uid === undefined) return c.accessToken
144
+ const now = accountIdOf(c)
145
+ const currentYid = identityValue(c.yid)
146
+ const currentUid = identityValue(c.id)
147
+ const accountWasUid = account !== undefined && (account === uid || account === currentUid)
148
+ const conflict =
149
+ (uid !== undefined && currentUid !== undefined && uid !== currentUid) ||
150
+ (account !== undefined && currentYid !== undefined && account !== currentYid && !accountWasUid)
151
+ const sameAccount =
152
+ (account !== undefined && (account === now || account === currentUid)) ||
153
+ (uid !== undefined && uid === currentUid)
154
+ if (conflict || !sameAccount) {
155
+ throw Object.assign(
156
+ new Error(
157
+ now
158
+ ? `LobsterAI 桌面端已切换到其它账号(当前 ${now},本账号 ${expected}),请重新登录本账号`
159
+ : "LobsterAI 桌面端当前登录的账号身份不可读(可能刚切换过),请确认后在桌面端重新登录本账号",
160
+ ),
161
+ { signIn: "expired" },
162
+ )
163
+ }
99
164
  return c.accessToken
100
165
  }