@fish-under-sea/magpie-neartoken 0.1.6 → 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 +14 -1
  2. package/index.mjs +281 -0
  3. package/package.json +10 -2
package/README.md CHANGED
@@ -22,12 +22,25 @@
22
22
  | 余额、已用、代金券 | 站点钱包端点,用连接签下来的密钥读 |
23
23
  | 消耗看板(日 / 周 / 月) | 站点用量端点,按模型堆叠 |
24
24
  | 充值 | 站点充值端点,支付页交给系统浏览器 |
25
- | 模型目录 | 站点 `/api/pricing`,写进 magpie 的 `neartoken` 供应商 |
25
+ | 模型目录 | 站点 `/api/pricing` |
26
26
 
27
27
  **账号只有一个。** 不支持手动粘贴密钥,也不支持多账号:连接由页面发起,断开在页面点。
28
28
 
29
29
  登录全在站点自己页面上完成——密码、GitHub、Discord、LinuxDo、OIDC、Telegram、微信、Passkey 都走站点原生流程;插件不收密码,也不存密码。
30
30
 
31
+ ## 模型:按分组展开,带倍率
32
+
33
+ 包里的 `index.mjs` 同时是一个 magpie 的 provider 插件(`auth` + `provider` + `usage`),所以模型不用 magpie 侧代填:
34
+
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`,已用金额单列一行。
41
+
42
+ 模型目录读的是站点的 `/api/pricing`(公开、全量),不用 `/v1/models`——那个端点按令牌分组过滤,很多账号拿到的是空列表,magpie 的「重新获取模型」就会报 `no models listed`。这里不碰它。
43
+
31
44
  ## 页面怎么用
32
45
 
33
46
  1. 装上包,侧边栏「插件」→「NearToken」。
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.6",
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"