opencode-lobster-auth 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -43
- package/README.zh-CN.md +90 -35
- package/index.mjs +18 -2
- package/lib/assemble.mjs +7 -1
- package/lib/auth-import.mjs +70 -5
- package/lib/checkin.mjs +240 -0
- package/lib/constants.mjs +28 -2
- package/lib/models.mjs +11 -5
- package/lib/proxy.mjs +51 -19
- package/lib/sse.mjs +10 -14
- package/lib/stream-error.mjs +34 -0
- package/lib/thinking.mjs +58 -12
- package/lib/usage.mjs +83 -41
- package/package.json +38 -37
package/README.md
CHANGED
|
@@ -9,13 +9,21 @@ Kimi, Qwen, MiniMax and Doubao.
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
From npm:
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
|
-
magpie plugin add
|
|
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
|
|
87
|
-
(`event: error
|
|
88
|
-
nothing to fail over on. The plugin
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
116
|
-
`none` and turns it back into `off
|
|
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
|
|
151
|
+
### Kimi
|
|
122
152
|
|
|
123
|
-
|
|
124
|
-
`temperature
|
|
125
|
-
|
|
126
|
-
|
|
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`
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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`)
|
|
137
|
-
models, each with its context window, whether it takes images,
|
|
138
|
-
thinking levels. Until then a static list of five is used, so the
|
|
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
|
|
149
|
-
from `/api/user/quota` and `/api/user/profile-summary`. The
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
166
|
-
are magpie's own, which OpenCode ignores.
|
|
167
|
-
|
|
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
|
-
|
|
12
|
+
从 npm 安装:
|
|
13
13
|
|
|
14
14
|
```sh
|
|
15
|
-
magpie plugin add
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
→
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
`
|
|
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`
|
|
97
|
-
|
|
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
|
|
123
|
+
### Kimi
|
|
103
124
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
`
|
|
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`
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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`
|
|
142
|
-
OpenCode 会忽略它们。
|
|
143
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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()
|
package/lib/auth-import.mjs
CHANGED
|
@@ -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:
|
|
50
|
-
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:
|
|
84
|
-
uid
|
|
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
|
-
|
|
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
|
}
|