koishi-plugin-query-balance 0.0.0-stage → 0.1.0
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 +16 -2
- package/package.json +32 -4
- package/src/index.ts +270 -0
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# API 余额查询
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
仅自用插件。目前主要适配蓝钦与 DeepSeek 的 API 余额和用量查询。
|
|
4
|
+
|
|
5
|
+
## 支持接口
|
|
6
|
+
|
|
7
|
+
- `usage`:请求配置的 API 基础地址下的 `/v1/usage`。
|
|
8
|
+
- `deepseek`:请求配置的 API 基础地址下的 `/user/balance`。
|
|
9
|
+
|
|
10
|
+
## 命令
|
|
11
|
+
|
|
12
|
+
- `api.day [账号]`:查询今日余额与用量。
|
|
13
|
+
- `api.month [账号]`:查询本月余额与用量。
|
|
14
|
+
|
|
15
|
+
账号名称来自插件配置中的 `accounts`。API 密钥通过 Koishi 配置的 secret 字段填写,或使用环境变量 `OPENAI_API_KEY` 作为默认账号的初始值。不要把真实 API 密钥提交到版本库、聊天记录或公开日志中。
|
|
16
|
+
|
|
17
|
+
插件依赖 Koishi 的 `http` 服务,不新增第三方运行时依赖。
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koishi-plugin-query-balance",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "仅自用:目前主要适配蓝钦与 DeepSeek 的 API 余额和用量查询",
|
|
5
|
+
"koishi": {
|
|
6
|
+
"description": {
|
|
7
|
+
"zh": "仅自用:目前主要适配蓝钦与 DeepSeek 的 API 余额和用量查询",
|
|
8
|
+
"en": "Personal use only: API balance and usage queries, currently focused on Lanqin and DeepSeek"
|
|
9
|
+
},
|
|
10
|
+
"service": {
|
|
11
|
+
"required": [
|
|
12
|
+
"http"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"koishi": "^4.18.7"
|
|
18
|
+
},
|
|
19
|
+
"main": "src/index.ts",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"koishi",
|
|
27
|
+
"koishi-plugin",
|
|
28
|
+
"api",
|
|
29
|
+
"balance",
|
|
30
|
+
"usage",
|
|
31
|
+
"lanqin",
|
|
32
|
+
"deepseek"
|
|
33
|
+
]
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi'
|
|
2
|
+
|
|
3
|
+
export const name = 'query-balance'
|
|
4
|
+
export const inject = ['http']
|
|
5
|
+
|
|
6
|
+
export interface AccountConfig {
|
|
7
|
+
name: string
|
|
8
|
+
type: 'usage' | 'deepseek'
|
|
9
|
+
baseUrl: string
|
|
10
|
+
apiKey?: string
|
|
11
|
+
proxy?: string
|
|
12
|
+
noProxy?: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Config {
|
|
16
|
+
accounts: AccountConfig[]
|
|
17
|
+
timeout: number
|
|
18
|
+
timezone: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const AccountConfig: Schema<AccountConfig> = Schema.object({
|
|
22
|
+
name: Schema.string().default('默认账号').description('API 账号昵称。'),
|
|
23
|
+
type: Schema.union(['usage', 'deepseek']).role('radio').default('usage').description('接口类型。usage 为 /v1/usage,中转站;deepseek 为 DeepSeek 官方余额接口。'),
|
|
24
|
+
baseUrl: Schema.string().default('https://api.777358.xyz').description('API 基础地址。'),
|
|
25
|
+
apiKey: Schema.string().role('secret').description('API 密钥。'),
|
|
26
|
+
proxy: Schema.string().description('该账号使用的代理地址。'),
|
|
27
|
+
noProxy: Schema.array(String).default([]).description('该账号不使用代理的主机名或地址。'),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const Config: Schema<Config> = Schema.object({
|
|
31
|
+
accounts: Schema.array(AccountConfig).default([{
|
|
32
|
+
name: '默认账号',
|
|
33
|
+
type: 'usage',
|
|
34
|
+
baseUrl: 'https://api.777358.xyz',
|
|
35
|
+
apiKey: process.env.OPENAI_API_KEY || '',
|
|
36
|
+
proxy: '',
|
|
37
|
+
noProxy: [],
|
|
38
|
+
}]).description('可查询的 API 账号列表。'),
|
|
39
|
+
timeout: Schema.natural().role('ms').default(15000).description('请求超时时间。'),
|
|
40
|
+
timezone: Schema.string().default('Asia/Shanghai').description('月度统计使用的时区。'),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
type RecordValue = Record<string, unknown>
|
|
44
|
+
type CommandSession = {
|
|
45
|
+
send: (value: string) => Promise<unknown>
|
|
46
|
+
prompt?: (timeout?: number) => Promise<string | undefined>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is RecordValue {
|
|
50
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildUsageUrl(baseUrl: string) {
|
|
54
|
+
const normalized = baseUrl.trim().replace(/`/g, '').replace(/\/+$/, '')
|
|
55
|
+
return `${normalized.endsWith('/v1') ? normalized.slice(0, -3).replace(/\/+$/, '') : normalized}/v1/usage`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildDeepSeekBalanceUrl(baseUrl: string) {
|
|
59
|
+
const normalized = baseUrl.trim().replace(/`/g, '').replace(/\/+$/, '')
|
|
60
|
+
return `${normalized.replace(/\/v1$/, '')}/user/balance`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getNumber(value: unknown): number | undefined {
|
|
64
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
65
|
+
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getBalance(data: RecordValue) {
|
|
70
|
+
const quota = isRecord(data.quota) ? data.quota : undefined
|
|
71
|
+
const remaining = getNumber(quota?.remaining) ?? getNumber(quota?.balance) ?? getNumber(data.remaining) ?? getNumber(data.balance)
|
|
72
|
+
const unit = String(quota?.unit ?? data.unit ?? 'USD')
|
|
73
|
+
return { remaining, unit }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getDeepSeekBalances(data: RecordValue) {
|
|
77
|
+
return Array.isArray(data.balance_infos) ? data.balance_infos.filter(isRecord) : []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatNumber(value: unknown) {
|
|
81
|
+
const number = getNumber(value)
|
|
82
|
+
if (number === undefined) return value == null ? '-' : String(value)
|
|
83
|
+
return Number.isInteger(number) ? number.toLocaleString('en-US') : number.toLocaleString('en-US', { maximumFractionDigits: 6 })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatUsage(value: unknown, unit: string) {
|
|
87
|
+
if (!isRecord(value)) return `用量:${formatNumber(value)}${unit ? ` ${unit}` : ''}`
|
|
88
|
+
const labels: Record<string, string> = {
|
|
89
|
+
actual_cost: '实际费用',
|
|
90
|
+
cache_creation_tokens: '缓存创建令牌数',
|
|
91
|
+
cache_hit_rate: '缓存命中率',
|
|
92
|
+
cache_read_tokens: '缓存读取令牌数',
|
|
93
|
+
cache_write_tokens: '缓存写入令牌数',
|
|
94
|
+
cost: '费用',
|
|
95
|
+
input_tokens: '输入令牌数',
|
|
96
|
+
output_tokens: '输出令牌数',
|
|
97
|
+
requests: '请求次数',
|
|
98
|
+
total_cost: '总费用',
|
|
99
|
+
total_tokens: '总令牌数',
|
|
100
|
+
}
|
|
101
|
+
const lines = Object.entries(value)
|
|
102
|
+
.filter(([, item]) => item !== undefined && item !== null)
|
|
103
|
+
.map(([key, item]) => {
|
|
104
|
+
if (key === 'cache_hit_rate') {
|
|
105
|
+
const rate = getNumber(item)
|
|
106
|
+
return `${labels[key]}:${rate === undefined ? formatNumber(item) : `${formatNumber(rate * 100)}%`}`
|
|
107
|
+
}
|
|
108
|
+
return `${labels[key] || key}:${formatNumber(item)}${['cost', 'actual_cost', 'amount', 'total_cost'].includes(key) ? ` ${unit}` : ''}`
|
|
109
|
+
})
|
|
110
|
+
return lines.length ? lines.join('\n') : '用量:-'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getDateKey(value: unknown) {
|
|
114
|
+
if (typeof value !== 'string' && typeof value !== 'number') return undefined
|
|
115
|
+
const date = new Date(value)
|
|
116
|
+
if (Number.isNaN(date.getTime())) return undefined
|
|
117
|
+
return date
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getMonthKey(date: Date, timezone: string) {
|
|
121
|
+
const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit' }).formatToParts(date)
|
|
122
|
+
const year = parts.find(item => item.type === 'year')?.value
|
|
123
|
+
const month = parts.find(item => item.type === 'month')?.value
|
|
124
|
+
return year && month ? `${year}-${month}` : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function aggregateMonth(items: unknown[], timezone: string) {
|
|
128
|
+
const month = getMonthKey(new Date(), timezone)
|
|
129
|
+
const result: RecordValue = {}
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
if (!isRecord(item)) continue
|
|
132
|
+
const date = getDateKey(item.date ?? item.day ?? item.timestamp)
|
|
133
|
+
if (!date || getMonthKey(date, timezone) !== month) continue
|
|
134
|
+
for (const [key, value] of Object.entries(item)) {
|
|
135
|
+
if (['date', 'day', 'timestamp'].includes(key)) continue
|
|
136
|
+
const number = getNumber(value)
|
|
137
|
+
if (number !== undefined) result[key] = (getNumber(result[key]) ?? 0) + number
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getMonthlyUsage(data: RecordValue, timezone: string) {
|
|
144
|
+
const usage = isRecord(data.usage) ? data.usage : undefined
|
|
145
|
+
if (usage?.month !== undefined) return usage.month
|
|
146
|
+
const daily = Array.isArray(data.daily_usage) ? data.daily_usage : []
|
|
147
|
+
return aggregateMonth(daily, timezone)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isNoProxy(url: string, noProxy: string[]) {
|
|
151
|
+
const hostname = new URL(url).hostname
|
|
152
|
+
return noProxy.some(pattern => {
|
|
153
|
+
const value = pattern.trim().toLowerCase()
|
|
154
|
+
if (!value) return false
|
|
155
|
+
if (value === '*') return true
|
|
156
|
+
const host = hostname.toLowerCase()
|
|
157
|
+
return host === value || (value.startsWith('.') ? host.endsWith(value) : host.endsWith(`.${value}`))
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function errorText(error: unknown) {
|
|
162
|
+
if (error instanceof Error && error.name === 'AbortError') return '请求超时,请稍后重试。'
|
|
163
|
+
if (isRecord(error) && isRecord(error.response)) {
|
|
164
|
+
const status = getNumber(error.response.status)
|
|
165
|
+
return status ? `接口请求失败(HTTP ${status})。` : '接口请求失败。'
|
|
166
|
+
}
|
|
167
|
+
if (error instanceof Error && /timeout|ETIMEDOUT/i.test(error.message)) return '请求超时,请稍后重试。'
|
|
168
|
+
return '无法连接余额接口,请检查地址、网络和配置。'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getAccounts(config: Config) {
|
|
172
|
+
return (config.accounts || []).filter(account => account && account.baseUrl?.trim())
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveAccount(accounts: AccountConfig[], input?: string) {
|
|
176
|
+
const value = input?.trim()
|
|
177
|
+
if (!value && accounts.length === 1) return accounts[0]
|
|
178
|
+
if (!value) return undefined
|
|
179
|
+
const index = Number(value)
|
|
180
|
+
if (Number.isInteger(index) && index >= 1 && index <= accounts.length) return accounts[index - 1]
|
|
181
|
+
const exact = accounts.find(account => account.name === value)
|
|
182
|
+
if (exact) return exact
|
|
183
|
+
const matched = accounts.filter(account => account.name.includes(value))
|
|
184
|
+
return matched.length === 1 ? matched[0] : undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function accountList(accounts: AccountConfig[]) {
|
|
188
|
+
return accounts.map((account, index) => `${index + 1}. ${account.name || `账号${index + 1}`}`).join('\n')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function apply(ctx: Context, config: Config) {
|
|
192
|
+
const request = async (account: AccountConfig) => {
|
|
193
|
+
const apiKey = account.apiKey || process.env.OPENAI_API_KEY
|
|
194
|
+
if (!apiKey) throw new Error('未配置 API 密钥。')
|
|
195
|
+
const url = account.type === 'deepseek' ? buildDeepSeekBalanceUrl(account.baseUrl || 'https://api.deepseek.com') : buildUsageUrl(account.baseUrl)
|
|
196
|
+
const requestConfig: Record<string, unknown> = {
|
|
197
|
+
timeout: config.timeout,
|
|
198
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
|
|
199
|
+
}
|
|
200
|
+
if (account.proxy && !isNoProxy(url, account.noProxy || [])) requestConfig.proxyAgent = account.proxy
|
|
201
|
+
return ctx.http.get<RecordValue>(url, requestConfig as never)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const chooseAccounts = async (session: CommandSession | undefined, input?: string) => {
|
|
205
|
+
const accounts = getAccounts(config)
|
|
206
|
+
if (!accounts.length) throw new Error('未配置 API 账号。')
|
|
207
|
+
if (!input?.trim()) return accounts
|
|
208
|
+
const account = resolveAccount(accounts, input)
|
|
209
|
+
if (account) return [account]
|
|
210
|
+
if (!session?.prompt) throw new Error(`未找到账号,请使用账号序号或昵称。\n${accountList(accounts)}`)
|
|
211
|
+
await session.send(`请选择 API 账号:\n${accountList(accounts)}`)
|
|
212
|
+
const answer = await session.prompt(30000)
|
|
213
|
+
const selected = resolveAccount(accounts, answer)
|
|
214
|
+
if (selected) return [selected]
|
|
215
|
+
throw new Error(`未选择有效账号,请使用账号序号或昵称。\n${accountList(accounts)}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const queryAccount = async (account: AccountConfig, monthly: boolean) => {
|
|
219
|
+
try {
|
|
220
|
+
const data = await request(account)
|
|
221
|
+
if (account.type === 'deepseek') {
|
|
222
|
+
const balances = getDeepSeekBalances(data)
|
|
223
|
+
const lines = balances.length
|
|
224
|
+
? balances.flatMap(balance => [
|
|
225
|
+
`${String(balance.currency || '未知币种')} 总余额:${formatNumber(balance.total_balance)}`,
|
|
226
|
+
`${String(balance.currency || '未知币种')} 赠金余额:${formatNumber(balance.granted_balance)}`,
|
|
227
|
+
`${String(balance.currency || '未知币种')} 充值余额:${formatNumber(balance.topped_up_balance)}`,
|
|
228
|
+
])
|
|
229
|
+
: ['余额信息:-']
|
|
230
|
+
return [
|
|
231
|
+
`账号:${account.name || '-'}`,
|
|
232
|
+
`状态:${data.is_available === false ? '不可用' : '可用'}`,
|
|
233
|
+
...lines,
|
|
234
|
+
].join('\n')
|
|
235
|
+
}
|
|
236
|
+
const { remaining, unit } = getBalance(data)
|
|
237
|
+
const usage = monthly ? getMonthlyUsage(data, config.timezone) : isRecord(data.usage) ? data.usage.today : undefined
|
|
238
|
+
const label = monthly ? '本月用量' : '今日用量'
|
|
239
|
+
const status = data.isValid ?? data.is_active
|
|
240
|
+
return [
|
|
241
|
+
`账号:${account.name || '-'}`,
|
|
242
|
+
`余额:${formatNumber(remaining)} ${unit}`,
|
|
243
|
+
`状态:${status === false ? '无效' : '有效'}`,
|
|
244
|
+
`${label}:`,
|
|
245
|
+
formatUsage(usage, unit),
|
|
246
|
+
].join('\n')
|
|
247
|
+
} catch (error) {
|
|
248
|
+
const message = error instanceof Error && error.message === '未配置 API 密钥。' ? error.message : errorText(error)
|
|
249
|
+
return `账号:${account.name || '-'}\n余额查询失败:${message}`
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const report = async (session: CommandSession | undefined, monthly: boolean, input?: string) => {
|
|
254
|
+
try {
|
|
255
|
+
const accounts = await chooseAccounts(session, input)
|
|
256
|
+
const reports = await Promise.all(accounts.map(account => queryAccount(account, monthly)))
|
|
257
|
+
return [monthly ? 'API 月度余额查询' : 'API 今日余额查询', ...reports].join('\n\n')
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const message = error instanceof Error && ['未配置 API 密钥。', '未配置 API 账号。'].includes(error.message)
|
|
260
|
+
? error.message
|
|
261
|
+
: error instanceof Error && error.message.includes('未找到账号') || error instanceof Error && error.message.includes('未选择有效账号')
|
|
262
|
+
? error.message
|
|
263
|
+
: errorText(error)
|
|
264
|
+
return `余额查询失败:${message}`
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
ctx.command('api.day [account:text]', '查询今日 API 余额与用量').action(({ session }, account) => report(session, false, account))
|
|
269
|
+
ctx.command('api.month [account:text]', '查询本月 API 余额与用量').action(({ session }, account) => report(session, true, account))
|
|
270
|
+
}
|