opencode-kunrouter-auth 0.0.0-stage → 0.8.3
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/CHANGELOG.md +92 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/index.mjs +1064 -0
- package/package.json +24 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# 更新日志
|
|
2
|
+
|
|
3
|
+
本文件记录主要版本变化,格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
4
|
+
|
|
5
|
+
## [0.8.3] - 2026-11-12
|
|
6
|
+
|
|
7
|
+
### 修复
|
|
8
|
+
- 订阅总额和日/周/月滚动窗口恢复为进度条展示;剩余额度仍写在说明文字里。
|
|
9
|
+
|
|
10
|
+
## [0.8.2] - 2026-11-12
|
|
11
|
+
|
|
12
|
+
### 修复
|
|
13
|
+
- 签到奖励按站点的额度比例换算,与钱包余额使用相同单位。
|
|
14
|
+
|
|
15
|
+
## [0.8.1] - 2026-11-11
|
|
16
|
+
|
|
17
|
+
### 变更
|
|
18
|
+
- 额度卡精简为套餐、日/周/月滚动额度、钱包三类。
|
|
19
|
+
- 订阅行改为「剩余 / 总额」,滚动窗口改为「剩余 / 上限」,到期时间只显示日期。
|
|
20
|
+
- 分组和非默认计费优先级并入顶部套餐名,不再单独占一行。
|
|
21
|
+
|
|
22
|
+
### 移除
|
|
23
|
+
- 去掉「账号」「说明」和旧版「本期配额」行,不再从对话响应解析 `usage.quota`。
|
|
24
|
+
|
|
25
|
+
## [0.8.0] - 2026-11-11
|
|
26
|
+
|
|
27
|
+
### 新增
|
|
28
|
+
- 额度卡改为读取 `/api/subscription/self` 和 `/api/subscription/plans`,显示订阅套餐名称、总额、已用、剩余和到期时间。
|
|
29
|
+
- 订阅内的日/周/月滚动窗口分别显示额度、已用、剩余、窗口时长和下一次释放时间。
|
|
30
|
+
- 显示账号当前的计费优先级(优先订阅 / 优先钱包 / 仅订阅 / 仅钱包)。
|
|
31
|
+
- 订阅额度允许钱包兜底时按 `aside` 展示,不影响 Magpie 继续路由。
|
|
32
|
+
|
|
33
|
+
### 变更
|
|
34
|
+
- `period_window` 选项改为控制订阅额度展示;关掉后仍保留钱包余额。
|
|
35
|
+
- 服务端需要允许 Magpie 读取 `GET /api/subscription/self` 与 `GET /api/subscription/plans`。
|
|
36
|
+
|
|
37
|
+
## [0.7.0] - 2026-11-07
|
|
38
|
+
|
|
39
|
+
### 变更
|
|
40
|
+
- 重新登录改为覆盖原来的账号,不再生成 `kunrouter#xxxxxx` 的第二条记录,换 Key 不用先登出。
|
|
41
|
+
- 手动登录入口更名为「手动填写 / 更换访问令牌 + API 密钥」,并且只问这两项,不再额外要求重复粘贴一次 `sk-`。
|
|
42
|
+
|
|
43
|
+
## [0.6.0] - 2026-11-07
|
|
44
|
+
|
|
45
|
+
集中修复此前记录的 9 个 issue(#4–#12)。
|
|
46
|
+
|
|
47
|
+
### 修复
|
|
48
|
+
- 流式(SSE)响应现在也会解析 `usage.quota`,本期配额不再只对非流式请求生效。
|
|
49
|
+
- 只填访问令牌、没有 `sk-` 密钥时,直接返回可读的 401 提示,不再把访问令牌发到 `/v1`。
|
|
50
|
+
- 邀请码被禁用 / 过期 / 名额满时,自动隐藏注册入口,不再让用户点进去才看到报错。
|
|
51
|
+
- 钱包窗口单位跟随站点币种(`custom_currency_symbol`),不再写死 USD。
|
|
52
|
+
- `/api/status`、`/api/user/self`、`/api/user/checkin`、`/v1/models`、`/api/desktop/keys` 统一加 10 秒超时。
|
|
53
|
+
- 直连域名网络层失败时回退一次主站 `/v1`,避免整站明明可用却全部超时。
|
|
54
|
+
- 额度卡的分组改为读取 `/api/desktop/keys` 的实时分组,读不到才退回授权时快照并标注。
|
|
55
|
+
|
|
56
|
+
### 安全
|
|
57
|
+
- 浏览器授权地址校验收紧到 `/desktop/authorize`,不再放行信任域上的任意路径。
|
|
58
|
+
|
|
59
|
+
### 变更
|
|
60
|
+
- 邀请码支持 `KUNROUTER_INVITE_CODE` 覆盖,不用改代码发版。
|
|
61
|
+
- 新增 `registration_entry` 开关,可一键隐藏注册入口。
|
|
62
|
+
- 注册入口文案补充「仅新用户」和剩余名额。
|
|
63
|
+
|
|
64
|
+
## [0.5.0] - 2026-11-07
|
|
65
|
+
|
|
66
|
+
### 新增
|
|
67
|
+
- 用户在本站控制台删除 Key 后,插件用访问令牌自动重签一把同名 Key 并写回 Magpie,无需重新授权。
|
|
68
|
+
|
|
69
|
+
## [0.4.1] - 2026-11-07
|
|
70
|
+
|
|
71
|
+
### 变更
|
|
72
|
+
- 邀请入口文案统一为「鸡蛋」。
|
|
73
|
+
|
|
74
|
+
## [0.4.0] - 2026-11-07
|
|
75
|
+
|
|
76
|
+
### 新增
|
|
77
|
+
- Magpie 专属邀请码注册入口,注册页自动填入邀请码。
|
|
78
|
+
- 授权回调信任域名白名单(`wcnmb.si` / `wcnmb.fun` / `kuncode.120403.xyz` 及子域)。
|
|
79
|
+
|
|
80
|
+
## [0.3.0] - 2026-11-07
|
|
81
|
+
|
|
82
|
+
### 新增
|
|
83
|
+
- 浏览器一键授权:PKCE + 随机 `state` + 本机 loopback 回调,一次性授权码换取访问令牌和 `sk-` 密钥。
|
|
84
|
+
- 授权时可选模型分组,重新授权会轮换凭据。
|
|
85
|
+
|
|
86
|
+
## [0.1.0] - 2026-11-06
|
|
87
|
+
|
|
88
|
+
### 新增
|
|
89
|
+
- 首个版本:KunRouter 供应商插件,支持 magpie / OpenCode。
|
|
90
|
+
- 钱包余额窗口(`/api/user/self`)与本期配额窗口(对话响应)。
|
|
91
|
+
- 双凭据处理:访问令牌读 `/api`,`sk-` 密钥请求 `/v1`。
|
|
92
|
+
- 供应商选项框:`direct_api`(直连域名)、`period_window`(本期配额栏)。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KunRouter
|
|
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,86 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-kunrouter-auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[KunRouter](https://wcnmb.si) 的 **Magpie 插件**(同时兼容 OpenCode):浏览器一键授权、钱包与订阅额度、每日签到。
|
|
4
|
+
|
|
5
|
+
> 维护者:[MY-Final](https://github.com/MY-Final) · 插件名:KunRouter · 适用:Magpie / OpenCode
|
|
6
|
+
|
|
7
|
+
## 装
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
git clone https://github.com/MY-Final/opencode-kunrouter-auth.git
|
|
11
|
+
magpie plugin add ./opencode-kunrouter-auth
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
或直接 `magpie plugin add https://github.com/MY-Final/opencode-kunrouter-auth`。
|
|
15
|
+
|
|
16
|
+
## 登录
|
|
17
|
+
|
|
18
|
+
- **浏览器授权(推荐)**:打开 KunRouter 授权页,登录、选分组、确认授权,浏览器自动跳回本机一次性回调。插件拿不到账号密码,用 PKCE + 随机 `state` + loopback 回调,令牌不进跳转 URL。
|
|
19
|
+
- **没有账号**:登录页的「注册并领取鸡蛋」入口会打开注册页并自动填入活动邀请码。邀请码不可用(禁用 / 过期 / 名额满)时入口自动隐藏;换活动用环境变量 `KUNROUTER_INVITE_CODE` 覆盖,不用改代码发版。
|
|
20
|
+
- **手动填写 / 更换访问令牌 + API 密钥**:授权服务不可用时的兜底,也是换 Key 的入口。访问令牌读余额和签到,`sk-` 密钥请求模型;只填访问令牌也能登录,但**不能调用模型**(会直接提示补一把 `sk-`)。
|
|
21
|
+
|
|
22
|
+
访问令牌:后台「个人设置 → 安全设置」;API 密钥:后台「令牌」新建,`sk-` 开头。
|
|
23
|
+
|
|
24
|
+
### 控制台删了 Key?
|
|
25
|
+
|
|
26
|
+
下次请求 401 时插件会用访问令牌自动重签一把同名 Key 并写回 Magpie,不需要重新授权;访问令牌也失效才需要重登。
|
|
27
|
+
|
|
28
|
+
### 想换一把 Key?
|
|
29
|
+
|
|
30
|
+
插件页打开 KunRouter,点「重新登录」,选「手动填写 / 更换访问令牌 + API 密钥」,把两把钥匙粘贴进去即可。重新登录会覆盖原来那条记录,不会多出 `kunrouter#xxxxxx` 账号,也不用先登出。
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
magpie plugin login kunrouter
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
只想换 `sk-`、不想再填访问令牌:在控制台删掉旧 Key,下次请求 401 时插件会自动重签一把同名 Key 并写回 Magpie。
|
|
37
|
+
|
|
38
|
+
## 选项
|
|
39
|
+
|
|
40
|
+
| 选项 | 默认 | 干什么 |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `direct_api` | 开 | 模型请求走 `direct.wcnmb.fun`,绕开 Cloudflare 的 100 秒上限;关掉走 `wcnmb.si/v1` |
|
|
43
|
+
| `period_window` | 开 | 显示订阅剩余额度和滚动窗口;关掉只看钱包 |
|
|
44
|
+
| `registration_entry` | 开 | 登录页显示注册入口;邀请码不可用时自动隐藏,关掉完全不提注册 |
|
|
45
|
+
|
|
46
|
+
改完选项要重载插件,`config` 里的供应商地址才会跟着变。
|
|
47
|
+
|
|
48
|
+
## 显示什么
|
|
49
|
+
|
|
50
|
+
- **钱包**:`/api/user/self` 的 `quota`,按 `/api/status` 的 `quota_per_unit` 换算,单位跟随站点币种(本站 🥚)。
|
|
51
|
+
- **订阅额度**:`/api/subscription/self` 里的套餐总额、已用、剩余和到期时间;Lite 等套餐的日/周/月滚动窗口也会分别显示为进度条。
|
|
52
|
+
- **计费优先级**:非默认优先级会跟在顶部套餐名后,不再单独占一行。
|
|
53
|
+
- **分组**:读 `/api/desktop/keys` 按 Key 遮罩匹配真实分组,读不到才退回授权时快照。
|
|
54
|
+
- **签到**:站点开着 `checkin_enabled` 时每日自动签到。
|
|
55
|
+
- **模型**:从 `/v1/models` 拉,拉不到退回内置占位模型。
|
|
56
|
+
|
|
57
|
+
站点相关 `/api` 请求都有 10 秒超时,网络卡住不会把额度卡和签到一起拖死。
|
|
58
|
+
|
|
59
|
+
## 域名
|
|
60
|
+
|
|
61
|
+
- `https://wcnmb.si/api` —— 控制台和管理接口
|
|
62
|
+
- `https://kuncode.120403.xyz/desktop/authorize` —— 浏览器授权页
|
|
63
|
+
- `https://direct.wcnmb.fun/v1` —— 直连模型请求,绕开 Cloudflare 100 秒上限
|
|
64
|
+
|
|
65
|
+
直连域名网络层挂了会自动回退一次主站 `wcnmb.si/v1`。授权地址只认 `wcnmb.si`、`wcnmb.fun`、`kuncode.120403.xyz` 及其子域下的 `/desktop/authorize`。
|
|
66
|
+
|
|
67
|
+
## 本地联调
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
KUNROUTER_SITE=http://localhost:3000 \
|
|
71
|
+
KUNROUTER_API=http://localhost:3000 \
|
|
72
|
+
magpie plugin login kunrouter
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
后端 `ServerAddress` 要设成同一个 `http://localhost:3000`,否则授权回调会回正式域名;`magpie quota kunrouter` 也要带同样的环境变量。
|
|
76
|
+
|
|
77
|
+
## 已知边界
|
|
78
|
+
|
|
79
|
+
- 只有单一 provider id `kunrouter`,一台机器保留一个账号;重新登录会覆盖它。
|
|
80
|
+
- 浏览器授权签发的凭据默认 30 天有效,重新授权会轮换两把凭据并按新分组重签。
|
|
81
|
+
- 令牌失效时 `usage` 带 `signIn: "expired"`,magpie 会标出来让你重登。
|
|
82
|
+
- 模型能力参数拿不到真实值,统一按 128k 上下文 / 8k 输出 / 支持工具调用填。
|
|
83
|
+
|
|
84
|
+
## 更新日志
|
|
85
|
+
|
|
86
|
+
见 [CHANGELOG.md](./CHANGELOG.md)。
|
package/index.mjs
ADDED
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
// KunRouter (wcnmb.si) provider plugin for OpenCode / magpie.
|
|
2
|
+
//
|
|
3
|
+
// KunRouter 跑的是 new-api(one-api 的分支)。账号密码登录走浏览器授权,
|
|
4
|
+
// 访问令牌留在 metadata,模型请求使用站点为该插件单独签发的 sk- 密钥。
|
|
5
|
+
// 余额和签到走站点侧的 /api,模型请求走直连域名,绕开 Cloudflare 的 100 秒上限。
|
|
6
|
+
|
|
7
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
|
8
|
+
import { createServer } from "node:http"
|
|
9
|
+
import { homedir, hostname } from "node:os"
|
|
10
|
+
|
|
11
|
+
const PROVIDER = "kunrouter"
|
|
12
|
+
const SITE =
|
|
13
|
+
process.env.KUNROUTER_SITE?.replace(/\/+$/, "") || "https://wcnmb.si" // 控制台 + /api
|
|
14
|
+
const API =
|
|
15
|
+
process.env.KUNROUTER_API?.replace(/\/+$/, "") ||
|
|
16
|
+
"https://direct.wcnmb.fun" // 直连 /v1,不受 CF 超时限制
|
|
17
|
+
const VERSION = "0.8.3"
|
|
18
|
+
const MAGPIE_CLIENT_ID = "magpie"
|
|
19
|
+
// 活动邀请码。改活动时优先用环境变量覆盖,不用改代码发版。
|
|
20
|
+
const INVITE_CODE = (
|
|
21
|
+
process.env.KUNROUTER_INVITE_CODE ?? "shjglbwadfbhblqvwcpqoqbo"
|
|
22
|
+
).trim()
|
|
23
|
+
const AUTHORIZATION_ORIGIN =
|
|
24
|
+
process.env.KUNROUTER_AUTH_ORIGIN?.replace(/\/+$/, "") ||
|
|
25
|
+
"https://kuncode.120403.xyz"
|
|
26
|
+
const TRUSTED_AUTHORIZATION_DOMAINS = [
|
|
27
|
+
"wcnmb.si",
|
|
28
|
+
"wcnmb.fun",
|
|
29
|
+
"kuncode.120403.xyz",
|
|
30
|
+
new URL(SITE).hostname,
|
|
31
|
+
new URL(API).hostname,
|
|
32
|
+
new URL(AUTHORIZATION_ORIGIN).hostname,
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
// new-api 的默认换算:500000 quota = 1 美元,站点 /api/status 会覆盖。
|
|
36
|
+
const DEFAULT_QUOTA_PER_UNIT = 500000
|
|
37
|
+
const STATUS_TTL_MS = 10 * 60 * 1000
|
|
38
|
+
// 站点 /api 请求统一超时,免得网络卡住时额度卡和签到一起挂死。
|
|
39
|
+
const REQUEST_TIMEOUT_MS = 10000
|
|
40
|
+
const INVITE_STATUS_TTL_MS = 5 * 60 * 1000
|
|
41
|
+
// 插件加载时要等这次邀请码检查,超时放短一点,别因为站点慢拖住整个插件。
|
|
42
|
+
const INVITE_CHECK_TIMEOUT_MS = 2500
|
|
43
|
+
|
|
44
|
+
// 拉不到模型列表时的兜底,名字稳定,不至于让供应商空着。
|
|
45
|
+
const DEFAULT_MODELS = {
|
|
46
|
+
"deepseek-v4.1-flash": "DeepSeek V4.1 Flash",
|
|
47
|
+
"glm-5.3-flash": "GLM-5.3 Flash",
|
|
48
|
+
"minimax-m3.1-flash": "MiniMax M3.1 Flash",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const baseModel = {
|
|
52
|
+
limit: { context: 128000, output: 8192 },
|
|
53
|
+
tool_call: true,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 站点级配置(币种符号、quota 换算)不属于任何账号,缓存这里是安全的。
|
|
57
|
+
let statusCache = { at: 0, value: null }
|
|
58
|
+
// 邀请码是否还能用,也做短缓存,避免每次加载插件都打一次站点的脸。
|
|
59
|
+
let inviteCache = { code: "", at: 0, value: null }
|
|
60
|
+
|
|
61
|
+
async function siteStatus() {
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
if (statusCache.value && now - statusCache.at < STATUS_TTL_MS) return statusCache.value
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`${SITE}/api/status`, {
|
|
66
|
+
headers: clientHeaders(),
|
|
67
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
68
|
+
})
|
|
69
|
+
if (!res.ok) return statusCache.value ?? {}
|
|
70
|
+
const data = (await res.json())?.data ?? {}
|
|
71
|
+
const value = {
|
|
72
|
+
quotaPerUnit: Number(data.quota_per_unit) || DEFAULT_QUOTA_PER_UNIT,
|
|
73
|
+
symbol: typeof data.custom_currency_symbol === "string" && data.custom_currency_symbol
|
|
74
|
+
? data.custom_currency_symbol
|
|
75
|
+
: "$",
|
|
76
|
+
name: data.system_name || "KunRouter",
|
|
77
|
+
}
|
|
78
|
+
statusCache = { at: now, value }
|
|
79
|
+
return value
|
|
80
|
+
} catch {
|
|
81
|
+
return statusCache.value ?? {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clientHeaders() {
|
|
86
|
+
return { "x-kunrouter-client": `opencode-kunrouter-auth/${VERSION}` }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 站点公开接口 GET /api/invite_codes/public/:code 会告诉我们这个码还能不能用。
|
|
90
|
+
// 用不了(禁用 / 过期 / 名额满)就别再把注册入口摆出来,省得用户点进去才看到报错。
|
|
91
|
+
// 网络抖动时按「未知」处理、仍然展示入口,宁可多给一次机会也别把能用的码藏了。
|
|
92
|
+
async function inviteCodeState(code) {
|
|
93
|
+
if (!code) return { available: false, unknown: false }
|
|
94
|
+
const now = Date.now()
|
|
95
|
+
if (inviteCache.code === code && now - inviteCache.at < INVITE_STATUS_TTL_MS) {
|
|
96
|
+
return inviteCache.value
|
|
97
|
+
}
|
|
98
|
+
let value
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(
|
|
101
|
+
`${SITE}/api/invite_codes/public/${encodeURIComponent(code)}`,
|
|
102
|
+
{ headers: clientHeaders(), signal: AbortSignal.timeout(INVITE_CHECK_TIMEOUT_MS) },
|
|
103
|
+
)
|
|
104
|
+
if (res.status === 404) {
|
|
105
|
+
value = { available: false, unknown: false, reason: "invalid" }
|
|
106
|
+
} else if (!res.ok) {
|
|
107
|
+
value = { available: true, unknown: true }
|
|
108
|
+
} else {
|
|
109
|
+
const body = await res.json().catch(() => null)
|
|
110
|
+
const data = body?.data
|
|
111
|
+
if (!data) {
|
|
112
|
+
value = { available: true, unknown: true }
|
|
113
|
+
} else {
|
|
114
|
+
value = {
|
|
115
|
+
available: data.available === true,
|
|
116
|
+
unknown: false,
|
|
117
|
+
reason: typeof data.reason === "string" ? data.reason : "",
|
|
118
|
+
remaining: Number(data.remaining_registrations) || 0,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
value = { available: true, unknown: true }
|
|
124
|
+
}
|
|
125
|
+
inviteCache = { code, at: now, value }
|
|
126
|
+
return value
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 站点存的是不带 sk- 前缀的 Key,遮罩也按去前缀之后算,用来和列表里的 masked_key 对上。
|
|
130
|
+
function maskKey(key) {
|
|
131
|
+
if (!key) return ""
|
|
132
|
+
const raw = key.startsWith("sk-") ? key.slice(3) : key
|
|
133
|
+
if (raw.length <= 4) return "*".repeat(raw.length)
|
|
134
|
+
if (raw.length <= 8) return `${raw.slice(0, 2)}****${raw.slice(-2)}`
|
|
135
|
+
return `${raw.slice(0, 4)}**********${raw.slice(-4)}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 额度卡上的分组别用授权时的快照:用户可能在控制台改过 Key 的分组。
|
|
139
|
+
// 拿访问令牌列表里按遮罩匹配出真实分组,读不到就交给调用方退回快照。
|
|
140
|
+
async function currentGroup(accessToken, apiKey) {
|
|
141
|
+
if (!accessToken) return ""
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetch(`${SITE}/api/desktop/keys?p=1&page_size=100`, {
|
|
144
|
+
headers: authHeaders(accessToken),
|
|
145
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
146
|
+
})
|
|
147
|
+
if (!res.ok) return ""
|
|
148
|
+
const body = await res.json().catch(() => null)
|
|
149
|
+
const items = body?.data?.items
|
|
150
|
+
if (!Array.isArray(items) || items.length === 0) return ""
|
|
151
|
+
const masked = maskKey(apiKey)
|
|
152
|
+
const match =
|
|
153
|
+
(masked && items.find((item) => item?.masked_key === masked)) ||
|
|
154
|
+
(items.length === 1 ? items[0] : null)
|
|
155
|
+
return typeof match?.group === "string" ? match.group : ""
|
|
156
|
+
} catch {
|
|
157
|
+
return ""
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function authHeaders(key) {
|
|
162
|
+
return { authorization: `Bearer ${key}`, ...clientHeaders() }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function tokenOf(auth) {
|
|
166
|
+
const fromMeta =
|
|
167
|
+
typeof auth?.metadata?.accessToken === "string"
|
|
168
|
+
? auth.metadata.accessToken.trim()
|
|
169
|
+
: ""
|
|
170
|
+
if (fromMeta) return fromMeta
|
|
171
|
+
const key = typeof auth?.key === "string" ? auth.key.trim() : ""
|
|
172
|
+
return key && !key.startsWith("sk-") ? key : ""
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// new-api 有两套凭据:访问令牌只能读 /api(余额、签到),sk- 密钥只能调 /v1。
|
|
176
|
+
// 浏览器授权会把访问令牌放 metadata,把 sk- 密钥放主 key;手工登录也兼容。
|
|
177
|
+
function apiKeyOf(auth) {
|
|
178
|
+
const meta = auth?.metadata
|
|
179
|
+
const fromMeta = typeof meta?.apiKey === "string" ? meta.apiKey.trim() : ""
|
|
180
|
+
if (fromMeta) return fromMeta
|
|
181
|
+
const key = typeof auth?.key === "string" ? auth.key.trim() : ""
|
|
182
|
+
return key.startsWith("sk-") ? key : ""
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function authorizationError(error) {
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
187
|
+
return {
|
|
188
|
+
type: "failed",
|
|
189
|
+
error: (message || "KunRouter 浏览器授权失败").slice(0, 480),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function randomURIToken() {
|
|
194
|
+
return randomBytes(32).toString("base64url")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stableInstallationId() {
|
|
198
|
+
const digest = createHash("sha256")
|
|
199
|
+
.update(`kunrouter-magpie-v1\0${hostname()}\0${homedir()}`)
|
|
200
|
+
.digest()
|
|
201
|
+
.subarray(0, 16)
|
|
202
|
+
digest[6] = (digest[6] & 0x0f) | 0x50
|
|
203
|
+
digest[8] = (digest[8] & 0x3f) | 0x80
|
|
204
|
+
const hex = digest.toString("hex")
|
|
205
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function deviceName() {
|
|
209
|
+
return (hostname() || "Magpie").slice(0, 32)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sameSecret(left, right) {
|
|
213
|
+
if (typeof left !== "string" || typeof right !== "string") return false
|
|
214
|
+
const a = Buffer.from(left)
|
|
215
|
+
const b = Buffer.from(right)
|
|
216
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isAllowedAuthorizationURL(value) {
|
|
220
|
+
try {
|
|
221
|
+
const url = new URL(String(value || ""))
|
|
222
|
+
const localHTTP =
|
|
223
|
+
url.protocol === "http:" &&
|
|
224
|
+
(url.hostname === "127.0.0.1" || url.hostname === "localhost")
|
|
225
|
+
const trustedHost = TRUSTED_AUTHORIZATION_DOMAINS.some(
|
|
226
|
+
(domain) => url.hostname === domain || url.hostname.endsWith(`.${domain}`),
|
|
227
|
+
)
|
|
228
|
+
// 授权只应落在授权页,不能是信任域上的任意路径。
|
|
229
|
+
// 注册和登录走同一个 /desktop/authorize,用 query 区分,所以一条路径就够。
|
|
230
|
+
const allowedPath = url.pathname === "/desktop/authorize"
|
|
231
|
+
return (
|
|
232
|
+
(url.protocol === "https:" || localHTTP) &&
|
|
233
|
+
trustedHost &&
|
|
234
|
+
allowedPath
|
|
235
|
+
)
|
|
236
|
+
} catch {
|
|
237
|
+
return false
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function responseJSON(res) {
|
|
242
|
+
const body = await res.json().catch(() => null)
|
|
243
|
+
if (!res.ok || body?.success !== true) {
|
|
244
|
+
const message =
|
|
245
|
+
typeof body?.message === "string" && body.message
|
|
246
|
+
? body.message
|
|
247
|
+
: `KunRouter 返回 ${res.status}`
|
|
248
|
+
throw new Error(message)
|
|
249
|
+
}
|
|
250
|
+
return body.data
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function listenLoopback(server) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const onError = (error) => reject(error)
|
|
256
|
+
server.once("error", onError)
|
|
257
|
+
server.listen(0, "127.0.0.1", () => {
|
|
258
|
+
server.off("error", onError)
|
|
259
|
+
const address = server.address()
|
|
260
|
+
if (!address || typeof address === "string") {
|
|
261
|
+
reject(new Error("无法分配本地回调端口"))
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
resolve(address.port)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function waitForLoopbackCallback(server, expectedState, signal) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
let settled = false
|
|
272
|
+
const finish = (fn, value) => {
|
|
273
|
+
if (settled) return
|
|
274
|
+
settled = true
|
|
275
|
+
signal?.removeEventListener("abort", onAbort)
|
|
276
|
+
fn(value)
|
|
277
|
+
}
|
|
278
|
+
const onAbort = () => finish(reject, new Error("浏览器授权已取消"))
|
|
279
|
+
if (signal?.aborted) {
|
|
280
|
+
reject(new Error("浏览器授权已取消"))
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
284
|
+
server.on("request", (req, res) => {
|
|
285
|
+
if (settled) {
|
|
286
|
+
res.writeHead(409, { "content-type": "text/plain; charset=utf-8" })
|
|
287
|
+
res.end("授权请求已处理")
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
let callback
|
|
291
|
+
let code
|
|
292
|
+
try {
|
|
293
|
+
callback = new URL(req.url || "/", "http://127.0.0.1")
|
|
294
|
+
if (
|
|
295
|
+
callback.protocol !== "http:" ||
|
|
296
|
+
callback.hostname !== "127.0.0.1" ||
|
|
297
|
+
callback.pathname !== "/callback"
|
|
298
|
+
) {
|
|
299
|
+
throw new Error("本地回调路径无效")
|
|
300
|
+
}
|
|
301
|
+
if (!sameSecret(callback.searchParams.get("state"), expectedState)) {
|
|
302
|
+
throw new Error("授权状态校验失败")
|
|
303
|
+
}
|
|
304
|
+
code = callback.searchParams.get("code")
|
|
305
|
+
const denied = callback.searchParams.get("error")
|
|
306
|
+
if (!denied && !code) throw new Error("KunRouter 没有返回授权码")
|
|
307
|
+
settled = true
|
|
308
|
+
signal?.removeEventListener("abort", onAbort)
|
|
309
|
+
if (denied) {
|
|
310
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" })
|
|
311
|
+
res.end("KunRouter 授权未完成,请返回 Magpie。")
|
|
312
|
+
reject(new Error(denied === "access_denied" ? "已取消 KunRouter 授权" : "KunRouter 拒绝了授权"))
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
|
316
|
+
res.end(
|
|
317
|
+
"<!doctype html><meta charset=\"utf-8\"><title>KunRouter</title>" +
|
|
318
|
+
"<body style=\"font-family:system-ui,sans-serif;padding:2rem\">" +
|
|
319
|
+
"<h1>授权完成</h1><p>可以关闭此页面并返回 Magpie。</p></body>",
|
|
320
|
+
)
|
|
321
|
+
resolve(code)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" })
|
|
324
|
+
res.end("KunRouter 授权回调无效。")
|
|
325
|
+
if (settled) reject(error)
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function startBrowserAuthorization(signal, options = {}) {
|
|
332
|
+
const server = createServer()
|
|
333
|
+
try {
|
|
334
|
+
const port = await listenLoopback(server)
|
|
335
|
+
const redirectURI = `http://127.0.0.1:${port}/callback`
|
|
336
|
+
const state = randomURIToken()
|
|
337
|
+
const verifier = randomURIToken()
|
|
338
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url")
|
|
339
|
+
const res = await fetch(`${SITE}/api/desktop/auth/start`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: { "content-type": "application/json", ...clientHeaders() },
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
client_id: MAGPIE_CLIENT_ID,
|
|
344
|
+
installation_id: stableInstallationId(),
|
|
345
|
+
device_name: deviceName(),
|
|
346
|
+
redirect_uri: redirectURI,
|
|
347
|
+
state,
|
|
348
|
+
code_challenge: challenge,
|
|
349
|
+
code_challenge_method: "S256",
|
|
350
|
+
}),
|
|
351
|
+
signal,
|
|
352
|
+
})
|
|
353
|
+
const started = await responseJSON(res)
|
|
354
|
+
if (!isAllowedAuthorizationURL(started?.authorization_url)) {
|
|
355
|
+
throw new Error("KunRouter 返回了无效的授权地址")
|
|
356
|
+
}
|
|
357
|
+
const authorizationURL = new URL(started.authorization_url)
|
|
358
|
+
if (options.registration) {
|
|
359
|
+
authorizationURL.searchParams.set("mode", "register")
|
|
360
|
+
authorizationURL.searchParams.set("invite_code", INVITE_CODE)
|
|
361
|
+
}
|
|
362
|
+
const callbackPromise = waitForLoopbackCallback(server, state, signal)
|
|
363
|
+
const closeOnAbort = () => server.close()
|
|
364
|
+
signal?.addEventListener("abort", closeOnAbort, { once: true })
|
|
365
|
+
callbackPromise.catch(() => {})
|
|
366
|
+
return {
|
|
367
|
+
url: authorizationURL.toString(),
|
|
368
|
+
instructions: options.registration
|
|
369
|
+
? "没有账号时,浏览器会打开注册页并自动填入 Magpie 邀请码;注册并登录后会自动继续授权。"
|
|
370
|
+
: "请在浏览器中登录 KunRouter 并确认授权;完成后 Magpie 会自动继续。",
|
|
371
|
+
method: "auto",
|
|
372
|
+
async callback() {
|
|
373
|
+
try {
|
|
374
|
+
const code = await callbackPromise
|
|
375
|
+
const exchange = await fetch(`${SITE}/api/desktop/auth/exchange`, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
headers: { "content-type": "application/json", ...clientHeaders() },
|
|
378
|
+
body: JSON.stringify({
|
|
379
|
+
code,
|
|
380
|
+
code_verifier: verifier,
|
|
381
|
+
redirect_uri: redirectURI,
|
|
382
|
+
}),
|
|
383
|
+
signal,
|
|
384
|
+
})
|
|
385
|
+
const data = await responseJSON(exchange)
|
|
386
|
+
const accessToken = String(data?.access_token || "").trim()
|
|
387
|
+
const apiKey = String(data?.api_key || "").trim()
|
|
388
|
+
const group = String(data?.group || "").trim()
|
|
389
|
+
if (!accessToken || !apiKey.startsWith("sk-")) {
|
|
390
|
+
throw new Error("KunRouter 返回的凭据不完整")
|
|
391
|
+
}
|
|
392
|
+
const username =
|
|
393
|
+
data?.user?.email ||
|
|
394
|
+
data?.user?.username ||
|
|
395
|
+
data?.user?.display_name ||
|
|
396
|
+
undefined
|
|
397
|
+
return {
|
|
398
|
+
type: "success",
|
|
399
|
+
key: apiKey,
|
|
400
|
+
accountId:
|
|
401
|
+
data?.user?.id != null ? String(data.user.id) : undefined,
|
|
402
|
+
metadata: {
|
|
403
|
+
accessToken,
|
|
404
|
+
apiKey,
|
|
405
|
+
mode: "browser",
|
|
406
|
+
username,
|
|
407
|
+
group: group || undefined,
|
|
408
|
+
expiresAt: Number(data?.expires_at) || undefined,
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return authorizationError(error)
|
|
413
|
+
} finally {
|
|
414
|
+
signal?.removeEventListener("abort", closeOnAbort)
|
|
415
|
+
server.close()
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
} catch (error) {
|
|
420
|
+
server.close()
|
|
421
|
+
return authorizationError(error)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// /v1/models:只有 sk- 密钥能调,访问令牌到这里是 401。
|
|
426
|
+
async function listModels(apiKey) {
|
|
427
|
+
const res = await fetch(`${API}/v1/models`, {
|
|
428
|
+
headers: authHeaders(apiKey),
|
|
429
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
430
|
+
})
|
|
431
|
+
return { ok: res.ok, status: res.status, res }
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// /api/user/self:余额查这里,也是校验令牌是否还有效的唯一办法。
|
|
435
|
+
async function fetchSelf(key) {
|
|
436
|
+
let res
|
|
437
|
+
try {
|
|
438
|
+
res = await fetch(`${SITE}/api/user/self`, {
|
|
439
|
+
headers: authHeaders(key),
|
|
440
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
441
|
+
})
|
|
442
|
+
} catch (e) {
|
|
443
|
+
return { error: `KunRouter 连不上:${e}` }
|
|
444
|
+
}
|
|
445
|
+
if (res.status === 401 || res.status === 403) {
|
|
446
|
+
return { error: "KunRouter 说这个令牌无效或已过期", expired: true }
|
|
447
|
+
}
|
|
448
|
+
if (!res.ok) return { error: `KunRouter 返回 ${res.status}` }
|
|
449
|
+
const body = await res.json().catch(() => null)
|
|
450
|
+
if (!body?.success || !body?.data) {
|
|
451
|
+
const code = String(body?.code || body?.message_key || "")
|
|
452
|
+
if (code.includes("ACCESS_TOKEN_LEGACY_RETIRED")) {
|
|
453
|
+
return { error: "这个令牌是旧的访问令牌,站点已经退役了;模拟账号管理页 → 安全设置里重新生成一个", expired: true }
|
|
454
|
+
}
|
|
455
|
+
if (code.includes("AUTH_UNAUTHORIZED")) {
|
|
456
|
+
return { error: "这个令牌不是访问令牌;余额要后台安全设置里生成的那种,不是 sk- 开头的 API 密钥", expired: true }
|
|
457
|
+
}
|
|
458
|
+
return { error: body?.message || "KunRouter 返回了读不懂的内容" }
|
|
459
|
+
}
|
|
460
|
+
return { user: body.data }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// /api/subscription/self:当前订阅、钱包/订阅计费优先级。
|
|
464
|
+
// /api/subscription/plans:把 plan_id 换成套餐名;即使这里失败,订阅本身仍然能显示。
|
|
465
|
+
async function fetchSubscriptions(accessToken) {
|
|
466
|
+
try {
|
|
467
|
+
const [selfRes, plansRes] = await Promise.all([
|
|
468
|
+
fetch(`${SITE}/api/subscription/self`, {
|
|
469
|
+
headers: authHeaders(accessToken),
|
|
470
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
471
|
+
}),
|
|
472
|
+
fetch(`${SITE}/api/subscription/plans`, {
|
|
473
|
+
headers: authHeaders(accessToken),
|
|
474
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
475
|
+
}),
|
|
476
|
+
])
|
|
477
|
+
if (selfRes.status === 401) {
|
|
478
|
+
return { error: "KunRouter 说这个令牌无效或已过期", expired: true }
|
|
479
|
+
}
|
|
480
|
+
if (selfRes.status === 403) {
|
|
481
|
+
return { error: "站点还没有开放 Magpie 读取订阅额度" }
|
|
482
|
+
}
|
|
483
|
+
if (!selfRes.ok) return { error: `订阅接口返回 ${selfRes.status}` }
|
|
484
|
+
const selfBody = await selfRes.json().catch(() => null)
|
|
485
|
+
if (!selfBody?.success || !selfBody?.data) {
|
|
486
|
+
return { error: selfBody?.message || "订阅接口返回了读不懂的内容" }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
let plans = []
|
|
490
|
+
if (plansRes.ok) {
|
|
491
|
+
const plansBody = await plansRes.json().catch(() => null)
|
|
492
|
+
if (Array.isArray(plansBody?.data)) plans = plansBody.data
|
|
493
|
+
}
|
|
494
|
+
const data = selfBody.data
|
|
495
|
+
return {
|
|
496
|
+
billingPreference:
|
|
497
|
+
typeof data.billing_preference === "string"
|
|
498
|
+
? data.billing_preference
|
|
499
|
+
: "subscription_first",
|
|
500
|
+
subscriptions: Array.isArray(data.subscriptions) ? data.subscriptions : [],
|
|
501
|
+
plans,
|
|
502
|
+
}
|
|
503
|
+
} catch (e) {
|
|
504
|
+
return { error: `KunRouter 订阅接口连不上:${e}` }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function durationLabel(seconds) {
|
|
509
|
+
const value = Math.max(0, Number(seconds) || 0)
|
|
510
|
+
if (value > 0 && value % 86400 === 0) return `${value / 86400} 天`
|
|
511
|
+
if (value > 0 && value % 3600 === 0) return `${value / 3600} 小时`
|
|
512
|
+
if (value > 0 && value % 60 === 0) return `${value / 60} 分钟`
|
|
513
|
+
return `${Math.round(value)} 秒`
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function quotaWindowLabel(seconds) {
|
|
517
|
+
const value = Number(seconds)
|
|
518
|
+
if (value === 86400) return "日额度"
|
|
519
|
+
if (value === 604800) return "周额度"
|
|
520
|
+
if (value >= 28 * 86400 && value <= 31 * 86400) return "月额度"
|
|
521
|
+
return `${durationLabel(seconds)}额度`
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// 用户在控制台删掉 Key 后,插件手里的 sk- 立刻失效,但访问令牌还在。
|
|
525
|
+
// 用访问令牌重签一把同名 Key,再把新凭据写回 Magpie,下一次请求就能接着用。
|
|
526
|
+
const RECOVERY_COOLDOWN_MS = 60 * 1000
|
|
527
|
+
const recoveryAttempts = new Map() // 访问令牌 -> { at, promise }
|
|
528
|
+
|
|
529
|
+
async function recoverApiKey(accessToken, group) {
|
|
530
|
+
if (!accessToken) return null
|
|
531
|
+
const cached = recoveryAttempts.get(accessToken)
|
|
532
|
+
if (cached && Date.now() - cached.at < RECOVERY_COOLDOWN_MS) {
|
|
533
|
+
return cached.promise.catch(() => null)
|
|
534
|
+
}
|
|
535
|
+
const promise = (async () => {
|
|
536
|
+
const res = await fetch(`${SITE}/api/desktop/keys`, {
|
|
537
|
+
method: "POST",
|
|
538
|
+
headers: { "content-type": "application/json", ...authHeaders(accessToken) },
|
|
539
|
+
// group 为空时站点按账号默认分组签发,和首次授权一致。
|
|
540
|
+
body: JSON.stringify({ name: "Magpie", group: group || undefined }),
|
|
541
|
+
signal: AbortSignal.timeout(10000),
|
|
542
|
+
})
|
|
543
|
+
const data = await responseJSON(res)
|
|
544
|
+
const key = typeof data?.key === "string" ? data.key.trim() : ""
|
|
545
|
+
if (!key.startsWith("sk-")) return null
|
|
546
|
+
return { key, group: String(data?.group || ""), id: data?.id }
|
|
547
|
+
})()
|
|
548
|
+
recoveryAttempts.set(accessToken, { at: Date.now(), promise })
|
|
549
|
+
return promise.catch(() => null)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// 把新 Key 写回 Magpie 的账号;写不进去也只是一次性生效,不影响这次请求。
|
|
553
|
+
async function saveRecoveredAuth(client, auth, recovered) {
|
|
554
|
+
if (!client?.auth?.set) return
|
|
555
|
+
const metadata = { ...(auth?.metadata ?? {}) }
|
|
556
|
+
metadata.apiKey = recovered.key
|
|
557
|
+
if (recovered.group) metadata.group = recovered.group
|
|
558
|
+
if (recovered.id != null) metadata.apiKeyId = recovered.id
|
|
559
|
+
try {
|
|
560
|
+
await client.auth.set({
|
|
561
|
+
path: { id: PROVIDER },
|
|
562
|
+
body: {
|
|
563
|
+
type: "api",
|
|
564
|
+
key: recovered.key,
|
|
565
|
+
accountId: auth?.accountId,
|
|
566
|
+
metadata,
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
} catch {
|
|
570
|
+
/* 写回失败就等下一次 401 再试 */
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export const KunRouterPlugin = async (input) => {
|
|
575
|
+
// magpie 的选项框:plugins.json 里这一项的 options,按供应商 id 读。
|
|
576
|
+
const opt = (key, fallback) => {
|
|
577
|
+
const v = input?.magpie?.option?.(PROVIDER, key)
|
|
578
|
+
return typeof v === "boolean" ? v : fallback
|
|
579
|
+
}
|
|
580
|
+
// 直连域名绕开 Cloudflare 的 100 秒上限;关掉就走主站 /v1。
|
|
581
|
+
const apiBase = () => (opt("direct_api", true) ? API : SITE)
|
|
582
|
+
|
|
583
|
+
// 注册入口要先确认邀请码还能用,用不了就别把入口摆出来(#6)。
|
|
584
|
+
// 关掉 registration_entry 开关也能隐藏它(#12)。
|
|
585
|
+
const inviteState =
|
|
586
|
+
Boolean(INVITE_CODE) && opt("registration_entry", true)
|
|
587
|
+
? await inviteCodeState(INVITE_CODE)
|
|
588
|
+
: null
|
|
589
|
+
const showInvite = Boolean(inviteState?.available)
|
|
590
|
+
const inviteLabel =
|
|
591
|
+
Number(inviteState?.remaining) > 0
|
|
592
|
+
? `没有账号?注册并领取鸡蛋(剩 ${inviteState.remaining} 个名额)`
|
|
593
|
+
: "没有账号?注册并领取鸡蛋(仅新用户)"
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
config: async (cfg) => {
|
|
597
|
+
cfg.provider ??= {}
|
|
598
|
+
cfg.provider[PROVIDER] ??= {
|
|
599
|
+
name: "KunRouter",
|
|
600
|
+
npm: "@ai-sdk/openai-compatible",
|
|
601
|
+
api: `${apiBase()}/v1`,
|
|
602
|
+
endpoint: `${apiBase()}/v1`,
|
|
603
|
+
website: SITE,
|
|
604
|
+
keys: `${SITE}/console/token`,
|
|
605
|
+
models: Object.fromEntries(
|
|
606
|
+
Object.entries(DEFAULT_MODELS).map(([id, name]) => [id, { ...baseModel, name }]),
|
|
607
|
+
),
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
auth: {
|
|
612
|
+
provider: PROVIDER,
|
|
613
|
+
// 一台机器只留一个账号:重新登录(包括手动填 Key)覆盖原来那条,
|
|
614
|
+
// 而不是另开一个 kunrouter#xxxxxx。验证码/密钥填错想重来,
|
|
615
|
+
// 直接再走一遍登录即可,不用先登出。
|
|
616
|
+
single: true,
|
|
617
|
+
maxConcurrency: 8,
|
|
618
|
+
options: [
|
|
619
|
+
{
|
|
620
|
+
key: "direct_api",
|
|
621
|
+
label: "走直连域名",
|
|
622
|
+
description:
|
|
623
|
+
"默认走直连域名,避免长请求被 Cloudflare 的 100 秒上限打断;关掉改走主站。",
|
|
624
|
+
default: true,
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
key: "period_window",
|
|
628
|
+
label: "显示订阅额度",
|
|
629
|
+
description:
|
|
630
|
+
"开(默认):显示订阅套餐的总额、已用、剩余、到期时间和滚动窗口。关掉就只看钱包余额。",
|
|
631
|
+
default: true,
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
key: "registration_entry",
|
|
635
|
+
label: "显示注册入口",
|
|
636
|
+
description: "登录页显示注册入口;邀请码不可用时自动隐藏。",
|
|
637
|
+
default: true,
|
|
638
|
+
},
|
|
639
|
+
],
|
|
640
|
+
methods: [
|
|
641
|
+
{
|
|
642
|
+
type: "oauth",
|
|
643
|
+
label: "浏览器授权(推荐)",
|
|
644
|
+
async authorize(_inputs, { signal } = {}) {
|
|
645
|
+
return startBrowserAuthorization(signal)
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
...(showInvite
|
|
649
|
+
? [
|
|
650
|
+
{
|
|
651
|
+
type: "oauth",
|
|
652
|
+
label: inviteLabel,
|
|
653
|
+
async authorize(_inputs, { signal } = {}) {
|
|
654
|
+
return startBrowserAuthorization(signal, { registration: true })
|
|
655
|
+
},
|
|
656
|
+
},
|
|
657
|
+
]
|
|
658
|
+
: []),
|
|
659
|
+
{
|
|
660
|
+
// 手工兜底:授权服务不可用时,仍可分别填写 PAT 和 sk- 密钥。
|
|
661
|
+
type: "api",
|
|
662
|
+
// 插件自己收两把钥匙;关掉 magpie 额外的 API key 输入框,免得重复粘贴。
|
|
663
|
+
keyless: true,
|
|
664
|
+
label: "手动填写 / 更换访问令牌 + API 密钥",
|
|
665
|
+
placeholder: "两把钥匙分别粘贴",
|
|
666
|
+
prompts: [
|
|
667
|
+
{
|
|
668
|
+
type: "password",
|
|
669
|
+
key: "accessToken",
|
|
670
|
+
message: "访问令牌(后台「个人设置 → 安全设置」生成,用来读余额和签到)",
|
|
671
|
+
placeholder: "不是 sk- 开头的那串",
|
|
672
|
+
validate: (v) => (v && v.trim().length >= 8 ? undefined : "令牌太短,先确认复制全了"),
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
type: "password",
|
|
676
|
+
key: "apiKey",
|
|
677
|
+
message: "API 密钥(后台「令牌」页生成,sk- 开头,用来请求模型;留空就只能看余额)",
|
|
678
|
+
placeholder: "sk-…",
|
|
679
|
+
validate: (v) => (!v || v.trim().startsWith("sk-") ? undefined : "API 密钥是 sk- 开头的,别把访问令牌填这儿"),
|
|
680
|
+
},
|
|
681
|
+
],
|
|
682
|
+
authorize: async (inputs) => {
|
|
683
|
+
const token = String(inputs?.accessToken ?? "").trim()
|
|
684
|
+
const apiKey = String(inputs?.apiKey ?? "").trim()
|
|
685
|
+
if (!token) return { type: "failed", error: "访问令牌不能为空" }
|
|
686
|
+
|
|
687
|
+
const self = await fetchSelf(token)
|
|
688
|
+
if (self.error) {
|
|
689
|
+
return {
|
|
690
|
+
type: "failed",
|
|
691
|
+
error: `${self.error}(这一栏要的是后台安全设置生成的访问令牌,不是 sk- 开头的 API 密钥)`,
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// 顺手验一下 sk- 能不能列模型,省得进了列表才发现拉不到。
|
|
696
|
+
if (apiKey) {
|
|
697
|
+
const probe = await listModels(apiKey).catch(() => null)
|
|
698
|
+
if (!probe) return { type: "failed", error: "API 密钥连不上 /v1/models,确认一下是不是复制全了" }
|
|
699
|
+
if (!probe.ok) return { type: "failed", error: `API 密钥被拒绝(HTTP ${probe.status})` }
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const name = self.user?.email || self.user?.username || self.user?.display_name
|
|
703
|
+
return {
|
|
704
|
+
type: "success",
|
|
705
|
+
key: token,
|
|
706
|
+
accountId: self.user?.id != null ? String(self.user.id) : undefined,
|
|
707
|
+
metadata: { mode: "token", username: name, apiKey: apiKey || undefined },
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
],
|
|
712
|
+
|
|
713
|
+
async loader(getAuth) {
|
|
714
|
+
const auth = await getAuth()
|
|
715
|
+
if (auth?.type !== "api") return {}
|
|
716
|
+
const client = input?.client
|
|
717
|
+
const apiKey = apiKeyOf(auth)
|
|
718
|
+
// 只有访问令牌、没有 sk- 密钥时,/v1 必然 401(访问令牌只能读 /api)。
|
|
719
|
+
// 与其让请求打出去 401,不如当场给一句能看懂的话:这把账号只能看余额和签到。
|
|
720
|
+
if (!apiKey) {
|
|
721
|
+
return {
|
|
722
|
+
baseURL: `${apiBase()}/v1`,
|
|
723
|
+
async fetch() {
|
|
724
|
+
return new Response(
|
|
725
|
+
JSON.stringify({
|
|
726
|
+
error: {
|
|
727
|
+
type: "invalid_request_error",
|
|
728
|
+
message:
|
|
729
|
+
"这个账号只填了访问令牌,没有 sk- API 密钥,不能调用模型。请在 KunRouter 后台「令牌」新建一把 sk- 密钥,或改用浏览器授权。",
|
|
730
|
+
},
|
|
731
|
+
}),
|
|
732
|
+
{ status: 401, headers: { "content-type": "application/json" } },
|
|
733
|
+
)
|
|
734
|
+
},
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
let key = apiKey
|
|
738
|
+
return {
|
|
739
|
+
baseURL: `${apiBase()}/v1`,
|
|
740
|
+
async fetch(input, init) {
|
|
741
|
+
const send = (secret, base) => {
|
|
742
|
+
const headers = new Headers(init?.headers)
|
|
743
|
+
headers.set("authorization", `Bearer ${secret}`)
|
|
744
|
+
for (const [k, v] of Object.entries(clientHeaders())) headers.set(k, v)
|
|
745
|
+
// loader 已经用 apiBase 拼好完整 URL,回退时把直连域名换回主站。
|
|
746
|
+
const target =
|
|
747
|
+
base === SITE && typeof input === "string" && input.startsWith(API)
|
|
748
|
+
? `${SITE}${input.slice(API.length)}`
|
|
749
|
+
: input
|
|
750
|
+
return fetch(target, { ...init, headers })
|
|
751
|
+
}
|
|
752
|
+
let res
|
|
753
|
+
try {
|
|
754
|
+
res = await send(key, apiBase())
|
|
755
|
+
} catch (error) {
|
|
756
|
+
// 直连域名网络层挂了(DNS / 连不上)时回退一次主站,别让明明还能用的站白等。
|
|
757
|
+
const replayable =
|
|
758
|
+
init?.body == null ||
|
|
759
|
+
typeof init.body === "string" ||
|
|
760
|
+
init.body instanceof Uint8Array ||
|
|
761
|
+
init.body instanceof ArrayBuffer
|
|
762
|
+
if (apiBase() === API && replayable) {
|
|
763
|
+
res = await send(key, SITE)
|
|
764
|
+
} else {
|
|
765
|
+
throw error
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
// Key 被控制台删掉后 /v1 会直接 401。访问令牌还能用就重签一把再试。
|
|
769
|
+
if (res.status === 401) {
|
|
770
|
+
const recovered = await recoverApiKey(tokenOf(auth), auth?.metadata?.group)
|
|
771
|
+
if (recovered?.key) {
|
|
772
|
+
key = recovered.key
|
|
773
|
+
await saveRecoveredAuth(client, auth, recovered)
|
|
774
|
+
// 请求体已经被读掉(流式上传)时没法重放,留给下一次请求。
|
|
775
|
+
const replayable =
|
|
776
|
+
init?.body == null ||
|
|
777
|
+
typeof init.body === "string" ||
|
|
778
|
+
init.body instanceof Uint8Array ||
|
|
779
|
+
init.body instanceof ArrayBuffer
|
|
780
|
+
if (replayable) res = await send(key, apiBase())
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return res
|
|
784
|
+
},
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
|
|
788
|
+
// 钱包余额和订阅额度分开显示,避免把订阅消耗误当成钱包扣费。
|
|
789
|
+
// 只有一把钥匙时只显示能读到的那一个,不假装知道另一边的数。
|
|
790
|
+
async usage(getAuth) {
|
|
791
|
+
const auth = await getAuth()
|
|
792
|
+
if (auth?.type !== "api") return { windows: [] }
|
|
793
|
+
const token = tokenOf(auth)
|
|
794
|
+
const apiKey = apiKeyOf(auth)
|
|
795
|
+
if (!token && !apiKey) return { windows: [] }
|
|
796
|
+
|
|
797
|
+
const status = await siteStatus()
|
|
798
|
+
const unit = status.quotaPerUnit || DEFAULT_QUOTA_PER_UNIT
|
|
799
|
+
const sym = status.symbol || "$"
|
|
800
|
+
// 窗口单位跟着站点币种走(本站是 🥚),别写死成美元。
|
|
801
|
+
const currencyUnit = sym && sym !== "$" ? sym : "USD"
|
|
802
|
+
const money = (n) => `${sym} ${n.toFixed(2)}`
|
|
803
|
+
const windows = []
|
|
804
|
+
const showSubscriptions = opt("period_window", true)
|
|
805
|
+
|
|
806
|
+
// 只填了 sk- 的账号到这儿为止。
|
|
807
|
+
if (!token) return { plan: "API 密钥", windows }
|
|
808
|
+
|
|
809
|
+
const [self, subscriptionData] = await Promise.all([
|
|
810
|
+
fetchSelf(token),
|
|
811
|
+
showSubscriptions ? fetchSubscriptions(token) : Promise.resolve(null),
|
|
812
|
+
])
|
|
813
|
+
if (self.error) {
|
|
814
|
+
const out = { windows }
|
|
815
|
+
if (self.expired) out.signIn = "expired"
|
|
816
|
+
if (!windows.length) out.error = self.error
|
|
817
|
+
return out
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const left = Number(self.user?.quota) || 0
|
|
821
|
+
const spent = Number(self.user?.used_quota) || 0
|
|
822
|
+
const available = left / unit
|
|
823
|
+
const walletWindow = {
|
|
824
|
+
name: "钱包",
|
|
825
|
+
balance: available,
|
|
826
|
+
unit: currencyUnit,
|
|
827
|
+
display: `${sym} ${available.toFixed(2)} 可用 · 已用 ${money(spent / unit)}`,
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const planTitles = new Map()
|
|
831
|
+
for (const item of subscriptionData?.plans || []) {
|
|
832
|
+
const plan = item?.plan
|
|
833
|
+
if (plan?.id != null && typeof plan.title === "string") {
|
|
834
|
+
planTitles.set(Number(plan.id), plan.title)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// 分组别用授权时的快照:用户可能在控制台改过 Key 的分组。读一次真实分组,读不到再退回。
|
|
839
|
+
const liveGroup = apiKey ? await currentGroup(token, apiKey) : ""
|
|
840
|
+
const group = liveGroup
|
|
841
|
+
? liveGroup
|
|
842
|
+
: self.user?.group
|
|
843
|
+
? self.user.group
|
|
844
|
+
: auth.metadata?.group
|
|
845
|
+
? `${auth.metadata.group}(授权时)`
|
|
846
|
+
: "默认分组"
|
|
847
|
+
|
|
848
|
+
const activeSubscriptions = (subscriptionData?.subscriptions || [])
|
|
849
|
+
.map((item) => item?.subscription)
|
|
850
|
+
.filter((sub) => sub?.status === "active")
|
|
851
|
+
const subscriptionWindows = []
|
|
852
|
+
for (const sub of activeSubscriptions) {
|
|
853
|
+
const title =
|
|
854
|
+
planTitles.get(Number(sub.plan_id)) || `订阅 #${sub.plan_id || "?"}`
|
|
855
|
+
const total = Number(sub.amount_total) || 0
|
|
856
|
+
const used = Math.max(0, Number(sub.amount_used) || 0)
|
|
857
|
+
const remaining = Math.max(0, total - used)
|
|
858
|
+
const totalAmount = total / unit
|
|
859
|
+
const remainingAmount = remaining / unit
|
|
860
|
+
const percent = total > 0 ? (used / total) * 100 : 0
|
|
861
|
+
const endHint = sub.end_time
|
|
862
|
+
? new Date(Number(sub.end_time) * 1000).toLocaleDateString("zh-CN")
|
|
863
|
+
: ""
|
|
864
|
+
const allowWalletOverflow = sub.allow_wallet_overflow !== false
|
|
865
|
+
|
|
866
|
+
// 滚动窗口套餐把额度全放在 quota_windows 里,amount_total 是 0;
|
|
867
|
+
// 这时不画一条 0 / 0 的空进度条。
|
|
868
|
+
if (total > 0) {
|
|
869
|
+
subscriptionWindows.push({
|
|
870
|
+
name: title,
|
|
871
|
+
used: percent,
|
|
872
|
+
amount: used / unit,
|
|
873
|
+
limit: totalAmount,
|
|
874
|
+
unit: currencyUnit,
|
|
875
|
+
...(sub.start_time && sub.end_time
|
|
876
|
+
? {
|
|
877
|
+
span: Math.max(
|
|
878
|
+
0,
|
|
879
|
+
Number(sub.end_time) - Number(sub.start_time),
|
|
880
|
+
),
|
|
881
|
+
}
|
|
882
|
+
: {}),
|
|
883
|
+
...(sub.end_time
|
|
884
|
+
? {
|
|
885
|
+
runsOutAt: new Date(Number(sub.end_time) * 1000).toISOString(),
|
|
886
|
+
runsOut: remainingAmount,
|
|
887
|
+
}
|
|
888
|
+
: {}),
|
|
889
|
+
// 套餐允许钱包兜底时,用完订阅不会让账号停摆。
|
|
890
|
+
aside: allowWalletOverflow,
|
|
891
|
+
display: `剩余 ${money(remainingAmount)} / ${money(totalAmount)}${endHint ? ` · ${endHint} 到期` : ""}`,
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
for (const window of Array.isArray(sub.quota_windows)
|
|
896
|
+
? sub.quota_windows
|
|
897
|
+
: []) {
|
|
898
|
+
const windowLimit = Number(window?.quota_limit) || 0
|
|
899
|
+
const windowUsed = Math.max(0, Number(window?.used) || 0)
|
|
900
|
+
if (windowLimit <= 0) continue
|
|
901
|
+
const windowRemaining = Math.max(0, windowLimit - windowUsed)
|
|
902
|
+
const windowPercent = (windowUsed / windowLimit) * 100
|
|
903
|
+
const nextRelease = Number(window?.next_release_at) || 0
|
|
904
|
+
subscriptionWindows.push({
|
|
905
|
+
name:
|
|
906
|
+
activeSubscriptions.length > 1
|
|
907
|
+
? `${title} · ${quotaWindowLabel(window?.window_seconds)}`
|
|
908
|
+
: quotaWindowLabel(window?.window_seconds),
|
|
909
|
+
used: windowPercent,
|
|
910
|
+
amount: windowUsed / unit,
|
|
911
|
+
limit: windowLimit / unit,
|
|
912
|
+
unit: currencyUnit,
|
|
913
|
+
span: Number(window?.window_seconds) || 0,
|
|
914
|
+
...(nextRelease
|
|
915
|
+
? { resetsAt: new Date(nextRelease * 1000).toISOString() }
|
|
916
|
+
: {}),
|
|
917
|
+
aside: allowWalletOverflow,
|
|
918
|
+
display: `剩余 ${money(windowRemaining / unit)} / ${money(windowLimit / unit)}`,
|
|
919
|
+
})
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const subscriptionFirst =
|
|
924
|
+
subscriptionData?.billingPreference !== "wallet_first" &&
|
|
925
|
+
subscriptionData?.billingPreference !== "wallet_only"
|
|
926
|
+
const orderedWindows = subscriptionFirst
|
|
927
|
+
? [...subscriptionWindows, walletWindow]
|
|
928
|
+
: [walletWindow, ...subscriptionWindows]
|
|
929
|
+
|
|
930
|
+
windows.unshift(...orderedWindows)
|
|
931
|
+
if (subscriptionData?.error) {
|
|
932
|
+
windows.push({
|
|
933
|
+
name: "订阅",
|
|
934
|
+
aside: true,
|
|
935
|
+
display: "订阅额度暂不可用",
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const subscriptionTitles = activeSubscriptions
|
|
940
|
+
.map(
|
|
941
|
+
(sub) =>
|
|
942
|
+
planTitles.get(Number(sub.plan_id)) || `订阅 #${sub.plan_id || "?"}`,
|
|
943
|
+
)
|
|
944
|
+
.filter((title, index, titles) => titles.indexOf(title) === index)
|
|
945
|
+
const preferenceSuffix =
|
|
946
|
+
subscriptionData?.billingPreference === "wallet_first"
|
|
947
|
+
? " · 钱包优先"
|
|
948
|
+
: subscriptionData?.billingPreference === "subscription_only"
|
|
949
|
+
? " · 仅订阅"
|
|
950
|
+
: subscriptionData?.billingPreference === "wallet_only"
|
|
951
|
+
? " · 仅钱包"
|
|
952
|
+
: ""
|
|
953
|
+
const planTitle = subscriptionTitles.length
|
|
954
|
+
? subscriptionTitles.join(" / ")
|
|
955
|
+
: ""
|
|
956
|
+
const plan = `${planTitle ? `${planTitle} · ` : ""}${group}${preferenceSuffix}`
|
|
957
|
+
const until = activeSubscriptions.reduce(
|
|
958
|
+
(latest, sub) => Math.max(latest, Number(sub.end_time) || 0),
|
|
959
|
+
0,
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
plan,
|
|
964
|
+
user: self.user?.email || self.user?.username || undefined,
|
|
965
|
+
balance: money(available),
|
|
966
|
+
...(until ? { until: new Date(until * 1000).toISOString() } : {}),
|
|
967
|
+
windows,
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
// 站点开着每日签到(/api/status 的 checkin_enabled),顺手替账号签了。
|
|
972
|
+
async checkin(getAuth) {
|
|
973
|
+
const auth = await getAuth()
|
|
974
|
+
const key = tokenOf(auth)
|
|
975
|
+
if (!key) return { outcome: "ineligible" }
|
|
976
|
+
|
|
977
|
+
// 与签到请求并行读取换算比例,避免成功后再等一次站点请求。
|
|
978
|
+
const status = siteStatus()
|
|
979
|
+
let res
|
|
980
|
+
try {
|
|
981
|
+
res = await fetch(`${SITE}/api/user/checkin`, {
|
|
982
|
+
method: "POST",
|
|
983
|
+
headers: { ...authHeaders(key), "content-type": "application/json" },
|
|
984
|
+
body: "{}",
|
|
985
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
986
|
+
})
|
|
987
|
+
} catch (e) {
|
|
988
|
+
return { outcome: "failed", message: `KunRouter 连不上:${e}` }
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (res.status === 401 || res.status === 403) {
|
|
992
|
+
throw Object.assign(new Error("KunRouter 说这个令牌无效或已过期"), { signIn: "expired" })
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const body = await res.json().catch(() => ({}))
|
|
996
|
+
const message = typeof body?.message === "string" ? body.message : ""
|
|
997
|
+
if (!res.ok || body?.success === false) {
|
|
998
|
+
// 「今天已签到」是正常的 done,不是失败。
|
|
999
|
+
if (/已签到|已经签到|already/i.test(message)) return { outcome: "done", message }
|
|
1000
|
+
return { outcome: "failed", message: message || `KunRouter 返回 ${res.status}` }
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// 签到接口返回原始 quota,和钱包一样换算成站点显示的额度。
|
|
1004
|
+
const quota = Number(body?.data?.quota_awarded ?? body?.data?.quota ?? 0) || 0
|
|
1005
|
+
const credit = quota ? quota / ((await status).quotaPerUnit || DEFAULT_QUOTA_PER_UNIT) : 0
|
|
1006
|
+
return {
|
|
1007
|
+
outcome: "claimed",
|
|
1008
|
+
credit: credit || undefined,
|
|
1009
|
+
message: message || (body?.data?.checkin_date ? `已签到 ${body.data.checkin_date}` : "签到完成"),
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
},
|
|
1013
|
+
|
|
1014
|
+
// 站点的模型列表是公开的(要 key),拉一次就够了,拉不到就保留上一份。
|
|
1015
|
+
provider: {
|
|
1016
|
+
id: PROVIDER,
|
|
1017
|
+
async models(provider, { auth } = {}) {
|
|
1018
|
+
if (auth?.type !== "api") return provider.models
|
|
1019
|
+
const key = apiKeyOf(auth)
|
|
1020
|
+
if (!key) return provider.models
|
|
1021
|
+
|
|
1022
|
+
let list
|
|
1023
|
+
try {
|
|
1024
|
+
const res = await fetch(`${API}/v1/models`, {
|
|
1025
|
+
headers: authHeaders(key),
|
|
1026
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
1027
|
+
})
|
|
1028
|
+
if (!res.ok) return provider.models
|
|
1029
|
+
const body = await res.json()
|
|
1030
|
+
list = Array.isArray(body?.data) ? body.data : []
|
|
1031
|
+
} catch {
|
|
1032
|
+
return provider.models
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const out = {}
|
|
1036
|
+
for (const m of list) {
|
|
1037
|
+
const id = typeof m?.id === "string" ? m.id : ""
|
|
1038
|
+
if (!id) continue
|
|
1039
|
+
out[id] = { ...baseModel, name: id }
|
|
1040
|
+
}
|
|
1041
|
+
return Object.keys(out).length ? out : provider.models
|
|
1042
|
+
},
|
|
1043
|
+
},
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// 给测试用,别从入口导出别的函数:magpie 会把每个导出函数都当插件调用。
|
|
1048
|
+
export const _internal = {
|
|
1049
|
+
INVITE_CODE,
|
|
1050
|
+
apiKeyOf,
|
|
1051
|
+
currentGroup,
|
|
1052
|
+
fetchSelf,
|
|
1053
|
+
fetchSubscriptions,
|
|
1054
|
+
inviteCodeState,
|
|
1055
|
+
isAllowedAuthorizationURL,
|
|
1056
|
+
maskKey,
|
|
1057
|
+
recoverApiKey,
|
|
1058
|
+
siteStatus,
|
|
1059
|
+
stableInstallationId,
|
|
1060
|
+
startBrowserAuthorization,
|
|
1061
|
+
tokenOf,
|
|
1062
|
+
DEFAULT_MODELS,
|
|
1063
|
+
DEFAULT_QUOTA_PER_UNIT,
|
|
1064
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-kunrouter-auth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.3",
|
|
4
|
+
"description": "KunRouter (wcnmb.si) provider for OpenCode and magpie — browser sign-in, wallet and subscription quota, daily check-in",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.mjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node browser-auth.test.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": ["index.mjs", "README.md", "CHANGELOG.md"],
|
|
11
|
+
"keywords": ["opencode", "opencode-plugin", "magpie", "kunrouter", "new-api"],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/MY-Final/opencode-kunrouter-auth.git"
|
|
16
|
+
},
|
|
17
|
+
"magpie": {
|
|
18
|
+
"name": "KunRouter",
|
|
19
|
+
"icon": "https://wcnmb.si/logo.png",
|
|
20
|
+
"maxConcurrency": 8,
|
|
21
|
+
"summary": {
|
|
22
|
+
"en": "KunRouter relay: browser sign-in, wallet and subscription quota, daily check-in.",
|
|
23
|
+
"zh": "KunRouter 中转:浏览器一键授权、钱包与订阅额度、每日签到。"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|