opencode-lobster-auth 0.1.1 → 0.1.2

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/index.mjs CHANGED
@@ -1,191 +1,210 @@
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 }
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, beginSignInAnnounced } 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
+ // 浏览器在另一台机器上(magpie 跑在 NAS / Docker 里,用这台电脑的
70
+ // 浏览器打开它的页面):回调落在浏览器的 127.0.0.1,到不了 magpie。
71
+ // 登录地址在普通 query 里写明了本机回调端口,magpie 因此会为这次
72
+ // 登录显示「回调 URL」输入框;把登录后地址栏里的完整地址粘进去,
73
+ // magpie 在本机把请求送到插件正在监听的端口,回调照常抵达,
74
+ // 剩下的与「浏览器登录」完全一样。
75
+ type: "oauth",
76
+ label: "远程浏览器登录",
77
+ async authorize() {
78
+ const pending = await beginSignInAnnounced()
79
+ return {
80
+ url: pending.url,
81
+ instructions: "在浏览器里用有道账号登录。登录后浏览器会跳到 127.0.0.1 并提示无法访问,这是预期现象:复制此时地址栏的完整地址,粘贴到下面的「回调 URL」,再点「完成登录」。请在 5 分钟内完成;回调地址包含一次性授权信息,不要发给别人。",
82
+ method: "auto",
83
+ callback: () => pending.finish(),
84
+ }
85
+ },
86
+ },
87
+ {
88
+ // 必须是 oauth:api 类型会让 magpie 强制从 stdin 读一个 key,
89
+ // 而这条路根本不需要用户输入任何东西(见设计文档 §4.3)。
90
+ type: "oauth",
91
+ label: "从本机 LobsterAI 导入登录",
92
+ async authorize() {
93
+ // url 留空:magpie 不打开任何页面,直接走 callback
94
+ return {
95
+ url: "",
96
+ instructions: "直接使用本机 LobsterAI 桌面端已登录的账号。",
97
+ method: "auto",
98
+ async callback() {
99
+ try {
100
+ return desktopSignIn()
101
+ } catch (e) {
102
+ return { type: "failed", error: e.message }
103
+ }
104
+ },
105
+ }
106
+ },
107
+ },
108
+ ],
109
+
110
+ // 只对路径 A(浏览器登录,有 refresh 和 expires)生效。
111
+ // 路径 B(桌面端导入)的 refresh 是空串、expires 为 0,
112
+ // magpie 不会调用这里,桌面端的会话因此不会被搅动。
113
+ async refresh(auth) {
114
+ if (auth?.type !== "oauth" || auth.source === "desktop" || !auth.refresh) return {}
115
+ const res = await fetch(`${API}/auth/refresh`, {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: JSON.stringify({ refreshToken: auth.refresh }),
119
+ signal: AbortSignal.timeout(30_000),
120
+ })
121
+ const text = await res.text()
122
+ let v
123
+ try { v = JSON.parse(text) } catch { throw new Error("LobsterAI 续期:响应无法解析") }
124
+ if (v?.code !== 0 || !v?.data?.accessToken) {
125
+ throw Object.assign(new Error(`LobsterAI 续期失败:${v?.message || `code ${v?.code}`}`), { signIn: "expired" })
126
+ }
127
+ const d = v.data
128
+ return {
129
+ access: String(d.accessToken),
130
+ refresh: String(d.refreshToken ?? auth.refresh),
131
+ // 上游可能不给新 token 的 exp;读不到就沿用旧的到期时间
132
+ expires: jwtExp(d.accessToken) || auth.expires || 0,
133
+ }
134
+ },
135
+
136
+ async loader(getAuth) {
137
+ const auth = await getAuth()
138
+ // 这里只是给 magpie 一个「看起来已登录」的信号;真正的 token
139
+ // 由下面的 fetch 每次请求现取(桌面端账号尤其需要)。
140
+ let token = ""
141
+ try { token = await tokenOf(auth) } catch { return {} }
142
+ if (!token) return {}
143
+ return {
144
+ baseURL: CHAT_BASE,
145
+ apiKey: token,
146
+ headers: {},
147
+ fetch: makeFetch({
148
+ getAuth,
149
+ tokenOf,
150
+ profileOf: (id) => profiles.get(String(id)),
151
+ call: (input, init) => fetch(input, init),
152
+ }),
153
+ }
154
+ },
155
+
156
+ async usage(getAuth) {
157
+ const auth = await getAuth()
158
+ let token = ""
159
+ try { token = await tokenOf(auth) } catch (e) {
160
+ return { error: e.message, windows: [], signIn: e.signIn === "expired" ? "expired" : "kept" }
161
+ }
162
+ if (!token) return { error: "尚未登录", windows: [] }
163
+ try {
164
+ const [quota, profile] = await Promise.all([
165
+ apiGet("/user/quota", token),
166
+ apiGet("/user/profile-summary", token),
167
+ ])
168
+ // 插件自己不续期(交给 magpie 的 auth.refresh),所以这次请求用的
169
+ // 就是存下来的 token —— 如实报 kept。社区插件报 renewed 是因为它们
170
+ // 自己维护续期表,这里没有那张表,不该照抄。
171
+ return usageFrom(quota, profile, "kept")
172
+ } catch (e) {
173
+ return {
174
+ error: e.message,
175
+ windows: [],
176
+ signIn: e.signIn === "expired" ? "expired" : "kept",
177
+ }
178
+ }
179
+ },
180
+ },
181
+
182
+ provider: {
183
+ id: PROVIDER,
184
+ async models(provider, { auth } = {}) {
185
+ let token = ""
186
+ try { token = await tokenOf(auth) } catch (e) {
187
+ if (e.signIn === "expired") throw e
188
+ return provider.models
189
+ }
190
+ if (!token) return provider.models
191
+ try {
192
+ const list = await apiGet(
193
+ "/models/available?firstKeyfrom=official&latestKeyfrom=official",
194
+ token,
195
+ )
196
+ if (!Array.isArray(list) || list.length === 0) return provider.models
197
+ for (const [id, p] of Object.entries(profileOfFrom(list))) profiles.set(id, p)
198
+ const out = modelsFromCatalog(list, { ...provider, id: PROVIDER })
199
+ return Object.keys(out).length > 0 ? out : provider.models
200
+ } catch (e) {
201
+ // 登录失效要标记账号;其余情况保留原列表
202
+ if (e.signIn === "expired") throw e
203
+ return provider.models
204
+ }
205
+ },
206
+ },
207
+ })
208
+
209
+ // 唯一允许的函数导出:magpie 会把每个导出的函数都当成一个插件加载
210
+ export const _internal = { PROVIDER, FALLBACK_MODELS, usageFrom, modelsFromCatalog }