@fish-under-sea/magpie-neartoken 0.1.5 → 0.1.7

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.
Files changed (3) hide show
  1. package/README.md +32 -91
  2. package/index.mjs +281 -0
  3. package/package.json +10 -2
package/README.md CHANGED
@@ -1,10 +1,8 @@
1
1
  # NearToken in Magpie
2
2
 
3
- 把 [NearToken](https://neartoken.cc) 接进 [magpie](https://github.com/yetone/magpie) 的一个**单包插件**:侧边栏「插件」一组里多一个 NearToken 页面,负责登录绑定、余额与代金券、消耗看板和充值;模型与路由交回 magpie 自己的供应商页。
3
+ 把 [NearToken](https://neartoken.cc) 接进 [magpie](https://github.com/yetone/magpie) 的**单包插件**:侧边栏「插件」一组里多一个 NearToken 页面,负责登录绑定、余额与代金券、消耗看板和充值;模型与路由交回 magpie 自己的供应商页。
4
4
 
5
- 它不预装、不编进 magpie 的安装包,也不占用 magpie 的内置页面:`@fish-under-sea/magpie-neartoken` 就是一个可以单独下载、单独安装、单独卸载的包。
6
-
7
- 这个仓库是一次**迁移**,不是兼容层:原先是 DSH(DeepSeek Harness)里的 `dsh-neartoken`,现在按 magpie 的插件体系重做——页面跑在 magpie 的窗口里,出站请求由 magpie 本体代发,供应商挂在 `providers.json` 上,magpie 的模型选择、路由、失败切换照常工作。
5
+ 它不预装、不编进 magpie 的安装包,也不占用 magpie 的内置页面:`@fish-under-sea/magpie-neartoken` 就是一个可以单独下载、单独安装、单独卸载的包。**这个仓库只放插件本身**,不含 magpie 的源码、补丁或改动。
8
6
 
9
7
  ## 下载
10
8
 
@@ -12,7 +10,7 @@
12
10
  | --- | --- |
13
11
  | npm(推荐) | `magpie plugin add @fish-under-sea/magpie-neartoken` |
14
12
  | GitHub 仓库 | `magpie plugin add github:Fish-under-sea/magpie-neartoken` |
15
- | 离线包 | 在 [Releases](https://github.com/Fish-under-sea/magpie-neartoken/releases) 下载 `magpie-neartoken-<版本>.tgz`,解包后把目录添进「插件」页 |
13
+ | 离线包 | 在 [Releases](https://github.com/Fish-under-sea/magpie-neartoken/releases) 下载 `fish-under-sea-magpie-neartoken-<版本>.tgz`,解包后把目录添进「插件」页 |
16
14
 
17
15
  装完在 magpie 的「插件」页打开 NearToken。卸载就是删掉这个包,magpie 的其它部分不受影响。
18
16
 
@@ -21,23 +19,35 @@
21
19
  | 页面上的事 | 数据从哪来 |
22
20
  | --- | --- |
23
21
  | 连接 NearToken | 站点的设备授权(RFC 8628 形状):页面显示确认码,你在站点自己的页面登录并批准 |
24
- | 余额、已用、代金券 | `GET /api/dsh/device/wallet`,用插件签下来的密钥读 |
25
- | 消耗看板(日 / 周 / 月) | `GET /api/dsh/device/usage?range=…`,按模型堆叠 |
26
- | 充值 | `GET /api/dsh/device/topup/info` 与 `POST /api/dsh/device/topup`,支付页交给系统浏览器 |
27
- | 模型列表 | `GET /api/pricing`,写进 magpie 的供应商目录 |
22
+ | 余额、已用、代金券 | 站点钱包端点,用连接签下来的密钥读 |
23
+ | 消耗看板(日 / 周 / 月) | 站点用量端点,按模型堆叠 |
24
+ | 充值 | 站点充值端点,支付页交给系统浏览器 |
25
+ | 模型目录 | 站点 `/api/pricing` |
26
+
27
+ **账号只有一个。** 不支持手动粘贴密钥,也不支持多账号:连接由页面发起,断开在页面点。
28
28
 
29
- 页面本身一条 `fetch` 都发不出去(它跑在 `connect-src 'none'` 的沙箱帧里),所以上面每一件事都经 `magpie.call` 回到宿主执行。页面永远拿不到密钥:密钥只存在 `providers.json` 的 `neartoken` 那一条里。
29
+ 登录全在站点自己页面上完成——密码、GitHub、Discord、LinuxDo、OIDC、Telegram、微信、Passkey 都走站点原生流程;插件不收密码,也不存密码。
30
30
 
31
- **账号只有一个。** 不支持手动粘贴密钥,也不支持多账号:连接由页面发起,断开在页面点,别的地方没有第二入口。
31
+ ## 模型:按分组展开,带倍率
32
32
 
33
- ## 宿主通道
33
+ 包里的 `index.mjs` 同时是一个 magpie 的 provider 插件(`auth` + `provider` + `usage`),所以模型不用 magpie 侧代填:
34
34
 
35
- `neartoken:*` 这几条调用属于**宿主能力**,不在插件包里:页面能做的每一次出站请求,都得由 magpie 本体执行。用到的是 magpie 的插件页调用通道(`POST /api/plugin-pages/call`),按插件 `package.json` 的 `magpie.calls` 白名单放行。
35
+ - **模型表按「分组 × 模型」展开**:一个模型在几个分组里就是几行,id 形如 `#56::gpt-5.6-sol`。
36
+ - **名字带站点原文的组名与倍率**:`[低价] GPT 福利 0.2x · gpt-5.6-sol`。组名里的 emoji 与备注照抄,不翻译。
37
+ - **每行带自己的 `x-neartoken-group` 头**:选哪一行就走那个分组,不用手填任何路由。
38
+ - **生图与视频模型单独标注**(`output: image` / `video`),进 magpie 的图片生成与视频生成。
39
+ - **图标来自包里那张图**(`data:image/png`),不联网抓,断网也不会变成灰书页。
40
+ - **余额走 magpie 的用量卡**(`auth.usage`):显示 `¥3.60`,已用金额单列一行。
36
41
 
37
- 因此有两种 magpie:
42
+ 模型目录读的是站点的 `/api/pricing`(公开、全量),不用 `/v1/models`——那个端点按令牌分组过滤,很多账号拿到的是空列表,magpie 的「重新获取模型」就会报 `no models listed`。这里不碰它。
38
43
 
39
- - **带 neartoken 通道的构建**:页面全套功能可用。补丁与构建脚本在本仓库 `patches/`、`host/`、`tools/` 下,见[自己给 magpie 加通道](#自己给-magpie-加通道)。
40
- - **官方原版**:页面照常出现、照常读得到站点与连接状态,但一点「连接」就会说明「这个 magpie 还没有插件页的调用通道」。不会静默失败,也不会假装成功。
44
+ ## 页面怎么用
45
+
46
+ 1. 装上包,侧边栏「插件」→「NearToken」。
47
+ 2. 点「连接 NearToken」,页面给一个确认码。
48
+ 3. 点「打开确认页」,用平时的方式登录站点,确认码一致后批准。
49
+ 4. 批准后自动接入:余额、代金券、看板、充值一次到齐;模型同时出现在 magpie 的供应商页。
50
+ 5. 断开连接会撤销站点上这把密钥,并删掉 magpie 里这一条供应商。
41
51
 
42
52
  ## 页面调用(`magpie.calls`)
43
53
 
@@ -52,94 +62,25 @@
52
62
  | `neartoken:usage` | `{range: day\|week\|month}` 的用量窗口 |
53
63
  | `neartoken:topup` | 不带 `amount` 读档位;带 `amount` 与 `paymentMethod` 下单并打开支付页 |
54
64
 
55
- `package.json` 必须原样声明这张表:magpie 按它放行,宿主再按同一份声明校验一次。
56
-
57
- ## 和 dsh-neartoken 的关系:只共用站点,不混用标识
58
-
59
- 这不是 dsh-neartoken 的兼容层,也没有复用它的代码、配置或令牌。两边的连接方式不一样:
60
-
61
- | | dsh-neartoken | 本插件 |
62
- | --- | --- | --- |
63
- | 宿主 | DSH | magpie |
64
- | 请求由谁发 | DSH Host | magpie 本体(`/api/plugin-pages/call`) |
65
- | 站点确认页上的称呼 | `DSH 插件` | `Magpie` |
66
- | 请求的 UA | DSH 自己的 | `magpie-neartoken` |
67
- | 请求的令牌名 | 不带,站点写死 `DSH` | 带 `token_name: Magpie` |
68
- | 供应商 | DSH 的 `neartoken` provider | magpie 的 `neartoken` 供应商 |
69
-
70
- 共用的只有站点已经实现的那批设备授权端点(`/api/dsh/device/authorize`、`/token`、`/revoke`)与钱包、用量、充值端点——那是站点自己的接口,不是哪一家的实现。作者的 `DSH` 标识原样留着:他的令牌、日志和授权记录仍然只认 `DSH`,我们的连接报 `Magpie`,两边在站点后台一眼分得开。
71
-
72
- ### 站点上的令牌名由站点决定
73
-
74
- 插件在发起授权时带两个名字:`client_name: Magpie`(确认页上写「Magpie 请求接入」)与 `token_name: Magpie`(希望站点给这把密钥起的名字)。
75
-
76
- - 站点按 `client_name` 或 `token_name` 命名时,站点后台里这把钥匙就叫 **Magpie**;
77
- - 站点若沿用它给 DSH 设备授权写死的那套命名,令牌名仍然是它自己的那一套——那是站点侧的实现,客户端改不了。
78
-
79
- 插件不把名字当事实:页面「令牌名」一栏显示站点回的 `token_name`,回什么显示什么;断开时按这把密钥撤销,不靠名字认人。要让站点后台固定显示 **Magpie**,需要在站点侧让 `/api/dsh/device/*` 用请求里的 `client_name` / `token_name` 命名令牌,而不是写死。
80
-
81
- 站点对「只有 DSH 授权签发的钥匙能充值」这件事回的是 `DSH_TOKEN_REQUIRED`,那是站点的历史码;插件只按它分支,对外一律说「这把密钥不是本插件登录签发的」。
82
-
83
- ## 页面怎么用
84
-
85
- 1. 装上包,侧边栏「插件」→「NearToken」。
86
- 2. 点「连接 NearToken」,页面给一个确认码。
87
- 3. 点「打开确认页」,用平时的方式登录站点——密码、GitHub、Discord、LinuxDo、OIDC、Telegram、微信、Passkey 都在站点自己的页面上完成。
88
- 4. 批准后自动接入:余额、代金券、看板、充值一次到齐;模型同时出现在 magpie 的供应商页。
89
- 5. 断开连接会撤销站点上这把密钥,并删掉 magpie 里这一条供应商。
90
-
91
- ## 排错
92
-
93
- | 画面 / 报错 | 原因 | 怎么办 |
94
- | --- | --- | --- |
95
- | `NearToken doesn't ask for neartoken:state` | 宿主补丁的 `PageCalls` 里没有这八条。只写在 `package.json` 里会被 `callsKeeping` 静默丢掉,窗口那边就只剩一句「没问过」 | 重新打 `patches/magpie-neartoken.patch`(0.1.4 起含 `internal/plugin/page.go` 的改动),再重启 magpie |
96
- | 「这个 magpie 还没有插件页的调用通道」 | 官方原版 magpie,没有插件页调用通道 | 同上,需要带通道的构建 |
97
- | 插件列表里是灰书页 / 没有图标 | 包里的 `page/icon.png` 没装到 | 用 npm 装或 release 的 tgz,别只拷 `page/index.html` |
98
- | 页面空白,10 秒后变成「停止工作」 | 页面没应答 hello | 确认 magpie 版本,或看窗口日志 |
99
-
100
- 自证一遍(不用开窗口):`magpie web` 会打印带一次性 key 的链接;用它换 cookie 后请求 `/api/plugin-pages`,那个页面的 `calls` 里应当有八条 `neartoken:*`。
65
+ 页面跑在 magpie 的沙箱帧里(`connect-src 'none'`),自己发不出网络请求,所以上面每一件事都经 `magpie.call` 由 magpie 本体代发。这几条调用属于宿主能力,由 magpie 侧提供;插件这边只负责声明它们,并按返回的数据画页面。
101
66
 
102
67
  ## 仓库结构
103
68
 
104
- 仓库根就是那个 npm 包(`package.json` 在根,`npm pack` 直接可用);宿主的源码与脚本在旁边的目录里,不进包。
69
+ 仓库根就是那个 npm 包(`package.json` 在根,`npm pack` 直接可用):
105
70
 
106
71
  ```
107
72
  /
108
73
  ├── package.json 插件清单:magpie.page / magpie.icon / magpie.calls
109
74
  ├── page/ 页面本体:index.html · app.js · style.css · icon.svg · icon.png
110
75
  ├── LICENSE
111
- ├── host/internal/ 宿主侧实现的源码(设备授权、钱包、用量、充值、目录)
112
- ├── patches/ 叠在 FISH 的 magpie 补丁之上的增量
113
- ├── tools/ 构建与打补丁的脚本
114
- └── docs/ 设计与契约
115
- ```
116
-
117
- ## 自己给 magpie 加通道
118
-
119
- 前提:magpie 源码已经是 `v0.1.2732` + FISH 的 `magpie-v0.1.2732+fish.9.patch`(那一版已经带了插件页调用通道),本仓库的补丁只往上再叠三件事:
120
-
121
- 1. `internal/neartoken`:设备授权、钱包、用量、充值、目录,以及把站点挂成供应商;
122
- 2. `internal/plugin/page.go` 的 `PageCalls`:补上 `neartoken:*` 八条——**两处都要列**,只写在 `package.json` 里会被 `callsKeeping` 静默丢掉,页面只会看到「NearToken doesn't ask for neartoken:state」;
123
- 3. `internal/gui/pluginpagecall.go` 里多一条 `neartoken:` 前缀的分派。
124
-
125
- ```powershell
126
- git -C ..\upstream-2732 apply patches\magpie-neartoken.patch
127
- pwsh -File tools\build.ps1 -Upstream ..\upstream-2732 -Out dist\magpie.exe
128
- ```
129
-
130
- 打完之后可以自证一遍:`magpie web` 打印的链接带一次性 key,用它换 cookie 后请求 `/api/plugin-pages`,那个页面的 `calls` 里应当有八条 `neartoken:*`;`POST /api/plugin-pages/call` 带 `{"name":"neartoken:state"}` 应当回 `configured:false`。
131
-
132
- `internal/neartoken` 的测试用 `httptest` 起一个假站点,不碰网络:
133
-
134
- ```powershell
135
- go test ./internal/neartoken/ -count=1
76
+ └── README.md
136
77
  ```
137
78
 
138
79
  ## 设计取舍
139
80
 
140
- - **登录用设备授权,不收密码。** 站点自己的登录页负责认证与 2FA,插件只轮询结果。插件签下来的密钥就是站点的一张普通令牌,与 `Authorization: Bearer` 完全一致。
141
- - **模型选择不重做。** magpie 已经有供应商、模型、路由、失败切换这一整套;插件只负责把站点目录写进供应商,剩下的交回窗口。
142
- - **金额口径只有一份。** 汇率来自 `/api/status` 的 `quota_per_unit` 与 `usd_exchange_rate`,钱包、看板、代金券都用它折算,三处数字不会互相打架。
81
+ - **登录用设备授权,不收密码。** 站点自己的登录页负责认证与 2FA,插件只轮询结果。连接签下来的密钥就是站点的一张普通令牌。
82
+ - **模型选择不重做。** magpie 已经有供应商、模型、路由、失败切换这一整套;插件只把站点目录写进供应商,剩下的交回窗口。
83
+ - **金额口径只有一份。** 汇率来自站点状态接口,钱包、看板、代金券都用它折算,三处数字不会互相打架。
143
84
  - **读不到就说不知道。** 站点没有用量端点时页面写「站点未提供用量统计」,不画一张空图——空图会被读成「这段时间没有消耗」。
144
85
 
145
86
  ## 许可
package/index.mjs ADDED
@@ -0,0 +1,281 @@
1
+ // NearToken 的 magpie 宿主部分:一个 OpenCode 形状的 provider 插件。
2
+ //
3
+ // 它不碰 magpie 源码,用的都是 magpie 自己提供的扩展点:
4
+ //
5
+ // auth.methods 设备授权(站点自己的确认页,插件不收密码)
6
+ // auth.usage 余额,按 magpie 的用量卡显示
7
+ // auth.loader 请求带着这把钥匙出去
8
+ // auth.icon 供应商卡上的那张图(data: URI,不联网抓)
9
+ // provider.models 模型目录:一个「分组 × 模型」一行,名字带站点原文组名与
10
+ // 倍率,每个模型带自己的 x-neartoken-group 头
11
+ //
12
+ // 站点自己的 /v1/models 按令牌分组过滤,很多账号拿到的是空列表,所以模型目录
13
+ // 直接读 /api/pricing(公开、全量),不靠那个端点。
14
+
15
+ import { readFileSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const SITE = 'https://neartoken.cc'
20
+ /** 站点确认页上的称呼,也是站点给这把钥匙起的名字(HANDOFF:Magpie 与 DSH 是两把)。 */
21
+ const CLIENT_NAME = 'Magpie'
22
+ const TOKEN_NAME = 'Magpie'
23
+ /** 供应商 id:一个就好,单账号。 */
24
+ const PROVIDER_ID = 'neartoken'
25
+ /** 一次授权在内存里的那点状态。 */
26
+ let pending = null
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url))
29
+
30
+ /**
31
+ * 站点图标编成 data: URI:magpie 的 auth.icon 只收 https 与 data:image,
32
+ * 不抓包内相对路径,联网抓又会在断网时变成灰书页。
33
+ */
34
+ function iconURI() {
35
+ try {
36
+ const png = readFileSync(join(here, 'page', 'icon.png'))
37
+ return 'data:image/png;base64,' + png.toString('base64')
38
+ } catch {
39
+ return ''
40
+ }
41
+ }
42
+ const ICON = iconURI()
43
+
44
+ /** call 是一次站点请求:JSON 出去,JSON 回来。 */
45
+ async function call(path, { method = 'GET', body, key } = {}) {
46
+ const headers = { accept: 'application/json', 'user-agent': 'magpie-neartoken' }
47
+ if (body !== undefined) headers['content-type'] = 'application/json'
48
+ if (key) headers.authorization = 'Bearer ' + key
49
+ const res = await fetch(SITE + path, {
50
+ method,
51
+ headers,
52
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
53
+ })
54
+ const text = await res.text()
55
+ let parsed
56
+ try {
57
+ parsed = JSON.parse(text)
58
+ } catch {
59
+ throw new Error(`${path} answered ${res.status} with a body that isn't JSON`)
60
+ }
61
+ return parsed
62
+ }
63
+
64
+ /** 站点没给 token_name 时的回落:Magpie,不是 DSH。 */
65
+ function grantOf(data) {
66
+ const token = typeof data?.access_token === 'string' ? data.access_token : ''
67
+ if (!token) throw new Error('设备授权应答缺少 access_token')
68
+ return { type: 'success', access: token, refresh: token, tokenName: data?.token_name || TOKEN_NAME, user: data?.user }
69
+ }
70
+
71
+ /** 站点当前怎么印钱:一个 quota 值折算成它显示的那笔钱。 */
72
+ async function currency() {
73
+ try {
74
+ const s = await call('/api/status')
75
+ const d = s?.data ?? {}
76
+ const per = Number(d.quota_per_unit) > 0 ? Number(d.quota_per_unit) : 1
77
+ const rate = Number(d.usd_exchange_rate) > 0 ? Number(d.usd_exchange_rate) : 1
78
+ const display = d.display_in_currency === true
79
+ const symbol = typeof d.custom_currency_symbol === 'string' && d.custom_currency_symbol.trim()
80
+ ? d.custom_currency_symbol.trim()
81
+ : display ? '¥' : '$'
82
+ return { symbol, per, rate, display }
83
+ } catch {
84
+ return { symbol: '$', per: 1, rate: 1, display: false }
85
+ }
86
+ }
87
+
88
+ /** money 把 quota 折成站点显示的那笔钱。 */
89
+ function money(quota, cur) {
90
+ const base = quota / (cur.per > 0 ? cur.per : 1)
91
+ return (cur.display && cur.rate > 0 ? base * cur.rate : base).toFixed(2)
92
+ }
93
+
94
+ /** text 是带符号的一笔钱。 */
95
+ function text(quota, cur) {
96
+ return cur.symbol + money(quota, cur)
97
+ }
98
+
99
+ /** 倍率的写法:0.1 → 0.1,1 → 1。 */
100
+ function ratioText(v) {
101
+ return String(Math.round(v * 10000) / 10000)
102
+ }
103
+
104
+ /** 生图模型的名字词。 */
105
+ const DRAWS = ['image', 'imagen', 'imagine', 'dall-e', 'flux', 'seedream', 'cogview', 'stable-diffusion', 'sdxl', 'wanx', 'kolors', 'hidream', 'banana', 'midjourney', 'mj-', 'qwen-image']
106
+ /** 视频模型的名字词。 */
107
+ const FILMS = ['veo', 'sora', 'kling', 'seedance', 'hailuo', 'vidu', 'pixverse', 'runway', 'luma', 'wan2', 'wanx-video', 'minimax-video', 'movie', 'video', 't2v', 'i2v']
108
+ /** 既不聊也不画不拍的名字。 */
109
+ const SKIP = ['embedding', 'embed-', 'text-embedding', 'tts', 'transcribe', 'asr', 'rerank', 'moderation', 'whisper', 'speech']
110
+
111
+ const hasWord = (id, words) => words.some((w) => id.toLowerCase().includes(w))
112
+
113
+ /**
114
+ * 目录:把 /api/pricing 展开成 magpie 的模型表。
115
+ *
116
+ * 一个模型在几个分组里就是几行,id 里带组 ref(`#7::grok-4.6`),名字是
117
+ * 「站点原文组名 + 倍率 + 模型」。每行带自己的 x-neartoken-group 头,请求
118
+ * 按那一行走那一组——这就是"模型后面跟着它的分组"。
119
+ */
120
+ async function catalog() {
121
+ const env = await call('/api/pricing')
122
+ const rows = Array.isArray(env?.data) ? env.data : []
123
+ const refs = env?.group_refs && typeof env.group_refs === 'object' ? env.group_refs : {}
124
+ const ratios = env?.group_ratio && typeof env.group_ratio === 'object' ? env.group_ratio : {}
125
+ const out = {}
126
+ for (const row of rows) {
127
+ const model = String(row?.model_name ?? '').trim()
128
+ if (!model || hasWord(model, SKIP) || model.toLowerCase().endsWith('-c')) continue
129
+ const ends = Array.isArray(row?.supported_endpoint_types) ? row.supported_endpoint_types : []
130
+ const chat = ends.length === 0 || ends.some((e) => ['openai', 'openai-response', 'anthropic', 'gemini'].includes(e))
131
+ const drawer = hasWord(model, DRAWS) || ends.includes('image-generation')
132
+ const film = hasWord(model, FILMS)
133
+ if (!chat && !drawer && !film) continue
134
+ const groups = Array.isArray(row?.enable_groups) && row.enable_groups.length ? row.enable_groups : ['']
135
+ for (const raw of groups) {
136
+ const group = String(raw ?? '').trim()
137
+ const ref = group && refs[group] ? String(refs[group]) : ''
138
+ const ratio = group && Number(ratios[group]) > 0 ? Number(ratios[group]) : 0
139
+ const id = ref ? `${ref}::${model}` : model
140
+ const label = group ? (ratio > 0 ? `${group} ${ratioText(ratio)}x` : group) + ' · ' + model : model
141
+ out[id] = {
142
+ name: label,
143
+ api: { id: model, url: SITE + '/v1', npm: '@ai-sdk/openai-compatible' },
144
+ limit: {
145
+ context: Number(row?.context_window) > 0 ? Number(row.context_window) : 0,
146
+ output: Number(row?.max_tokens) > 0 ? Number(row.max_tokens) : 0,
147
+ },
148
+ ...(ref ? { headers: { 'x-neartoken-group': ref } } : {}),
149
+ capabilities: {
150
+ toolcall: true,
151
+ ...(drawer ? { output: { image: true, text: true } } : {}),
152
+ ...(film ? { output: { video: true, text: true } } : {}),
153
+ },
154
+ }
155
+ }
156
+ }
157
+ return out
158
+ }
159
+
160
+ export default async function neartoken() {
161
+ return {
162
+ auth: {
163
+ provider: PROVIDER_ID,
164
+ ...(ICON ? { icon: ICON } : {}),
165
+ methods: [
166
+ {
167
+ type: 'oauth',
168
+ label: 'NearToken(设备授权)',
169
+ /**
170
+ * 向站点要一个确认码;人自己去站点确认。插件不碰密码。
171
+ * @returns {Promise<{url: string, instructions: string}>}
172
+ */
173
+ async authorize() {
174
+ const env = await call('/api/dsh/device/authorize', {
175
+ method: 'POST',
176
+ body: { client_name: CLIENT_NAME, token_name: TOKEN_NAME },
177
+ })
178
+ if (env?.success !== true) throw new Error('站点拒绝发起设备授权:' + (env?.message || '未知原因'))
179
+ const d = env.data ?? {}
180
+ if (!d.device_code || !d.user_code || !d.verification_uri) {
181
+ throw new Error('设备授权应答缺少 device_code / user_code / verification_uri')
182
+ }
183
+ pending = {
184
+ deviceCode: d.device_code,
185
+ interval: Math.max(1, Number(d.interval) || 5) * 1000,
186
+ expiresAt: Date.now() + (Number(d.expires_in) || 600) * 1000,
187
+ }
188
+ return {
189
+ url: d.verification_uri_complete || d.verification_uri,
190
+ instructions: `在站点上确认这个码:${d.user_code}(登录用你平时的方式,插件不收密码)`,
191
+ }
192
+ },
193
+ /** 轮询结果,直到批准、拒绝或过期。 */
194
+ async callback() {
195
+ if (!pending) throw new Error('没有进行中的授权,请重新开始')
196
+ while (Date.now() < pending.expiresAt) {
197
+ await new Promise((r) => setTimeout(r, pending.interval))
198
+ const env = await call('/api/dsh/device/token', { method: 'POST', body: { device_code: pending.deviceCode } })
199
+ if (env?.success === true) {
200
+ const grant = grantOf(env.data)
201
+ pending = null
202
+ return grant
203
+ }
204
+ switch (env?.message) {
205
+ case 'authorization_pending':
206
+ continue
207
+ case 'slow_down':
208
+ pending.interval += 2000
209
+ continue
210
+ case 'access_denied':
211
+ pending = null
212
+ throw new Error('站点上拒绝了这次授权')
213
+ case 'expired_token':
214
+ pending = null
215
+ throw new Error('授权已过期,请重新连接')
216
+ default:
217
+ throw new Error('设备授权轮询返回了未知状态:' + (env?.message || '(空)'))
218
+ }
219
+ }
220
+ pending = null
221
+ throw new Error('授权已过期,请重新连接')
222
+ },
223
+ },
224
+ ],
225
+ /** 余额:站点钱包端点,按 magpie 的用量卡显示。 */
226
+ async usage(getAuth) {
227
+ const auth = await getAuth()
228
+ const key = auth?.access || auth?.key
229
+ if (!key) throw new Error('还没有连接 NearToken')
230
+ const [cur, env] = await Promise.all([currency(), call('/api/dsh/device/wallet', { key })])
231
+ if (env?.success !== true) throw new Error('读取余额失败:' + (env?.message || '站点拒绝'))
232
+ const d = env.data ?? {}
233
+ const used = Number(d.used_quota) || 0
234
+ return {
235
+ user: typeof d.username === 'string' ? d.username : '',
236
+ balance: text(Number(d.quota) || 0, cur),
237
+ windows: [{ name: '已用', display: text(used, cur) }],
238
+ }
239
+ },
240
+ /** 请求带着这把钥匙出去;分组头由每个模型自己带(见 catalog)。 */
241
+ async loader(getAuth) {
242
+ const auth = await getAuth()
243
+ const key = auth?.access || auth?.key || ''
244
+ return {
245
+ apiKey: key,
246
+ baseURL: SITE + '/v1',
247
+ fetch: async (input, init) => {
248
+ // 保险:模型名里若还带着 `#ref::` 前缀,拆掉并补上头
249
+ const body = init?.body
250
+ if (typeof body === 'string') {
251
+ try {
252
+ const parsed = JSON.parse(body)
253
+ const raw = parsed?.model
254
+ if (typeof raw === 'string' && raw.includes('::')) {
255
+ const [ref, model] = raw.split('::')
256
+ parsed.model = model
257
+ const headers = new Headers(init?.headers || {})
258
+ if (ref.startsWith('#')) headers.set('x-neartoken-group', ref)
259
+ return fetch(input, { ...init, body: JSON.stringify(parsed), headers })
260
+ }
261
+ } catch {
262
+ /* 不是 JSON 就原样发 */
263
+ }
264
+ }
265
+ return fetch(input, init)
266
+ },
267
+ }
268
+ },
269
+ },
270
+ provider: {
271
+ id: PROVIDER_ID,
272
+ /**
273
+ * 模型表:一个「分组 × 模型」一行。
274
+ * @returns {Promise<Record<string, object>>}
275
+ */
276
+ async models() {
277
+ return catalog()
278
+ },
279
+ },
280
+ }
281
+ }
package/package.json CHANGED
@@ -1,17 +1,25 @@
1
1
  {
2
2
  "name": "@fish-under-sea/magpie-neartoken",
3
- "version": "0.1.5",
4
- "description": "NearToken for magpie: sign in to neartoken.cc from a page of magpie's own window, see the balance, vouchers and spending, top up without leaving it, and let magpie pick the models as it does for any provider.",
3
+ "version": "0.1.7",
4
+ "description": "NearToken for magpie: a provider whose sign-in is the site's own device authorization, models listed per billing group with their multiplier, balance and vouchers on magpie's usage card, plus a page for spending and top-ups.",
5
5
  "type": "module",
6
+ "main": "index.mjs",
7
+ "exports": {
8
+ ".": "./index.mjs",
9
+ "./page": "./page/index.html"
10
+ },
6
11
  "license": "MIT",
7
12
  "files": [
13
+ "index.mjs",
8
14
  "page",
9
15
  "README.md",
10
16
  "LICENSE"
11
17
  ],
12
18
  "keywords": [
13
19
  "magpie",
20
+ "magpie-provider",
14
21
  "magpie-page",
22
+ "opencode-plugin",
15
23
  "neartoken",
16
24
  "new-api",
17
25
  "relay"