opencode-lobster-auth 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -2
- package/README.zh-CN.md +147 -0
- package/index.mjs +191 -0
- package/lib/assemble.mjs +87 -0
- package/lib/auth-import.mjs +100 -0
- package/lib/auth-login.mjs +131 -0
- package/lib/constants.mjs +41 -0
- package/lib/jwt.mjs +34 -0
- package/lib/models.mjs +77 -0
- package/lib/proxy.mjs +106 -0
- package/lib/sse.mjs +67 -0
- package/lib/thinking.mjs +98 -0
- package/lib/usage.mjs +41 -0
- package/package.json +23 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jasery
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,170 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-lobster-auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Signs in to a [LobsterAI](https://lobsterai.youdao.com) (有道龙虾, NetEase
|
|
4
|
+
Youdao's desktop agent) account and serves its models as the provider
|
|
5
|
+
`lobster` in [magpie](https://usemagpie.ai). About 30 models: DeepSeek, GLM,
|
|
6
|
+
Kimi, Qwen, MiniMax and Doubao.
|
|
7
|
+
|
|
8
|
+
**English** · [中文](README.zh-CN.md)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
A folder plugin — it is not published to npm:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
magpie plugin add /path/to/magpie-lobster
|
|
16
|
+
magpie plugin login lobster
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Signing in
|
|
20
|
+
|
|
21
|
+
Two ways, either one is enough. Both name the account by the Youdao account
|
|
22
|
+
id (`yid`), so signing in the second way to an account already signed in the
|
|
23
|
+
first way replaces it in place.
|
|
24
|
+
|
|
25
|
+
- **Browser (a Youdao account).** The plugin serves a callback on
|
|
26
|
+
`127.0.0.1` and magpie opens Youdao's sign-in page. The browser comes back
|
|
27
|
+
with a code, which is traded for tokens at `/api/auth/exchange`. Kept as
|
|
28
|
+
an `oauth` sign-in holding the access and refresh tokens and their expiry,
|
|
29
|
+
so magpie renews it.
|
|
30
|
+
- **The desktop's sign-in.** Uses the account LobsterAI's own desktop app is
|
|
31
|
+
signed in to. **No page is opened and nothing is typed.** The app's SQLite
|
|
32
|
+
database is read, never written:
|
|
33
|
+
- Windows: `%APPDATA%\LobsterAI\lobsterai.sqlite`
|
|
34
|
+
- macOS: `~/Library/Application Support/LobsterAI/lobsterai.sqlite`
|
|
35
|
+
- Linux: `~/.config/LobsterAI/lobsterai.sqlite`
|
|
36
|
+
|
|
37
|
+
A database that isn't there, or one the app keeps encrypted, fails this
|
|
38
|
+
way with a message saying so; use the browser way instead.
|
|
39
|
+
|
|
40
|
+
Both are kept in magpie's `plugin-auth.json`.
|
|
41
|
+
|
|
42
|
+
### Why the import keeps no token
|
|
43
|
+
|
|
44
|
+
The desktop way stores **no credential at all** — `access` and `refresh` are
|
|
45
|
+
empty and `expires` is 0 — and reads the token from the app's database on
|
|
46
|
+
every request:
|
|
47
|
+
|
|
48
|
+
- A copied `refreshToken` would be shared with the app: whichever side
|
|
49
|
+
refreshes first signs the other out. Empty means neither disturbs the
|
|
50
|
+
other.
|
|
51
|
+
- `expires` of 0 means magpie never renews it — magpie renews only an
|
|
52
|
+
`oauth` sign-in whose expiry is set — so the app's session is never
|
|
53
|
+
touched.
|
|
54
|
+
- Reading it fresh means the plugin follows a token the app renewed, with no
|
|
55
|
+
need to sign in again.
|
|
56
|
+
|
|
57
|
+
The cost: if the app is signed out, uninstalled, or its database becomes
|
|
58
|
+
unreadable, the account stops working at once and magpie is told to ask for
|
|
59
|
+
a new sign-in. That is more honest than a token that quietly went stale.
|
|
60
|
+
|
|
61
|
+
### Renewal
|
|
62
|
+
|
|
63
|
+
A browser sign-in is renewed by magpie through the plugin's `auth.refresh`,
|
|
64
|
+
six hours before it ends. The desktop's sign-in is structurally never
|
|
65
|
+
renewed, as above.
|
|
66
|
+
|
|
67
|
+
The plugin never calls LobsterAI's own refresh endpoint to check or renew
|
|
68
|
+
anything: `/api/auth/refresh` may spend the account's real refresh token, so
|
|
69
|
+
it is deliberately left alone.
|
|
70
|
+
|
|
71
|
+
## Requests
|
|
72
|
+
|
|
73
|
+
Chats are chat completions at
|
|
74
|
+
`https://lobsterai-server.youdao.com/api/proxy/v1`. Three of LobsterAI's
|
|
75
|
+
habits are handled:
|
|
76
|
+
|
|
77
|
+
- **Every reply is SSE**, even one asked for with `stream: false`. A
|
|
78
|
+
non-streaming caller gets one `chat.completion` reassembled from it; a
|
|
79
|
+
streaming caller gets the events as they are.
|
|
80
|
+
- **Failures ride inside a 200.** LobsterAI puts an error in the stream
|
|
81
|
+
(`event: error`) and still answers 200, which would leave a caller with
|
|
82
|
+
nothing to fail over on. The plugin reads the first record and answers the
|
|
83
|
+
status it means: `40100` → 401, `40300` → 403, `42900` or rate-limit
|
|
84
|
+
wording → 429, anything else → 400. A sign-in LobsterAI turns down is a
|
|
85
|
+
genuine 401, passed through with magpie's lapse mark so the account is
|
|
86
|
+
flagged.
|
|
87
|
+
- **Requests carry the desktop client's headers**
|
|
88
|
+
(`X-LobsterAI-Client-Version`, `X-LobsterAI-Client-Capabilities`).
|
|
89
|
+
LobsterAI doesn't insist on them, but they name the feature set the plugin
|
|
90
|
+
speaks.
|
|
91
|
+
|
|
92
|
+
### Thinking levels
|
|
93
|
+
|
|
94
|
+
A model's `thinkingConfig` gives it levels — `off`, `high`, `max` in the
|
|
95
|
+
current catalogue — and they reach the wire in LobsterAI's own field:
|
|
96
|
+
`{"lobsterai_options":{"version":1,"thinking":{"level":"off"}}}`.
|
|
97
|
+
|
|
98
|
+
Two things are worth knowing:
|
|
99
|
+
|
|
100
|
+
- **`lobsterai_options` is only for models that have a thinking profile.**
|
|
101
|
+
21 of the 29 models have none, and sending the field to one of those is a
|
|
102
|
+
hard error (`code 4000`, *model does not have a valid thinkingConfig*), so
|
|
103
|
+
the whole chat fails. A level is passed on only where there is a profile
|
|
104
|
+
to map it through.
|
|
105
|
+
- **The lowest level is offered as `none`, not `off`.** magpie doesn't use
|
|
106
|
+
what a variant holds; it maps `reasoning_effort` onto a level name the
|
|
107
|
+
model declared, from a ladder of its own
|
|
108
|
+
(`none/minimal/low/medium/high/xhigh/max`). `off` is not on that ladder,
|
|
109
|
+
so a level declared as `off` can never be chosen. The plugin declares
|
|
110
|
+
`none` and turns it back into `off` for LobsterAI.
|
|
111
|
+
|
|
112
|
+
Measured on `deepseek-flash`: `none` leaves `reasoning_content` at exactly
|
|
113
|
+
0 bytes, while `high`, `max` and no level at all don't.
|
|
114
|
+
|
|
115
|
+
### Kimi K3
|
|
116
|
+
|
|
117
|
+
LobsterAI's `kimi-k3` takes its sampling parameters from the server, so
|
|
118
|
+
`temperature`, `top_p`, `n` and the penalties are dropped, `reasoning_effort`
|
|
119
|
+
is fixed to `max`, and an assistant tool call missing `reasoning_content` is
|
|
120
|
+
given an empty one. No model in the current catalogue is K3; the handling is
|
|
121
|
+
there for when one is.
|
|
122
|
+
|
|
123
|
+
## Models
|
|
124
|
+
|
|
125
|
+
Signed in, the list is the account's own (`/api/models/available`): 29
|
|
126
|
+
models, each with its context window, whether it takes images, and its
|
|
127
|
+
thinking levels. Until then a static list of five is used, so the provider
|
|
128
|
+
is visible before signing in.
|
|
129
|
+
|
|
130
|
+
`costMultiplier` becomes magpie's `rate`. It is **time-of-day pricing**:
|
|
131
|
+
`deepseek-flash` is 0.05 in LobsterAI's off-peak hours and 0.1 in the peak
|
|
132
|
+
ones (09:00–12:00 and 14:00–18:00, Beijing time). The rate is read when the
|
|
133
|
+
list is, so it can be a while out of date.
|
|
134
|
+
|
|
135
|
+
## Usage
|
|
136
|
+
|
|
137
|
+
`magpie quota` shows the free allowance, when it ends and the credits left,
|
|
138
|
+
from `/api/user/quota` and `/api/user/profile-summary`. The plugin reports
|
|
139
|
+
`kept`: it renews nothing itself, so it must not claim a renewal it didn't
|
|
140
|
+
do.
|
|
141
|
+
|
|
142
|
+
## Not here
|
|
143
|
+
|
|
144
|
+
- **Company accounts.** LobsterAI sends them through a different page
|
|
145
|
+
(`EnterpriseIdentitySelect`). Only personal accounts are handled.
|
|
146
|
+
- **A verified `/api/auth/refresh`.** Calling it may spend the account's
|
|
147
|
+
real refresh token, so it is not exercised. The renewal path is written
|
|
148
|
+
from the app's own code and has not been confirmed against the service.
|
|
149
|
+
- **An exact output limit.** The catalogue has no such field. The plugin
|
|
150
|
+
declares a conservative 32,000; measured, LobsterAI takes 262,144 and
|
|
151
|
+
answers 500 at 524,288, so the real ceiling is somewhere between.
|
|
152
|
+
- **OpenCode.** The plugin is written in OpenCode's provider-plugin format —
|
|
153
|
+
that is the format magpie loads — but it has only been exercised in magpie.
|
|
154
|
+
`auth.refresh`, `auth.refreshLead` and the `magpie` field in `package.json`
|
|
155
|
+
are magpie's own, which OpenCode ignores.
|
|
156
|
+
- **Several accounts at once.** magpie keeps one sign-in per provider.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
bun test
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The entry point only assembles the hooks. The logic lives in `lib/` as plain
|
|
165
|
+
functions and is reached through `_internal`, because magpie and OpenCode
|
|
166
|
+
both treat every exported function as a plugin of its own.
|
|
167
|
+
|
|
168
|
+
## License
|
|
169
|
+
|
|
170
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# opencode-lobster-auth
|
|
2
|
+
|
|
3
|
+
为 [magpie](https://usemagpie.ai) 提供 provider `lobster`:登录
|
|
4
|
+
[LobsterAI](https://lobsterai.youdao.com)(有道龙虾,网易有道的桌面 agent)
|
|
5
|
+
账号,把它名下的模型接进来。约 30 个模型,含 DeepSeek、GLM、Kimi、Qwen、
|
|
6
|
+
MiniMax、豆包。
|
|
7
|
+
|
|
8
|
+
[English](README.md) · **中文**
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
这是一个文件夹插件,没有发布到 npm:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
magpie plugin add /path/to/magpie-lobster
|
|
16
|
+
magpie plugin login lobster
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 登录
|
|
20
|
+
|
|
21
|
+
两种方式,任选其一。两者都用有道账号标识(`yid`)给账号命名,所以对一个
|
|
22
|
+
已经用第一种方式登录过的账号再用第二种登录,是原地替换。
|
|
23
|
+
|
|
24
|
+
- **浏览器登录(有道账号)。** 插件在本机 `127.0.0.1` 起一个回调服务,
|
|
25
|
+
magpie 打开有道登录页。浏览器带着授权码跳回来,插件拿它在
|
|
26
|
+
`/api/auth/exchange` 换取 token。存成一个 `oauth` 账号,带 access 与
|
|
27
|
+
refresh token 及到期时间,因此 magpie 会为它续期。
|
|
28
|
+
- **从桌面端导入。** 使用 LobsterAI 桌面端当前登录的账号。**不打开任何页面,
|
|
29
|
+
不需要输入任何东西。** 只读桌面端的 SQLite 库,绝不写入:
|
|
30
|
+
- Windows:`%APPDATA%\LobsterAI\lobsterai.sqlite`
|
|
31
|
+
- macOS:`~/Library/Application Support/LobsterAI/lobsterai.sqlite`
|
|
32
|
+
- Linux:`~/.config/LobsterAI/lobsterai.sqlite`
|
|
33
|
+
|
|
34
|
+
库不存在,或者被桌面端加密存放,这条路会带着说明失败,改用浏览器登录即可。
|
|
35
|
+
|
|
36
|
+
两种方式都保存在 magpie 的 `plugin-auth.json`。
|
|
37
|
+
|
|
38
|
+
### 为什么导入这条路不保存 token
|
|
39
|
+
|
|
40
|
+
导入方式**一个字节的凭据都不存**(`access` 和 `refresh` 都是空串,`expires`
|
|
41
|
+
为 0),只在每次请求时现读桌面端的库:
|
|
42
|
+
|
|
43
|
+
- 抄一份 `refreshToken` 会与桌面端共用同一个凭据:**谁先续期谁就把对方踢
|
|
44
|
+
下线**。留空则双方互不干扰。
|
|
45
|
+
- `expires` 为 0,magpie 因此永不续期(它只对设了到期时间的 `oauth` 账号做
|
|
46
|
+
续期),桌面端的会话不会被搅动。
|
|
47
|
+
- 现读意味着桌面端续期之后,插件下一次请求自动用上新 token,不必重新登录。
|
|
48
|
+
|
|
49
|
+
代价是:桌面端退出登录、卸载,或者库变得不可读,这个账号会**立刻**失效,
|
|
50
|
+
并被标记为需要重新登录。这比用一个已经悄悄失效的旧 token 更诚实。
|
|
51
|
+
|
|
52
|
+
### 续期
|
|
53
|
+
|
|
54
|
+
浏览器登录的账号由 magpie 通过插件的 `auth.refresh` 在到期前 6 小时续期。
|
|
55
|
+
导入的账号如上一节所述,结构性不会被续期。
|
|
56
|
+
|
|
57
|
+
插件从不调用 LobsterAI 自己的续期接口去验证或刷新任何东西:
|
|
58
|
+
`/api/auth/refresh` 可能消耗账号真实的 refresh token,因此刻意不碰它。
|
|
59
|
+
|
|
60
|
+
## 请求
|
|
61
|
+
|
|
62
|
+
对话走 `https://lobsterai-server.youdao.com/api/proxy/v1` 的 chat
|
|
63
|
+
completions。LobsterAI 有三个习惯被处理掉了:
|
|
64
|
+
|
|
65
|
+
- **所有回复都是 SSE**,即使请求里写了 `stream: false`。非流式调用方会拿到
|
|
66
|
+
由它重组出的一个完整 `chat.completion`;流式调用方拿到的就是原样的事件。
|
|
67
|
+
- **错误藏在 200 里。** LobsterAI 把错误放进流里(`event: error`),状态码
|
|
68
|
+
仍是 200 —— 这样调用方没有可故障切换的依据。插件读首条记录,还原成它真正
|
|
69
|
+
表示的状态:`40100` → 401,`40300` → 403,`42900` 或限流措辞 → 429,其余
|
|
70
|
+
→ 400。被 LobsterAI 拒绝的登录是真 401,原样透传并带上 magpie 的失效标记,
|
|
71
|
+
账号因此会被标出来。
|
|
72
|
+
- **请求带上桌面端的标识头**(`X-LobsterAI-Client-Version`、
|
|
73
|
+
`X-LobsterAI-Client-Capabilities`)。LobsterAI 不强制要求,但它们标明了
|
|
74
|
+
插件所说的特性集。
|
|
75
|
+
|
|
76
|
+
### 思考档位
|
|
77
|
+
|
|
78
|
+
模型的 `thinkingConfig` 决定它有哪些档位 —— 当前目录里是 `off`、`high`、
|
|
79
|
+
`max` —— 它们通过 LobsterAI 自己的字段上线:
|
|
80
|
+
`{"lobsterai_options":{"version":1,"thinking":{"level":"off"}}}`。
|
|
81
|
+
|
|
82
|
+
有两件事值得知道:
|
|
83
|
+
|
|
84
|
+
- **`lobsterai_options` 只发给自带档位表的模型。** 29 个模型里有 21 个没有
|
|
85
|
+
档位表,给这些模型带上该字段是硬错误(`code 4000`,*model does not have a
|
|
86
|
+
valid thinkingConfig*),整轮对话直接失败。只有在有档位表可映射时,档位才会
|
|
87
|
+
被传下去。
|
|
88
|
+
- **最低档对外叫 `none`,不叫 `off`。** magpie 不使用 variant 里存的东西,它
|
|
89
|
+
按自己的一条阶梯(`none/minimal/low/medium/high/xhigh/max`)把
|
|
90
|
+
`reasoning_effort` 映射到「模型声明过的那个档位名」。`off` 不在那条阶梯上,
|
|
91
|
+
所以声明成 `off` 的那一档永远选不中。插件对外声明 `none`,写给 LobsterAI
|
|
92
|
+
时再换回 `off`。
|
|
93
|
+
|
|
94
|
+
在 `deepseek-flash` 上实测:`none` 会让 `reasoning_content` 恰好为 0 字节,
|
|
95
|
+
而 `high`、`max` 和不指定档位都不会。
|
|
96
|
+
|
|
97
|
+
### Kimi K3
|
|
98
|
+
|
|
99
|
+
LobsterAI 的 `kimi-k3` 采样参数由服务端固定,因此 `temperature`、`top_p`、
|
|
100
|
+
`n` 和两个 penalty 会被删掉,`reasoning_effort` 固定为 `max`,缺少
|
|
101
|
+
`reasoning_content` 的 assistant 工具调用会被补一个空串。当前目录里没有 K3
|
|
102
|
+
模型;这段处理是为将来出现 K3 准备的。
|
|
103
|
+
|
|
104
|
+
## 模型
|
|
105
|
+
|
|
106
|
+
登录后,列表来自账号自己(`/api/models/available`):29 个模型,各带上下文
|
|
107
|
+
窗口、是否收图片、以及它的思考档位。登录之前用一份 5 个模型的静态列表兜底,
|
|
108
|
+
这样 provider 在登录前就可见。
|
|
109
|
+
|
|
110
|
+
`costMultiplier` 成为 magpie 的 `rate`。它是**分时计价**:`deepseek-flash`
|
|
111
|
+
在 LobsterAI 的空闲时段是 0.05,高峰时段是 0.1(高峰为北京时间
|
|
112
|
+
09:00–12:00 与 14:00–18:00)。倍率是在拉列表时读的,所以可能有一阵子是过时
|
|
113
|
+
的。
|
|
114
|
+
|
|
115
|
+
## 用量
|
|
116
|
+
|
|
117
|
+
`magpie quota` 显示免费额度、它的到期时间和剩余点数,数据来自
|
|
118
|
+
`/api/user/quota` 与 `/api/user/profile-summary`。插件上报的是 `kept`:它自己
|
|
119
|
+
不续期,因此不该声称做过一次它没做的续期。
|
|
120
|
+
|
|
121
|
+
## 不做的事
|
|
122
|
+
|
|
123
|
+
- **企业账号。** LobsterAI 让它们走另一个页面(`EnterpriseIdentitySelect`)。
|
|
124
|
+
目前只处理个人账号。
|
|
125
|
+
- **经过验证的 `/api/auth/refresh`。** 调用它可能消耗账号真实的 refresh
|
|
126
|
+
token,因此没有实测。续期这条路是照着桌面端自己的代码写的,没有与服务端
|
|
127
|
+
核对过。
|
|
128
|
+
- **精确的输出上限。** 目录里没有这个字段。插件声明了保守的 32,000;实测
|
|
129
|
+
LobsterAI 接受 262,144,在 524,288 时返回 500,所以真实上限在两者之间。
|
|
130
|
+
- **OpenCode。** 这个插件是按 OpenCode 的 provider 插件格式写的 —— magpie
|
|
131
|
+
加载的正是这个格式 —— 但只在 magpie 上实测过。`auth.refresh`、
|
|
132
|
+
`auth.refreshLead` 以及 `package.json` 里的 `magpie` 字段是 magpie 自己的,
|
|
133
|
+
OpenCode 会忽略它们。
|
|
134
|
+
- **同时使用多个账号。** magpie 每个 provider 只保留一份登录。
|
|
135
|
+
|
|
136
|
+
## 开发
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
bun test
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
入口只负责装配钩子。逻辑都在 `lib/` 下,是普通函数,通过 `_internal` 暴露
|
|
143
|
+
出来 —— 因为 magpie 和 OpenCode 都会把每一个导出的函数当成一个独立插件。
|
|
144
|
+
|
|
145
|
+
## 许可
|
|
146
|
+
|
|
147
|
+
MIT
|
package/index.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { PROVIDER, NAME, API, CHAT_BASE, NPM, FALLBACK_MODELS } from "./lib/constants.mjs"
|
|
2
|
+
import { makeFetch } from "./lib/proxy.mjs"
|
|
3
|
+
import { modelsFromCatalog, profileOfFrom } from "./lib/models.mjs"
|
|
4
|
+
import { usageFrom } from "./lib/usage.mjs"
|
|
5
|
+
import { desktopSignIn, desktopToken } from "./lib/auth-import.mjs"
|
|
6
|
+
import { beginSignIn } from "./lib/auth-login.mjs"
|
|
7
|
+
import { jwtExp } from "./lib/jwt.mjs"
|
|
8
|
+
|
|
9
|
+
const REFRESH_LEAD = 6 * 3600 * 1000
|
|
10
|
+
|
|
11
|
+
// 每个账号的模型 → 思考档位,供请求管线查表
|
|
12
|
+
const profiles = new Map()
|
|
13
|
+
|
|
14
|
+
async function apiGet(path, token, timeout = 20_000) {
|
|
15
|
+
const res = await fetch(API + path, {
|
|
16
|
+
headers: { authorization: `Bearer ${token}` },
|
|
17
|
+
signal: AbortSignal.timeout(timeout),
|
|
18
|
+
})
|
|
19
|
+
const text = await res.text()
|
|
20
|
+
let v
|
|
21
|
+
try { v = JSON.parse(text) } catch { throw new Error(`LobsterAI ${path}: 响应无法解析`) }
|
|
22
|
+
if (res.status === 401) {
|
|
23
|
+
throw Object.assign(new Error("LobsterAI 的登录已过期,请重新登录"), { signIn: "expired" })
|
|
24
|
+
}
|
|
25
|
+
if (v?.code !== 0) throw new Error(`LobsterAI ${path}: ${v?.message || `code ${v?.code}`}`)
|
|
26
|
+
return v.data
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 取这次请求要用的 token。
|
|
30
|
+
// 桌面端导入的账号自己不存 token(见 lib/auth-import.mjs),每次现读 ——
|
|
31
|
+
// 桌面端续期后插件自动跟上。读不到就抛 signIn:"expired"。
|
|
32
|
+
async function tokenOf(auth) {
|
|
33
|
+
if (!auth) return ""
|
|
34
|
+
if (auth.source === "desktop") return desktopToken()
|
|
35
|
+
return auth.type === "oauth" ? (auth.access ?? "") : (auth.key ?? "")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const LobsterAuthPlugin = async () => ({
|
|
39
|
+
config: async (cfg) => {
|
|
40
|
+
cfg.provider ??= {}
|
|
41
|
+
cfg.provider[PROVIDER] ??= {
|
|
42
|
+
name: NAME,
|
|
43
|
+
npm: NPM,
|
|
44
|
+
api: API,
|
|
45
|
+
models: FALLBACK_MODELS,
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
auth: {
|
|
50
|
+
provider: PROVIDER,
|
|
51
|
+
refreshLead: REFRESH_LEAD,
|
|
52
|
+
|
|
53
|
+
methods: [
|
|
54
|
+
{
|
|
55
|
+
type: "oauth",
|
|
56
|
+
label: "浏览器登录(有道账号)",
|
|
57
|
+
async authorize() {
|
|
58
|
+
// 由 magpie 打开这个 url;插件不再自己开一次,否则会开两个标签页
|
|
59
|
+
const pending = await beginSignIn()
|
|
60
|
+
return {
|
|
61
|
+
url: pending.url,
|
|
62
|
+
instructions: "在浏览器里用有道账号登录,完成后回到这里。",
|
|
63
|
+
method: "auto",
|
|
64
|
+
callback: () => pending.finish(),
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
// 必须是 oauth:api 类型会让 magpie 强制从 stdin 读一个 key,
|
|
70
|
+
// 而这条路根本不需要用户输入任何东西(见设计文档 §4.3)。
|
|
71
|
+
type: "oauth",
|
|
72
|
+
label: "从本机 LobsterAI 导入登录",
|
|
73
|
+
async authorize() {
|
|
74
|
+
// url 留空:magpie 不打开任何页面,直接走 callback
|
|
75
|
+
return {
|
|
76
|
+
url: "",
|
|
77
|
+
instructions: "直接使用本机 LobsterAI 桌面端已登录的账号。",
|
|
78
|
+
method: "auto",
|
|
79
|
+
async callback() {
|
|
80
|
+
try {
|
|
81
|
+
return desktopSignIn()
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { type: "failed", error: e.message }
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
|
|
91
|
+
// 只对路径 A(浏览器登录,有 refresh 和 expires)生效。
|
|
92
|
+
// 路径 B(桌面端导入)的 refresh 是空串、expires 为 0,
|
|
93
|
+
// magpie 不会调用这里,桌面端的会话因此不会被搅动。
|
|
94
|
+
async refresh(auth) {
|
|
95
|
+
if (auth?.type !== "oauth" || auth.source === "desktop" || !auth.refresh) return {}
|
|
96
|
+
const res = await fetch(`${API}/auth/refresh`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "content-type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ refreshToken: auth.refresh }),
|
|
100
|
+
signal: AbortSignal.timeout(30_000),
|
|
101
|
+
})
|
|
102
|
+
const text = await res.text()
|
|
103
|
+
let v
|
|
104
|
+
try { v = JSON.parse(text) } catch { throw new Error("LobsterAI 续期:响应无法解析") }
|
|
105
|
+
if (v?.code !== 0 || !v?.data?.accessToken) {
|
|
106
|
+
throw Object.assign(new Error(`LobsterAI 续期失败:${v?.message || `code ${v?.code}`}`), { signIn: "expired" })
|
|
107
|
+
}
|
|
108
|
+
const d = v.data
|
|
109
|
+
return {
|
|
110
|
+
access: String(d.accessToken),
|
|
111
|
+
refresh: String(d.refreshToken ?? auth.refresh),
|
|
112
|
+
// 上游可能不给新 token 的 exp;读不到就沿用旧的到期时间
|
|
113
|
+
expires: jwtExp(d.accessToken) || auth.expires || 0,
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async loader(getAuth) {
|
|
118
|
+
const auth = await getAuth()
|
|
119
|
+
// 这里只是给 magpie 一个「看起来已登录」的信号;真正的 token
|
|
120
|
+
// 由下面的 fetch 每次请求现取(桌面端账号尤其需要)。
|
|
121
|
+
let token = ""
|
|
122
|
+
try { token = await tokenOf(auth) } catch { return {} }
|
|
123
|
+
if (!token) return {}
|
|
124
|
+
return {
|
|
125
|
+
baseURL: CHAT_BASE,
|
|
126
|
+
apiKey: token,
|
|
127
|
+
headers: {},
|
|
128
|
+
fetch: makeFetch({
|
|
129
|
+
getAuth,
|
|
130
|
+
tokenOf,
|
|
131
|
+
profileOf: (id) => profiles.get(String(id)),
|
|
132
|
+
call: (input, init) => fetch(input, init),
|
|
133
|
+
}),
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async usage(getAuth) {
|
|
138
|
+
const auth = await getAuth()
|
|
139
|
+
let token = ""
|
|
140
|
+
try { token = await tokenOf(auth) } catch (e) {
|
|
141
|
+
return { error: e.message, windows: [], signIn: e.signIn === "expired" ? "expired" : "kept" }
|
|
142
|
+
}
|
|
143
|
+
if (!token) return { error: "尚未登录", windows: [] }
|
|
144
|
+
try {
|
|
145
|
+
const [quota, profile] = await Promise.all([
|
|
146
|
+
apiGet("/user/quota", token),
|
|
147
|
+
apiGet("/user/profile-summary", token),
|
|
148
|
+
])
|
|
149
|
+
// 插件自己不续期(交给 magpie 的 auth.refresh),所以这次请求用的
|
|
150
|
+
// 就是存下来的 token —— 如实报 kept。社区插件报 renewed 是因为它们
|
|
151
|
+
// 自己维护续期表,这里没有那张表,不该照抄。
|
|
152
|
+
return usageFrom(quota, profile, "kept")
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return {
|
|
155
|
+
error: e.message,
|
|
156
|
+
windows: [],
|
|
157
|
+
signIn: e.signIn === "expired" ? "expired" : "kept",
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
provider: {
|
|
164
|
+
id: PROVIDER,
|
|
165
|
+
async models(provider, { auth } = {}) {
|
|
166
|
+
let token = ""
|
|
167
|
+
try { token = await tokenOf(auth) } catch (e) {
|
|
168
|
+
if (e.signIn === "expired") throw e
|
|
169
|
+
return provider.models
|
|
170
|
+
}
|
|
171
|
+
if (!token) return provider.models
|
|
172
|
+
try {
|
|
173
|
+
const list = await apiGet(
|
|
174
|
+
"/models/available?firstKeyfrom=official&latestKeyfrom=official",
|
|
175
|
+
token,
|
|
176
|
+
)
|
|
177
|
+
if (!Array.isArray(list) || list.length === 0) return provider.models
|
|
178
|
+
for (const [id, p] of Object.entries(profileOfFrom(list))) profiles.set(id, p)
|
|
179
|
+
const out = modelsFromCatalog(list, { ...provider, id: PROVIDER })
|
|
180
|
+
return Object.keys(out).length > 0 ? out : provider.models
|
|
181
|
+
} catch (e) {
|
|
182
|
+
// 登录失效要标记账号;其余情况保留原列表
|
|
183
|
+
if (e.signIn === "expired") throw e
|
|
184
|
+
return provider.models
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// 唯一允许的函数导出:magpie 会把每个导出的函数都当成一个插件加载
|
|
191
|
+
export const _internal = { PROVIDER, FALLBACK_MODELS, usageFrom, modelsFromCatalog }
|
package/lib/assemble.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// 上游给的是 OpenAI 形状的 chat.completion.chunk,只是永远走 SSE。
|
|
2
|
+
// 流式请求原样转发;非流式请求要把分片拼成一个完整的 chat.completion。
|
|
3
|
+
|
|
4
|
+
import { randomBytes } from "node:crypto"
|
|
5
|
+
|
|
6
|
+
const enc = new TextEncoder()
|
|
7
|
+
|
|
8
|
+
function newId() {
|
|
9
|
+
return "chatcmpl-" + randomBytes(12).toString("hex")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function mergeChunks(records, model) {
|
|
13
|
+
const out = {
|
|
14
|
+
id: newId(),
|
|
15
|
+
object: "chat.completion",
|
|
16
|
+
created: Math.floor(Date.now() / 1000),
|
|
17
|
+
model,
|
|
18
|
+
choices: [{ index: 0, message: { role: "assistant", content: "" }, finish_reason: "stop" }],
|
|
19
|
+
usage: null,
|
|
20
|
+
}
|
|
21
|
+
const calls = new Map()
|
|
22
|
+
let content = ""
|
|
23
|
+
let reasoning = ""
|
|
24
|
+
|
|
25
|
+
for await (const rec of records) {
|
|
26
|
+
if (rec.data === "[DONE]") break
|
|
27
|
+
let v
|
|
28
|
+
try { v = JSON.parse(rec.data) } catch { continue }
|
|
29
|
+
if (v?.usage) out.usage = v.usage
|
|
30
|
+
const ch = v?.choices?.[0]
|
|
31
|
+
if (!ch) continue
|
|
32
|
+
const d = ch.delta ?? {}
|
|
33
|
+
if (typeof d.content === "string") content += d.content
|
|
34
|
+
if (typeof d.reasoning_content === "string") reasoning += d.reasoning_content
|
|
35
|
+
for (const tc of d.tool_calls ?? []) {
|
|
36
|
+
const i = tc.index ?? 0
|
|
37
|
+
let c = calls.get(i)
|
|
38
|
+
if (!c) { c = { id: "", type: "function", function: { name: "", arguments: "" } }; calls.set(i, c) }
|
|
39
|
+
if (tc.id) c.id = tc.id
|
|
40
|
+
if (tc.type) c.type = tc.type
|
|
41
|
+
if (tc.function?.name) c.function.name = tc.function.name
|
|
42
|
+
if (typeof tc.function?.arguments === "string") c.function.arguments += tc.function.arguments
|
|
43
|
+
}
|
|
44
|
+
if (ch.finish_reason) out.choices[0].finish_reason = ch.finish_reason
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const msg = out.choices[0].message
|
|
48
|
+
msg.content = content
|
|
49
|
+
if (reasoning) msg.reasoning_content = reasoning
|
|
50
|
+
if (calls.size > 0) {
|
|
51
|
+
msg.tool_calls = [...calls.entries()].sort((a, b) => a[0] - b[0]).map(([, c]) => c)
|
|
52
|
+
// OpenAI 的约定:只有工具调用时 content 为 null
|
|
53
|
+
if (!content) msg.content = null
|
|
54
|
+
}
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function streamOut(records) {
|
|
59
|
+
const body = new ReadableStream({
|
|
60
|
+
async start(controller) {
|
|
61
|
+
try {
|
|
62
|
+
for await (const rec of records) {
|
|
63
|
+
if (rec.data === "[DONE]") break
|
|
64
|
+
controller.enqueue(enc.encode(`data: ${rec.data}\n\n`))
|
|
65
|
+
}
|
|
66
|
+
controller.enqueue(enc.encode("data: [DONE]\n\n"))
|
|
67
|
+
controller.close()
|
|
68
|
+
} catch (e) {
|
|
69
|
+
controller.error(e)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
cancel() { records.return?.() },
|
|
73
|
+
})
|
|
74
|
+
return new Response(body, {
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function respond(req, records) {
|
|
81
|
+
if (req.stream) return streamOut(records)
|
|
82
|
+
const body = await mergeChunks(records, req.model)
|
|
83
|
+
return new Response(JSON.stringify(body), {
|
|
84
|
+
status: 200,
|
|
85
|
+
headers: { "content-type": "application/json" },
|
|
86
|
+
})
|
|
87
|
+
}
|