@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.
- package/README.md +14 -1
- package/index.mjs +281 -0
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -22,12 +22,25 @@
|
|
|
22
22
|
| 余额、已用、代金券 | 站点钱包端点,用连接签下来的密钥读 |
|
|
23
23
|
| 消耗看板(日 / 周 / 月) | 站点用量端点,按模型堆叠 |
|
|
24
24
|
| 充值 | 站点充值端点,支付页交给系统浏览器 |
|
|
25
|
-
| 模型目录 | 站点 `/api/pricing
|
|
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.
|
|
4
|
-
"description": "NearToken for magpie:
|
|
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"
|