dsh-model-usage 0.8.1 → 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 +51 -35
- package/LICENSE +21 -21
- package/README.md +302 -239
- package/cordis.patch.yml +11 -11
- package/docs/screenshots/usage-panel.png +0 -0
- package/icon.svg +7 -7
- package/lib/client.js +1608 -1410
- package/lib/index.js +1083 -1083
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +84 -76
package/lib/index.js
CHANGED
|
@@ -1,1083 +1,1083 @@
|
|
|
1
|
-
// Node half of dsh-model-usage: hosts the usage query for the model the CURRENT
|
|
2
|
-
// CONVERSATION uses and exposes it to the browser half over loopback-only Web
|
|
3
|
-
// routes. The model route is resolved from the Session's `modelSelection`
|
|
4
|
-
// projection (the same fact the model picker shows), the provider's own
|
|
5
|
-
// credential reference is read from the live `llm-pi-ai` / `llm-deepseek`
|
|
6
|
-
// configuration, and the matching usage API is called with that very key.
|
|
7
|
-
// Secrets never leave the host process — the browser only ever receives the
|
|
8
|
-
// normalized payload plus the credential REFERENCE NAME (never a value).
|
|
9
|
-
//
|
|
10
|
-
// Query kinds, chosen from the resolved provider route and its configured
|
|
11
|
-
// `baseURL`:
|
|
12
|
-
// - commandcode: the Command Code private usage API (default origin
|
|
13
|
-
// https://api.commandcode.ai): `/alpha/whoami?limits=1`,
|
|
14
|
-
// `/alpha/billing/credits`, `/alpha/billing/subscriptions`,
|
|
15
|
-
// `/alpha/usage/summary?since=`. This is exactly what `cmd /usage` renders.
|
|
16
|
-
// - deepseek: the official `GET https://api.deepseek.com/user/balance`.
|
|
17
|
-
// - deepseek-account: the signed-in DeepSeek platform wallet, read through
|
|
18
|
-
// `ctx.deepseekAccount` (no API key involved).
|
|
19
|
-
// - opencode-go: the OpenCode Go / Go Plus subscription allowances at
|
|
20
|
-
// `GET {origin}/zen/go/v1/usage` (undocumented; normalized defensively).
|
|
21
|
-
// - openrouter: `GET {origin}/api/v1/key`, plus `/api/v1/credits` when the key
|
|
22
|
-
// carries no limit of its own.
|
|
23
|
-
// - moonshot: `GET {origin}/v1/users/me/balance`.
|
|
24
|
-
// - siliconflow: `GET {origin}/v1/user/info`.
|
|
25
|
-
// - newapi: any other configured OpenAI-compatible route — best effort against
|
|
26
|
-
// the cc-switch New API convention `GET {origin}/api/user/self`.
|
|
27
|
-
//
|
|
28
|
-
// The routes follow the webServer contract (like the official webhook
|
|
29
|
-
// plugins): `ctx.effect(() => ctx.webServer.register({ kind: 'exact', ... }))`.
|
|
30
|
-
// node:http routes carry no global authentication, so every one of ours asks
|
|
31
|
-
// the composition's connection service for a rejection first and answers its
|
|
32
|
-
// 401/403 itself — the same fence the shipped browser-facing routes apply.
|
|
33
|
-
// No Config schema is declared: the plugin loads through a profile junction or
|
|
34
|
-
// an npm install, so everything is node builtins and validation happens at
|
|
35
|
-
// query time (fail loud in the panel).
|
|
36
|
-
// Only `webServer` is a hard dependency; `connection`, `credentials`, `llm`,
|
|
37
|
-
// `settings`, `sessions`, `sessionProjections`, `sessionController`,
|
|
38
|
-
// `agentDefaultModel` and `deepseekAccount` are read through `ctx.get()` on
|
|
39
|
-
// every use: the plugin still mounts — and says what is missing — in a
|
|
40
|
-
// composition that provides fewer services, and a service that activates later
|
|
41
|
-
// is picked up by the next query.
|
|
42
|
-
import { readFileSync } from 'node:fs'
|
|
43
|
-
import { homedir } from 'node:os'
|
|
44
|
-
|
|
45
|
-
export const inject = ['webServer']
|
|
46
|
-
|
|
47
|
-
const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance'
|
|
48
|
-
/** Provider routes that authenticate with the official DeepSeek API key. */
|
|
49
|
-
const DEEPSEEK_PROVIDERS = Object.freeze(['deepseek-official', 'deepseek'])
|
|
50
|
-
const NEW_API_USER_AGENT = 'cc-switch/1.0'
|
|
51
|
-
const NEW_API_QUOTA_SCALE = 500000
|
|
52
|
-
/** Deadline for one usage API call; a wedged provider must not block the next refresh. */
|
|
53
|
-
const QUERY_TIMEOUT_MS = 30000
|
|
54
|
-
|
|
55
|
-
const COMMAND_CODE_PROVIDER = 'commandcode'
|
|
56
|
-
const COMMAND_CODE_BASE = 'https://api.commandcode.ai'
|
|
57
|
-
/** Web host the human-facing usage page lives on (the API host is `api.`). */
|
|
58
|
-
const COMMAND_CODE_WEB_BASE = 'https://commandcode.ai'
|
|
59
|
-
/** Env var older builds of this plugin (and the cc tooling) used. */
|
|
60
|
-
const COMMAND_CODE_LEGACY_ENV_KEY = 'COMMAND_CODE_API_KEY'
|
|
61
|
-
const COMMAND_CODE_AUTH_FILE = '.commandcode/auth.json'
|
|
62
|
-
|
|
63
|
-
/** Plan id → display name (subset relevant to goat and neighbors). */
|
|
64
|
-
const COMMAND_CODE_PLANS = Object.freeze({
|
|
65
|
-
'individual-go': 'Go',
|
|
66
|
-
'individual-goat': 'GOAT',
|
|
67
|
-
'individual-pro': 'Pro',
|
|
68
|
-
'individual-pro-v1': 'Pro',
|
|
69
|
-
'individual-provider': 'Provider',
|
|
70
|
-
'individual-max': 'Max',
|
|
71
|
-
'individual-ultra': 'Ultra',
|
|
72
|
-
'teams-pro': 'Teams Pro',
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
/** Human labels for the panel's 说明 list, keyed by query kind. */
|
|
76
|
-
const KIND_LABELS = Object.freeze({
|
|
77
|
-
commandcode: 'Command Code 用量(窗口 / 信用)',
|
|
78
|
-
deepseek: 'DeepSeek 官方余额',
|
|
79
|
-
'deepseek-account': 'DeepSeek 账户钱包余额',
|
|
80
|
-
'opencode-go': 'OpenCode Go 订阅用量',
|
|
81
|
-
openrouter: 'OpenRouter 额度',
|
|
82
|
-
moonshot: 'Moonshot / Kimi 余额',
|
|
83
|
-
siliconflow: 'SiliconFlow 余额',
|
|
84
|
-
newapi: 'New API 兼容额度',
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
/** Reference names to fall back on when a setup declares no `apiKeyEnv`. */
|
|
88
|
-
const PROVIDER_KEY_REFS = Object.freeze({
|
|
89
|
-
opencode: 'OPENCODE_API_KEY',
|
|
90
|
-
'opencode-go': 'OPENCODE_API_KEY',
|
|
91
|
-
moonshotai: 'MOONSHOT_API_KEY',
|
|
92
|
-
'moonshotai-cn': 'MOONSHOT_API_KEY',
|
|
93
|
-
'kimi-coding': 'KIMI_API_KEY',
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
/** Normalize a configured base URL: trim and strip trailing slashes. */
|
|
97
|
-
function baseUrlOf(raw) {
|
|
98
|
-
return typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Path part of a configured base URL, lowercased — the same host can serve
|
|
103
|
-
* several products (`opencode.ai/zen` vs `opencode.ai/zen/go`), so the path is
|
|
104
|
-
* what separates a queryable subscription from one with no usage API at all.
|
|
105
|
-
* @param raw - configured `baseURL`.
|
|
106
|
-
* @returns the pathname (starting with `/`), or `""`.
|
|
107
|
-
*/
|
|
108
|
-
function pathOf(raw) {
|
|
109
|
-
const base = baseUrlOf(raw)
|
|
110
|
-
if (base === '') return ''
|
|
111
|
-
try {
|
|
112
|
-
return new URL(base).pathname.toLowerCase()
|
|
113
|
-
} catch {
|
|
114
|
-
return ''
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Reduce a configured base URL to its origin (`https://host[:port]`), which is
|
|
120
|
-
* what the billing endpoints hang off — a provider base URL carries the
|
|
121
|
-
* protocol path (`https://api.commandcode.ai/provider/v1`) that usage APIs do
|
|
122
|
-
* not serve.
|
|
123
|
-
* @param raw - configured `baseURL`.
|
|
124
|
-
* @returns the origin, or null when the value is not an absolute http(s) URL.
|
|
125
|
-
*/
|
|
126
|
-
function originOf(raw) {
|
|
127
|
-
const base = baseUrlOf(raw)
|
|
128
|
-
if (base === '') return null
|
|
129
|
-
try {
|
|
130
|
-
const url = new URL(base)
|
|
131
|
-
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
|
132
|
-
return url.origin
|
|
133
|
-
} catch {
|
|
134
|
-
return null
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Lowercased hostname of an origin, or `""`. */
|
|
139
|
-
function hostOf(origin) {
|
|
140
|
-
if (origin === null) return ''
|
|
141
|
-
try {
|
|
142
|
-
return new URL(origin).hostname.toLowerCase()
|
|
143
|
-
} catch {
|
|
144
|
-
return ''
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Derive the conventional credential reference for a provider route — the same
|
|
150
|
-
* name the Models settings page stores a typed key under when the profile does
|
|
151
|
-
* not declare `apiKeyEnv` (`commandcode` → `COMMANDCODE_API_KEY`).
|
|
152
|
-
* @param provider - provider route id.
|
|
153
|
-
* @returns the derived reference name.
|
|
154
|
-
*/
|
|
155
|
-
function deriveKeyRef(provider) {
|
|
156
|
-
return `${String(provider).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Read a nested value by path, tolerating every intermediate being absent.
|
|
161
|
-
* @param root - container value (a settings namespace `value`, usually).
|
|
162
|
-
* @param path - key path such as `['providers', 'commandcode']`.
|
|
163
|
-
* @returns the value at the path, or undefined.
|
|
164
|
-
*/
|
|
165
|
-
function atPath(root, path) {
|
|
166
|
-
let node = root
|
|
167
|
-
for (const key of Array.isArray(path) ? path : []) {
|
|
168
|
-
if (node === null || typeof node !== 'object') return undefined
|
|
169
|
-
node = node[key]
|
|
170
|
-
}
|
|
171
|
-
return node
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Apply the cc-switch New API extractor to a `/api/user/self` response body.
|
|
176
|
-
* @param body - parsed JSON response.
|
|
177
|
-
* @returns the normalized balance, or a readable failure.
|
|
178
|
-
*/
|
|
179
|
-
function extractNewApi(body) {
|
|
180
|
-
if (body === null || typeof body !== 'object' || body.success !== true || body.data == null) {
|
|
181
|
-
return { ok: false, message: typeof body?.message === 'string' && body.message !== '' ? body.message : '查询失败' }
|
|
182
|
-
}
|
|
183
|
-
const quota = Number(body.data.quota)
|
|
184
|
-
const used = Number(body.data.used_quota)
|
|
185
|
-
if (!Number.isFinite(quota) || !Number.isFinite(used)) {
|
|
186
|
-
return { ok: false, message: '返回数据缺少 quota / used_quota 字段' }
|
|
187
|
-
}
|
|
188
|
-
return {
|
|
189
|
-
ok: true,
|
|
190
|
-
value: {
|
|
191
|
-
planName: typeof body.data.group === 'string' && body.data.group !== '' ? body.data.group : '默认套餐',
|
|
192
|
-
remaining: quota / NEW_API_QUOTA_SCALE,
|
|
193
|
-
used: used / NEW_API_QUOTA_SCALE,
|
|
194
|
-
total: (quota + used) / NEW_API_QUOTA_SCALE,
|
|
195
|
-
unit: 'USD',
|
|
196
|
-
},
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Read a Command Code login file (never logged) — the fallback source for the
|
|
202
|
-
* key when the provider's credential reference is not configured.
|
|
203
|
-
* @returns the parsed file, or undefined.
|
|
204
|
-
*/
|
|
205
|
-
function readCommandCodeAuthFile() {
|
|
206
|
-
// auth.json lives in the OS user home (`command-code login` writes it there),
|
|
207
|
-
// never under DSH_HOME.
|
|
208
|
-
const home = homedir()
|
|
209
|
-
if (home === undefined || home === '') return undefined
|
|
210
|
-
try {
|
|
211
|
-
const raw = readFileSync(`${home.replace(/[\\/]+$/, '')}/${COMMAND_CODE_AUTH_FILE}`, 'utf8')
|
|
212
|
-
return JSON.parse(raw)
|
|
213
|
-
} catch {
|
|
214
|
-
return undefined
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Resolve one credential reference to its value plus a readable source name.
|
|
220
|
-
* Resolution is per call, exactly like the LLM adapters: a key stored from the
|
|
221
|
-
* Models page reaches the next query without a restart.
|
|
222
|
-
* @param ctx - host context, with the optional `credentials` service.
|
|
223
|
-
* @param ref - reference name (`COMMANDCODE_API_KEY`).
|
|
224
|
-
* @param provider - provider route, used for the provider-specific fallbacks.
|
|
225
|
-
* @returns `{ value, source }`, or undefined while unconfigured.
|
|
226
|
-
*/
|
|
227
|
-
async function resolveKey(ctx, ref, provider) {
|
|
228
|
-
try {
|
|
229
|
-
const hit = await ctx.get('credentials')?.resolve?.(ref)
|
|
230
|
-
if (hit !== undefined && hit.value !== '') {
|
|
231
|
-
return { value: hit.value, source: `凭据 ${ref}(${hit.source})` }
|
|
232
|
-
}
|
|
233
|
-
} catch {
|
|
234
|
-
// An unusable credential store falls through to the environment probes.
|
|
235
|
-
}
|
|
236
|
-
const fromEnv = process.env[ref]
|
|
237
|
-
if (fromEnv !== undefined && fromEnv.trim() !== '') {
|
|
238
|
-
return { value: fromEnv.trim(), source: `环境变量 ${ref}` }
|
|
239
|
-
}
|
|
240
|
-
if (provider === COMMAND_CODE_PROVIDER) {
|
|
241
|
-
const legacy = process.env[COMMAND_CODE_LEGACY_ENV_KEY]
|
|
242
|
-
if (legacy !== undefined && legacy.trim() !== '') {
|
|
243
|
-
return { value: legacy.trim(), source: `环境变量 ${COMMAND_CODE_LEGACY_ENV_KEY}` }
|
|
244
|
-
}
|
|
245
|
-
const parsed = readCommandCodeAuthFile()
|
|
246
|
-
if (typeof parsed?.apiKey === 'string' && parsed.apiKey !== '') {
|
|
247
|
-
return {
|
|
248
|
-
value: parsed.apiKey,
|
|
249
|
-
source: `~/.commandcode/auth.json${typeof parsed.userName === 'string' ? `(${parsed.userName})` : ''}`,
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
return undefined
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Build a per-request provider-profile lookup: the credential reference,
|
|
258
|
-
* endpoint, and display name the LLM adapter itself uses. The directory
|
|
259
|
-
* supplies the settings address, so a hand-declared route in the profile
|
|
260
|
-
* (`llm-pi-ai.providers.commandcode`) and a shipped one
|
|
261
|
-
* (`llm-deepseek.apiKeyEnv`) both resolve the same way.
|
|
262
|
-
*
|
|
263
|
-
* `settings.describe()` projects every active plugin's configuration and is not
|
|
264
|
-
* cheap, so it is read once per lookup and shared by every provider the request
|
|
265
|
-
* touches.
|
|
266
|
-
* @param ctx - host context carrying optional `llm` and `settings`.
|
|
267
|
-
* @returns `(provider) => { displayName, apiKeyEnv, baseURL, api, headers }`,
|
|
268
|
-
* with fields absent from the configuration left undefined.
|
|
269
|
-
*/
|
|
270
|
-
function createProfileLookup(ctx) {
|
|
271
|
-
let entries
|
|
272
|
-
let descriptors
|
|
273
|
-
let loaded = false
|
|
274
|
-
const load = () => {
|
|
275
|
-
if (loaded) return
|
|
276
|
-
loaded = true
|
|
277
|
-
try {
|
|
278
|
-
const rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
|
|
279
|
-
entries = Array.isArray(rows) ? rows : []
|
|
280
|
-
} catch {
|
|
281
|
-
entries = []
|
|
282
|
-
}
|
|
283
|
-
try {
|
|
284
|
-
const rows = ctx.get('settings')?.describe?.() ?? []
|
|
285
|
-
descriptors = Array.isArray(rows) ? rows : []
|
|
286
|
-
} catch {
|
|
287
|
-
descriptors = []
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return (provider) => {
|
|
291
|
-
const profile = { displayName: undefined, apiKeyEnv: undefined, baseURL: undefined, api: undefined, headers: undefined }
|
|
292
|
-
load()
|
|
293
|
-
const entry = entries.find((row) => row?.provider === provider)
|
|
294
|
-
if (entry === undefined) return profile
|
|
295
|
-
profile.displayName = typeof entry.displayName === 'string' && entry.displayName !== '' ? entry.displayName : undefined
|
|
296
|
-
const namespace = descriptors.find((row) => row?.ns === entry.settingsNs)
|
|
297
|
-
const configured = atPath(namespace?.value, entry.settingsPath ?? [])
|
|
298
|
-
if (configured === null || typeof configured !== 'object') return profile
|
|
299
|
-
if (typeof configured.displayName === 'string' && configured.displayName !== '') profile.displayName = configured.displayName
|
|
300
|
-
if (typeof configured.apiKeyEnv === 'string' && configured.apiKeyEnv !== '') profile.apiKeyEnv = configured.apiKeyEnv
|
|
301
|
-
if (typeof configured.baseURL === 'string' && configured.baseURL !== '') profile.baseURL = configured.baseURL
|
|
302
|
-
if (typeof configured.api === 'string' && configured.api !== '') profile.api = configured.api
|
|
303
|
-
if (configured.headers !== null && typeof configured.headers === 'object') profile.headers = configured.headers
|
|
304
|
-
return profile
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* The credential reference a route resolves its key from: what the profile
|
|
310
|
-
* declares, else the conventional name — with the official DeepSeek route
|
|
311
|
-
* pinned to its own long-standing variable.
|
|
312
|
-
* @param provider - provider route id.
|
|
313
|
-
* @param profile - resolved provider profile.
|
|
314
|
-
* @returns the reference name, or undefined when the route needs no API key.
|
|
315
|
-
*/
|
|
316
|
-
function keyRefOf(provider, profile) {
|
|
317
|
-
if (profile.apiKeyEnv !== undefined) return profile.apiKeyEnv
|
|
318
|
-
if (provider === 'deepseek-account') return undefined
|
|
319
|
-
if (DEEPSEEK_PROVIDERS.includes(provider)) return 'DEEPSEEK_API_KEY'
|
|
320
|
-
if (PROVIDER_KEY_REFS[provider] !== undefined) return PROVIDER_KEY_REFS[provider]
|
|
321
|
-
return deriveKeyRef(provider)
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Choose the usage API for one route. The configured endpoint wins over the
|
|
326
|
-
* route name, so a renamed deployment still reaches the right service.
|
|
327
|
-
*
|
|
328
|
-
* `opencode.ai` serves two products on one host: Zen (pay-as-you-go, no usage
|
|
329
|
-
* API at all) and Go/Go Plus (a subscription whose `/zen/go/v1/usage` endpoint
|
|
330
|
-
* exists and validates the API key), so the path decides.
|
|
331
|
-
* @param provider - provider route id.
|
|
332
|
-
* @param profile - resolved provider profile.
|
|
333
|
-
* @returns the query kind, or null when no usage API is known for the route.
|
|
334
|
-
*/
|
|
335
|
-
function kindOf(provider, profile) {
|
|
336
|
-
if (provider === 'deepseek-account') return 'deepseek-account'
|
|
337
|
-
const origin = originOf(profile.baseURL)
|
|
338
|
-
const host = hostOf(origin)
|
|
339
|
-
const path = pathOf(profile.baseURL)
|
|
340
|
-
if (host !== '' && (host === 'commandcode.ai' || host.endsWith('.commandcode.ai'))) return 'commandcode'
|
|
341
|
-
if (host !== '' && (host === 'opencode.ai' || host.endsWith('.opencode.ai'))) {
|
|
342
|
-
return path.includes('/zen/go') ? 'opencode-go' : null
|
|
343
|
-
}
|
|
344
|
-
if (host !== '' && (host === 'openrouter.ai' || host.endsWith('.openrouter.ai'))) return 'openrouter'
|
|
345
|
-
if (host !== '' && (host.endsWith('moonshot.cn') || host.endsWith('moonshot.ai'))) return 'moonshot'
|
|
346
|
-
if (host !== '' && (host.endsWith('siliconflow.cn') || host.endsWith('siliconflow.com'))) return 'siliconflow'
|
|
347
|
-
if (host !== '' && (host === 'deepseek.com' || host.endsWith('.deepseek.com'))) return 'deepseek'
|
|
348
|
-
if (provider === COMMAND_CODE_PROVIDER) return 'commandcode'
|
|
349
|
-
if (provider === 'opencode-go') return 'opencode-go'
|
|
350
|
-
if (provider === 'openrouter') return 'openrouter'
|
|
351
|
-
if (provider === 'moonshotai' || provider === 'moonshotai-cn' || provider === 'kimi-coding') return 'moonshot'
|
|
352
|
-
if (provider === 'siliconflow') return 'siliconflow'
|
|
353
|
-
if (DEEPSEEK_PROVIDERS.includes(provider)) return 'deepseek'
|
|
354
|
-
return origin !== null ? 'newapi' : null
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** Fetch one Command Code API endpoint with the shared bearer header. */
|
|
358
|
-
async function commandCodeFetch(base, endpoint, key, signal) {
|
|
359
|
-
const res = await fetch(`${base}${endpoint}`, {
|
|
360
|
-
headers: {
|
|
361
|
-
'content-type': 'application/json',
|
|
362
|
-
authorization: `Bearer ${key}`,
|
|
363
|
-
},
|
|
364
|
-
signal,
|
|
365
|
-
})
|
|
366
|
-
if (!res.ok) {
|
|
367
|
-
throw new Error(`Command Code 接口返回 HTTP ${res.status}`)
|
|
368
|
-
}
|
|
369
|
-
return res.json()
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Build a query string skipping null / undefined / empty values — the API
|
|
374
|
-
* rejects an empty `orgId` (personal accounts have no org), and the CLI's
|
|
375
|
-
* own `buildUsageEndpoint` skips them the same way.
|
|
376
|
-
* @param params - key → value map; falsy scalars are omitted.
|
|
377
|
-
* @returns `?a=1&b=2` or `""`.
|
|
378
|
-
*/
|
|
379
|
-
function queryOf(params) {
|
|
380
|
-
const parts = []
|
|
381
|
-
for (const [key, value] of Object.entries(params)) {
|
|
382
|
-
if (value === null || value === undefined || value === '') continue
|
|
383
|
-
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|
384
|
-
}
|
|
385
|
-
return parts.length > 0 ? `?${parts.join('&')}` : ''
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
* Query the Command Code private usage API — the same four calls the CLI's
|
|
390
|
-
* `/usage` command makes. Returns the normalized usage view.
|
|
391
|
-
* @param base - API origin.
|
|
392
|
-
* @param key - the provider key the conversation itself is using.
|
|
393
|
-
* @param signal - abort signal.
|
|
394
|
-
*/
|
|
395
|
-
async function queryCommandCode(base, key, signal) {
|
|
396
|
-
let whoami
|
|
397
|
-
try {
|
|
398
|
-
whoami = await commandCodeFetch(base, '/alpha/whoami?limits=1', key, signal)
|
|
399
|
-
} catch (err) {
|
|
400
|
-
return { ok: false, message: err instanceof Error ? err.message : String(err) }
|
|
401
|
-
}
|
|
402
|
-
const orgId = whoami?.org?.id ?? null
|
|
403
|
-
let credits = null
|
|
404
|
-
let subscription = null
|
|
405
|
-
let summary = null
|
|
406
|
-
try {
|
|
407
|
-
;[credits, subscription] = await Promise.all([
|
|
408
|
-
commandCodeFetch(base, `/alpha/billing/credits${queryOf({ orgId })}`, key, signal),
|
|
409
|
-
commandCodeFetch(base, `/alpha/billing/subscriptions${queryOf({ orgId })}`, key, signal),
|
|
410
|
-
])
|
|
411
|
-
} catch (err) {
|
|
412
|
-
return { ok: false, message: err instanceof Error ? err.message : String(err) }
|
|
413
|
-
}
|
|
414
|
-
const sub = subscription?.data ?? null
|
|
415
|
-
const periodStart = typeof sub?.currentPeriodStart === 'string' ? sub.currentPeriodStart : null
|
|
416
|
-
const periodEnd = typeof sub?.currentPeriodEnd === 'string' ? Date.parse(sub.currentPeriodEnd) : NaN
|
|
417
|
-
if (periodStart !== null) {
|
|
418
|
-
try {
|
|
419
|
-
summary = await commandCodeFetch(base, `/alpha/usage/summary${queryOf({ orgId, since: periodStart })}`, key, signal)
|
|
420
|
-
} catch {
|
|
421
|
-
// Summary is best-effort; the windows and credits still render.
|
|
422
|
-
summary = null
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
const creditInfo = credits?.credits ?? null
|
|
426
|
-
const monthly = Number(creditInfo?.monthlyCredits ?? 0)
|
|
427
|
-
const purchased = Number(creditInfo?.purchasedCredits ?? 0)
|
|
428
|
-
const free = Number(creditInfo?.freeCredits ?? 0)
|
|
429
|
-
const totalCost = Number(summary?.totalCost ?? 0)
|
|
430
|
-
const totalCount = Number(summary?.totalCount ?? 0)
|
|
431
|
-
const planId = typeof sub?.planId === 'string' ? sub.planId : null
|
|
432
|
-
// windowLimits sits at the credits response top level, beside `credits`.
|
|
433
|
-
const windows = credits?.windowLimits ?? null
|
|
434
|
-
const account = typeof whoami?.user?.userName === 'string' && whoami.user.userName !== ''
|
|
435
|
-
? whoami.user.userName
|
|
436
|
-
: typeof whoami?.user?.name === 'string' ? whoami.user.name : null
|
|
437
|
-
return {
|
|
438
|
-
ok: true,
|
|
439
|
-
value: {
|
|
440
|
-
planName: planId !== null && COMMAND_CODE_PLANS[planId] !== undefined ? COMMAND_CODE_PLANS[planId] : (planId ?? '未知套餐'),
|
|
441
|
-
account,
|
|
442
|
-
status: typeof sub?.status === 'string' ? sub.status : null,
|
|
443
|
-
cycleEnd: Number.isFinite(periodEnd) ? periodEnd : null,
|
|
444
|
-
credits: {
|
|
445
|
-
monthlyRemaining: monthly,
|
|
446
|
-
purchasedRemaining: purchased,
|
|
447
|
-
freeRemaining: free,
|
|
448
|
-
totalRemaining: monthly + purchased + free,
|
|
449
|
-
totalSpent: totalCost,
|
|
450
|
-
totalCount,
|
|
451
|
-
},
|
|
452
|
-
windows: windows === null ? null : {
|
|
453
|
-
limited: windows.limited === true,
|
|
454
|
-
fiveHour: windows.fiveHour != null ? {
|
|
455
|
-
used: Number(windows.fiveHour.used ?? 0),
|
|
456
|
-
cap: Number(windows.fiveHour.cap ?? 0),
|
|
457
|
-
resetAt: Number(windows.fiveHour.resetAt ?? 0),
|
|
458
|
-
} : null,
|
|
459
|
-
weekly: windows.weekly != null ? {
|
|
460
|
-
used: Number(windows.weekly.used ?? 0),
|
|
461
|
-
cap: Number(windows.weekly.cap ?? 0),
|
|
462
|
-
resetAt: Number(windows.weekly.resetAt ?? 0),
|
|
463
|
-
} : null,
|
|
464
|
-
},
|
|
465
|
-
now: Date.now(),
|
|
466
|
-
usageUrl: `${COMMAND_CODE_WEB_BASE}/usage`,
|
|
467
|
-
},
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Query the official DeepSeek balance endpoint with the route's own key.
|
|
473
|
-
* @param url - balance endpoint (the configured origin when it is DeepSeek's).
|
|
474
|
-
* @param key - the key the conversation is using.
|
|
475
|
-
* @param signal - abort signal.
|
|
476
|
-
*/
|
|
477
|
-
async function queryDeepSeek(url, key, signal) {
|
|
478
|
-
const res = await fetch(url, {
|
|
479
|
-
headers: { Authorization: `Bearer ${key}` },
|
|
480
|
-
signal,
|
|
481
|
-
})
|
|
482
|
-
if (!res.ok) {
|
|
483
|
-
return { ok: false, message: `DeepSeek 余额接口返回 HTTP ${res.status}` }
|
|
484
|
-
}
|
|
485
|
-
return { ok: true, value: await res.json() }
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Query a New API deployment's `/api/user/self` (best effort for any other
|
|
490
|
-
* OpenAI-compatible route), carrying the route's configured headers.
|
|
491
|
-
* @param origin - the route's origin.
|
|
492
|
-
* @param key - the key the conversation is using.
|
|
493
|
-
* @param headers - extra headers the profile declares.
|
|
494
|
-
* @param signal - abort signal.
|
|
495
|
-
*/
|
|
496
|
-
async function queryNewApi(origin, key, headers, signal) {
|
|
497
|
-
const res = await fetch(`${origin}/api/user/self`, {
|
|
498
|
-
method: 'GET',
|
|
499
|
-
headers: {
|
|
500
|
-
'Content-Type': 'application/json',
|
|
501
|
-
Authorization: `Bearer ${key}`,
|
|
502
|
-
'User-Agent': NEW_API_USER_AGENT,
|
|
503
|
-
...(headers ?? {}),
|
|
504
|
-
},
|
|
505
|
-
signal,
|
|
506
|
-
})
|
|
507
|
-
if (!res.ok) {
|
|
508
|
-
return { ok: false, message: `New API 余额接口返回 HTTP ${res.status}` }
|
|
509
|
-
}
|
|
510
|
-
const extracted = extractNewApi(await res.json())
|
|
511
|
-
return extracted.ok ? { ok: true, value: extracted.value } : { ok: false, message: extracted.message }
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// ---------------------------------------------------------------------------
|
|
515
|
-
// Per-provider usage adapters. Each one answers the same question in the
|
|
516
|
-
// provider's own vocabulary and normalizes to the panel's two building blocks:
|
|
517
|
-
// `windows` (used / limit / resetsAt meters) and `rows` (label → value lines).
|
|
518
|
-
// Whatever the endpoint actually returned rides along as `raw`, so an endpoint
|
|
519
|
-
// whose shape drifts still shows something readable — and reportable.
|
|
520
|
-
|
|
521
|
-
/** Parse a numeric field that may arrive as a number or a numeric string. */
|
|
522
|
-
function numberOf(value) {
|
|
523
|
-
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
524
|
-
if (typeof value === 'string' && value.trim() !== '') {
|
|
525
|
-
const parsed = Number(value)
|
|
526
|
-
return Number.isFinite(parsed) ? parsed : null
|
|
527
|
-
}
|
|
528
|
-
return null
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** Parse a reset instant that may arrive as epoch milliseconds or an ISO string. */
|
|
532
|
-
function dateOf(value) {
|
|
533
|
-
const numeric = numberOf(value)
|
|
534
|
-
if (numeric !== null) return numeric < 1e11 ? Math.round(numeric * 1000) : numeric
|
|
535
|
-
if (typeof value === 'string') {
|
|
536
|
-
const parsed = Date.parse(value)
|
|
537
|
-
return Number.isFinite(parsed) ? parsed : 0
|
|
538
|
-
}
|
|
539
|
-
return 0
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/** Round a money value for display without inventing precision. */
|
|
543
|
-
function moneyOf(value) {
|
|
544
|
-
const numeric = numberOf(value)
|
|
545
|
-
return numeric === null ? null : numeric
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/** Take the first present value among several candidate field names. */
|
|
549
|
-
function firstOf(node, keys) {
|
|
550
|
-
for (const key of keys) {
|
|
551
|
-
if (node[key] !== undefined && node[key] !== null) return node[key]
|
|
552
|
-
}
|
|
553
|
-
return undefined
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** Fetch one JSON endpoint with the provider's own key. */
|
|
557
|
-
async function fetchUsageJson(url, key, headers, signal, label) {
|
|
558
|
-
const res = await fetch(url, {
|
|
559
|
-
headers: {
|
|
560
|
-
accept: 'application/json',
|
|
561
|
-
authorization: `Bearer ${key}`,
|
|
562
|
-
...(headers ?? {}),
|
|
563
|
-
},
|
|
564
|
-
signal,
|
|
565
|
-
})
|
|
566
|
-
if (!res.ok) {
|
|
567
|
-
return { ok: false, message: `${label} 接口返回 HTTP ${res.status}` }
|
|
568
|
-
}
|
|
569
|
-
try {
|
|
570
|
-
return { ok: true, value: await res.json() }
|
|
571
|
-
} catch {
|
|
572
|
-
return { ok: false, message: `${label} 返回的不是 JSON` }
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
/** One window meter from a node that names its own used / limit / reset. */
|
|
577
|
-
function windowOf(label, node) {
|
|
578
|
-
if (node === null || typeof node !== 'object') return null
|
|
579
|
-
const used = moneyOf(firstOf(node, ['used', 'usage', 'spent', 'cost', 'usedUsd', 'used_usd']))
|
|
580
|
-
const limit = moneyOf(firstOf(node, ['limit', 'cap', 'quota', 'max', 'total', 'monthlyLimit', 'monthly_limit']))
|
|
581
|
-
if (used === null && limit === null) return null
|
|
582
|
-
return {
|
|
583
|
-
label,
|
|
584
|
-
used: used ?? 0,
|
|
585
|
-
limit: limit ?? 0,
|
|
586
|
-
resetsAt: dateOf(firstOf(node, ['resetsAt', 'resetAt', 'reset', 'resets_at', 'reset_at', 'renewsAt', 'periodEnd'])),
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* Normalize the OpenCode Go usage payload. Its exact shape is not documented
|
|
592
|
-
* (verified only as far as `401` without a key), so the candidate field names
|
|
593
|
-
* cover the plausible spellings and anything unrecognized stays visible in
|
|
594
|
-
* `raw`.
|
|
595
|
-
*/
|
|
596
|
-
function normalizeOpenCodeGo(body) {
|
|
597
|
-
const root = body !== null && typeof body === 'object' ? (body.data ?? body) : {}
|
|
598
|
-
const windows = []
|
|
599
|
-
const windowsNode = root.windows ?? root.limits ?? root.usageWindows ?? root.usage_windows ?? root
|
|
600
|
-
for (const [keys, label] of [
|
|
601
|
-
[['fiveHour', 'five_hour', 'fivehour', '5h', 'five_hours'], '5 小时窗口'],
|
|
602
|
-
[['weekly', 'week', '7d', 'sevenDay', 'seven_day'], '7 天窗口'],
|
|
603
|
-
[['monthly', 'month', 'monthlyUsage', 'monthly_usage', '30d'], '月额度'],
|
|
604
|
-
]) {
|
|
605
|
-
const node = firstOf(windowsNode, keys)
|
|
606
|
-
const window = windowOf(label, node)
|
|
607
|
-
if (window !== null) windows.push(window)
|
|
608
|
-
}
|
|
609
|
-
const rows = []
|
|
610
|
-
const plan = firstOf(root, ['plan', 'planName', 'plan_name', 'tier', 'subscription'])
|
|
611
|
-
if (typeof plan === 'string' && plan !== '') rows.push({ label: '套餐', value: plan })
|
|
612
|
-
const status = firstOf(root, ['status', 'state'])
|
|
613
|
-
if (typeof status === 'string' && status !== '') rows.push({ label: '状态', value: status })
|
|
614
|
-
const used = moneyOf(firstOf(root, ['used', 'usage', 'spent', 'cost', 'totalUsed', 'total_used']))
|
|
615
|
-
if (used !== null) rows.push({ label: '已用', value: `$${used.toFixed(2)}` })
|
|
616
|
-
const limit = moneyOf(firstOf(root, ['limit', 'cap', 'quota', 'totalLimit', 'total_limit']))
|
|
617
|
-
if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
|
|
618
|
-
const balance = moneyOf(firstOf(root, ['balance', 'credit', 'credits', 'remaining']))
|
|
619
|
-
if (balance !== null) rows.push({ label: '余额', value: `$${balance.toFixed(2)}` })
|
|
620
|
-
// A per-model breakdown, if this endpoint carries one.
|
|
621
|
-
const modelsNode = firstOf(root, ['models', 'byModel', 'by_model', 'perModel', 'per_model'])
|
|
622
|
-
const models = []
|
|
623
|
-
if (Array.isArray(modelsNode)) {
|
|
624
|
-
for (const entry of modelsNode) {
|
|
625
|
-
if (entry === null || typeof entry !== 'object') continue
|
|
626
|
-
const id = firstOf(entry, ['model', 'id', 'name', 'modelId', 'model_id'])
|
|
627
|
-
const entryUsed = moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost']))
|
|
628
|
-
const entryLimit = moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max']))
|
|
629
|
-
if (id === undefined && entryUsed === null) continue
|
|
630
|
-
models.push({ model: String(id ?? '未知模型'), used: entryUsed, limit: entryLimit })
|
|
631
|
-
}
|
|
632
|
-
} else if (modelsNode !== null && typeof modelsNode === 'object') {
|
|
633
|
-
for (const [id, entry] of Object.entries(modelsNode)) {
|
|
634
|
-
if (entry === null || typeof entry !== 'object') continue
|
|
635
|
-
models.push({
|
|
636
|
-
model: id,
|
|
637
|
-
used: moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost'])),
|
|
638
|
-
limit: moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max'])),
|
|
639
|
-
})
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
return { windows, models, rows, raw: body }
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/** OpenCode Go / Go Plus subscription usage (`/zen/go/v1/usage`). */
|
|
646
|
-
async function queryOpenCodeGo(origin, key, headers, signal) {
|
|
647
|
-
const res = await fetchUsageJson(`${origin}/zen/go/v1/usage`, key, headers, signal, 'OpenCode Go')
|
|
648
|
-
if (!res.ok) return res
|
|
649
|
-
return { ok: true, value: normalizeOpenCodeGo(res.value) }
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/** OpenRouter credits: `/api/v1/key` carries the limit, `/api/v1/credits` the pool. */
|
|
653
|
-
async function queryOpenRouter(origin, key, headers, signal) {
|
|
654
|
-
const res = await fetchUsageJson(`${origin}/api/v1/key`, key, headers, signal, 'OpenRouter')
|
|
655
|
-
if (!res.ok) return res
|
|
656
|
-
const data = res.value?.data ?? {}
|
|
657
|
-
const usage = moneyOf(data.usage) ?? 0
|
|
658
|
-
const limit = moneyOf(data.limit)
|
|
659
|
-
const remaining = moneyOf(data.limit_remaining)
|
|
660
|
-
const rows = [{ label: '已用', value: `$${usage.toFixed(2)}` }]
|
|
661
|
-
if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
|
|
662
|
-
rows.push({ label: '剩余', value: `$${(remaining ?? (limit === null ? 0 : Math.max(0, limit - usage))).toFixed(2)}` })
|
|
663
|
-
if (typeof data.label === 'string' && data.label !== '') rows.push({ label: 'Key', value: data.label })
|
|
664
|
-
if (data.is_free_tier === true) rows.push({ label: '套餐', value: '免费层' })
|
|
665
|
-
const raw = { key: res.value }
|
|
666
|
-
if (limit === null) {
|
|
667
|
-
// A key without its own limit spends from the account credit pool.
|
|
668
|
-
const credits = await fetchUsageJson(`${origin}/api/v1/credits`, key, headers, signal, 'OpenRouter')
|
|
669
|
-
if (credits.ok) {
|
|
670
|
-
const total = moneyOf(credits.value?.data?.total_credits)
|
|
671
|
-
const spent = moneyOf(credits.value?.data?.total_usage)
|
|
672
|
-
if (total !== null) rows.push({ label: '账户总额度', value: `$${total.toFixed(2)}` })
|
|
673
|
-
if (spent !== null) rows.push({ label: '账户已用', value: `$${spent.toFixed(2)}` })
|
|
674
|
-
if (total !== null && spent !== null) rows.push({ label: '账户剩余', value: `$${Math.max(0, total - spent).toFixed(2)}` })
|
|
675
|
-
raw.credits = credits.value
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
return { ok: true, value: { rows, raw } }
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/** Moonshot / Kimi balance: `GET /v1/users/me/balance`. */
|
|
682
|
-
async function queryMoonshot(origin, key, headers, signal) {
|
|
683
|
-
const res = await fetchUsageJson(`${origin}/v1/users/me/balance`, key, headers, signal, 'Moonshot')
|
|
684
|
-
if (!res.ok) return res
|
|
685
|
-
const data = res.value?.data ?? {}
|
|
686
|
-
const rows = []
|
|
687
|
-
const available = moneyOf(data.available_balance ?? data.availableBalance)
|
|
688
|
-
const voucher = moneyOf(data.voucher_balance ?? data.voucherBalance)
|
|
689
|
-
const cash = moneyOf(data.cash_balance ?? data.cashBalance)
|
|
690
|
-
if (available !== null) rows.push({ label: '可用余额', value: `¥${available.toFixed(2)}` })
|
|
691
|
-
if (cash !== null) rows.push({ label: '现金余额', value: `¥${cash.toFixed(2)}` })
|
|
692
|
-
if (voucher !== null) rows.push({ label: '代金券', value: `¥${voucher.toFixed(2)}` })
|
|
693
|
-
if (rows.length === 0) return { ok: false, message: 'Moonshot 接口未返回可识别的余额字段' }
|
|
694
|
-
return { ok: true, value: { rows, raw: res.value } }
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/** SiliconFlow balance: `GET /v1/user/info`. */
|
|
698
|
-
async function querySiliconFlow(origin, key, headers, signal) {
|
|
699
|
-
const res = await fetchUsageJson(`${origin}/v1/user/info`, key, headers, signal, 'SiliconFlow')
|
|
700
|
-
if (!res.ok) return res
|
|
701
|
-
const data = res.value?.data ?? {}
|
|
702
|
-
const rows = []
|
|
703
|
-
const total = moneyOf(data.totalBalance ?? data.total_balance)
|
|
704
|
-
const charge = moneyOf(data.chargeBalance ?? data.charge_balance)
|
|
705
|
-
const gift = moneyOf(data.balance)
|
|
706
|
-
if (total !== null) rows.push({ label: '总余额', value: `¥${total.toFixed(2)}` })
|
|
707
|
-
if (charge !== null) rows.push({ label: '充值余额', value: `¥${charge.toFixed(2)}` })
|
|
708
|
-
if (gift !== null) rows.push({ label: '赠送余额', value: `¥${gift.toFixed(2)}` })
|
|
709
|
-
if (typeof data.name === 'string' && data.name !== '') rows.push({ label: '账户', value: data.name })
|
|
710
|
-
if (rows.length === 0) return { ok: false, message: 'SiliconFlow 接口未返回可识别的余额字段' }
|
|
711
|
-
return { ok: true, value: { rows, raw: res.value } }
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Read the signed-in DeepSeek platform wallet through the account service —
|
|
716
|
-
* the `deepseek-account` route holds no API key at all.
|
|
717
|
-
* @param ctx - host context carrying optional `deepseekAccount`.
|
|
718
|
-
* @returns the normalized wallet view, or a readable failure.
|
|
719
|
-
*/
|
|
720
|
-
async function queryDeepSeekAccount(ctx) {
|
|
721
|
-
const account = ctx.get('deepseekAccount')
|
|
722
|
-
if (account === undefined) {
|
|
723
|
-
return { ok: false, message: '当前部署没有挂载 deepseek-account 服务,无法读取账户钱包' }
|
|
724
|
-
}
|
|
725
|
-
const client = {
|
|
726
|
-
version: '1.0.0',
|
|
727
|
-
locale: 'zh-CN',
|
|
728
|
-
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
729
|
-
}
|
|
730
|
-
const balance = await account.getBalance(client)
|
|
731
|
-
if (balance === null || balance === undefined) {
|
|
732
|
-
return { ok: false, message: '尚未登录 DeepSeek 账户(设置 → 账户里登录后再试)' }
|
|
733
|
-
}
|
|
734
|
-
if (balance.status !== 'ready') {
|
|
735
|
-
return { ok: false, message: 'DeepSeek 账户余额查询失败' }
|
|
736
|
-
}
|
|
737
|
-
const wallets = [...(balance.value ?? []), ...(balance.bonusWallets ?? [])]
|
|
738
|
-
.map((wallet) => ({ currency: wallet.currency, balance: wallet.balance }))
|
|
739
|
-
return { ok: true, value: { wallets } }
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
/**
|
|
743
|
-
* The model selection the current conversation runs on: the browser's copy of
|
|
744
|
-
* the Session's own projection first, then this Host's live projection, then
|
|
745
|
-
* the deployment default.
|
|
746
|
-
* @param ctx - host context carrying optional session services.
|
|
747
|
-
* @param payload - browser payload; `provider` + `model` win when both exist.
|
|
748
|
-
* @returns `{ provider, model, reasoningEffort? }`, or null while unknown.
|
|
749
|
-
*/
|
|
750
|
-
function resolveSelection(ctx, payload) {
|
|
751
|
-
if (typeof payload?.provider === 'string' && payload.provider !== ''
|
|
752
|
-
&& typeof payload?.model === 'string' && payload.model !== '') {
|
|
753
|
-
return {
|
|
754
|
-
provider: payload.provider,
|
|
755
|
-
model: payload.model,
|
|
756
|
-
...(typeof payload?.reasoningEffort === 'string' ? { reasoningEffort: payload.reasoningEffort } : {}),
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
try {
|
|
760
|
-
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : ''
|
|
761
|
-
const session = sessionId === '' ? undefined : ctx.get('sessions')?.get?.(sessionId)
|
|
762
|
-
const state = session === undefined ? undefined : ctx.get('sessionProjections')?.stateOf?.(session, 'modelSelection')
|
|
763
|
-
const selected = state?.pending ?? state?.lastUsed
|
|
764
|
-
if (selected !== null && selected !== undefined && typeof selected.provider === 'string') return selected
|
|
765
|
-
} catch {
|
|
766
|
-
// A missing or cold Session simply falls through to the deployment default.
|
|
767
|
-
}
|
|
768
|
-
const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
|
|
769
|
-
return fallback !== null && fallback !== undefined && typeof fallback.provider === 'string' ? fallback : null
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Run the usage query that belongs to one conversation model.
|
|
774
|
-
* @param ctx - host context.
|
|
775
|
-
* @param signal - abort signal for every outbound call.
|
|
776
|
-
* @param payload - browser payload (model selection and/or session id).
|
|
777
|
-
* @returns the normalized `{ ok, value | error }` envelope.
|
|
778
|
-
*/
|
|
779
|
-
async function queryUsage(ctx, signal, payload) {
|
|
780
|
-
const selection = resolveSelection(ctx, payload)
|
|
781
|
-
if (selection === null) {
|
|
782
|
-
return {
|
|
783
|
-
ok: false,
|
|
784
|
-
error: {
|
|
785
|
-
code: 'internal',
|
|
786
|
-
message: '无法确定当前对话使用的模型:会话还没有过模型选择,请先在对话里选一次模型',
|
|
787
|
-
details: {},
|
|
788
|
-
},
|
|
789
|
-
}
|
|
790
|
-
}
|
|
791
|
-
const { provider } = selection
|
|
792
|
-
const profileOf = createProfileLookup(ctx)
|
|
793
|
-
const profile = profileOf(provider)
|
|
794
|
-
const kind = kindOf(provider, profile)
|
|
795
|
-
const providerName = profile.displayName ?? provider
|
|
796
|
-
const shared = { provider, providerName, model: selection.model, reasoningEffort: selection.reasoningEffort }
|
|
797
|
-
if (kind === null) {
|
|
798
|
-
const configured = originOf(profile.baseURL) !== null
|
|
799
|
-
return {
|
|
800
|
-
ok: false,
|
|
801
|
-
error: {
|
|
802
|
-
code: 'internal',
|
|
803
|
-
message: configured
|
|
804
|
-
? `供应商「${providerName}」没有已适配的用量接口:${profile.baseURL} 不对应任何已知的用量 API`
|
|
805
|
-
: `供应商「${providerName}」既没有配置 baseURL,也不是已知的用量接口,无法查询用量`,
|
|
806
|
-
details: {},
|
|
807
|
-
},
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
const ref = keyRefOf(provider, profile)
|
|
811
|
-
let key
|
|
812
|
-
if (kind !== 'deepseek-account') {
|
|
813
|
-
if (ref === undefined) {
|
|
814
|
-
return {
|
|
815
|
-
ok: false,
|
|
816
|
-
error: { code: 'internal', message: `供应商「${providerName}」没有可用的凭据引用`, details: {} },
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
const resolved = await resolveKey(ctx, ref, provider)
|
|
820
|
-
if (resolved === undefined) {
|
|
821
|
-
return {
|
|
822
|
-
ok: false,
|
|
823
|
-
error: {
|
|
824
|
-
code: 'internal',
|
|
825
|
-
message: `未找到供应商「${providerName}」的 API Key:当前对话用的是 ${ref},请在 设置 → 模型 里填写`,
|
|
826
|
-
details: {},
|
|
827
|
-
},
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
key = resolved
|
|
831
|
-
}
|
|
832
|
-
const credential = ref === undefined
|
|
833
|
-
? null
|
|
834
|
-
: { ref, source: key?.source ?? null }
|
|
835
|
-
let result
|
|
836
|
-
const deadline = withTimeout(signal, QUERY_TIMEOUT_MS)
|
|
837
|
-
try {
|
|
838
|
-
if (kind === 'commandcode') {
|
|
839
|
-
result = await queryCommandCode(originOf(profile.baseURL) ?? COMMAND_CODE_BASE, key.value, deadline)
|
|
840
|
-
} else if (kind === 'deepseek') {
|
|
841
|
-
const origin = originOf(profile.baseURL)
|
|
842
|
-
const host = hostOf(origin)
|
|
843
|
-
const url = host === 'deepseek.com' || host.endsWith('.deepseek.com')
|
|
844
|
-
? `${origin}/user/balance`
|
|
845
|
-
: DEEPSEEK_BALANCE_URL
|
|
846
|
-
result = await queryDeepSeek(url, key.value, deadline)
|
|
847
|
-
} else if (kind === 'deepseek-account') {
|
|
848
|
-
result = await queryDeepSeekAccount(ctx)
|
|
849
|
-
} else if (kind === 'opencode-go') {
|
|
850
|
-
result = await queryOpenCodeGo(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
851
|
-
} else if (kind === 'openrouter') {
|
|
852
|
-
result = await queryOpenRouter(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
853
|
-
} else if (kind === 'moonshot') {
|
|
854
|
-
result = await queryMoonshot(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
855
|
-
} else if (kind === 'siliconflow') {
|
|
856
|
-
result = await querySiliconFlow(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
857
|
-
} else {
|
|
858
|
-
result = await queryNewApi(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
859
|
-
}
|
|
860
|
-
} catch (err) {
|
|
861
|
-
return {
|
|
862
|
-
ok: false,
|
|
863
|
-
error: { code: 'internal', message: queryFailureMessage(err), details: {} },
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
if (!result.ok) {
|
|
867
|
-
return { ok: false, error: { code: 'internal', message: result.message, details: {} } }
|
|
868
|
-
}
|
|
869
|
-
return { ok: true, value: { mode: kind, ...shared, credential, data: result.value } }
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* The read-only provider directory for the panel's 说明 dialog: which routes
|
|
874
|
-
* exist, which credential reference each resolves, and whether that reference
|
|
875
|
-
* is configured. No value ever crosses this boundary.
|
|
876
|
-
* @param ctx - host context.
|
|
877
|
-
* @returns one row per declared provider route.
|
|
878
|
-
*/
|
|
879
|
-
async function providerRows(ctx) {
|
|
880
|
-
const profileOf = createProfileLookup(ctx)
|
|
881
|
-
let rows = []
|
|
882
|
-
try {
|
|
883
|
-
rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
|
|
884
|
-
} catch {
|
|
885
|
-
rows = []
|
|
886
|
-
}
|
|
887
|
-
if (!Array.isArray(rows)) return []
|
|
888
|
-
const out = []
|
|
889
|
-
for (const row of rows) {
|
|
890
|
-
if (row === null || typeof row !== 'object' || typeof row.provider !== 'string') continue
|
|
891
|
-
const profile = profileOf(row.provider)
|
|
892
|
-
const ref = keyRefOf(row.provider, profile)
|
|
893
|
-
const kind = kindOf(row.provider, profile)
|
|
894
|
-
let configured = false
|
|
895
|
-
let source = null
|
|
896
|
-
if (ref !== undefined) {
|
|
897
|
-
try {
|
|
898
|
-
const info = await ctx.get('credentials')?.describe?.(ref)
|
|
899
|
-
configured = info?.configured === true
|
|
900
|
-
source = typeof info?.source === 'string' ? info.source : null
|
|
901
|
-
} catch {
|
|
902
|
-
configured = false
|
|
903
|
-
}
|
|
904
|
-
} else {
|
|
905
|
-
configured = true
|
|
906
|
-
source = '账户登录'
|
|
907
|
-
}
|
|
908
|
-
out.push({
|
|
909
|
-
provider: row.provider,
|
|
910
|
-
displayName: profile.displayName ?? row.displayName ?? row.provider,
|
|
911
|
-
kind,
|
|
912
|
-
kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
|
|
913
|
-
baseUrl: profile.baseURL ?? null,
|
|
914
|
-
credentialRef: ref ?? null,
|
|
915
|
-
configured,
|
|
916
|
-
source,
|
|
917
|
-
})
|
|
918
|
-
}
|
|
919
|
-
return out
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* Bound one outbound call. The panel now refreshes on a timer, so a provider
|
|
924
|
-
* that never answers must not hold the one in-flight slot forever — the caller
|
|
925
|
-
* aborts on close, and this adds a deadline of its own.
|
|
926
|
-
* @param signal - caller cancellation, when there is one.
|
|
927
|
-
* @param ms - deadline in milliseconds.
|
|
928
|
-
* @returns a signal that aborts on either cause.
|
|
929
|
-
*/
|
|
930
|
-
function withTimeout(signal, ms) {
|
|
931
|
-
if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return signal
|
|
932
|
-
const deadline = AbortSignal.timeout(ms)
|
|
933
|
-
if (signal === undefined || typeof AbortSignal.any !== 'function') return deadline
|
|
934
|
-
return AbortSignal.any([signal, deadline])
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/** Turn one adapter failure into a readable message; a deadline reads as a timeout. */
|
|
938
|
-
function queryFailureMessage(err) {
|
|
939
|
-
if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
940
|
-
return `用量接口超时(${Math.round(QUERY_TIMEOUT_MS / 1000)} 秒未返回)`
|
|
941
|
-
}
|
|
942
|
-
return err instanceof Error ? err.message : String(err)
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
/**
|
|
946
|
-
* The routable model catalog, annotated with the usage API each provider route
|
|
947
|
-
* resolves — the data behind the panel's manual model picker. Providers come
|
|
948
|
-
* from the same catalog the model switch uses, so a manual choice is always a
|
|
949
|
-
* route the deployment can actually serve.
|
|
950
|
-
* @param ctx - host context carrying optional `sessionController`.
|
|
951
|
-
* @returns `{ groups, default }`, each group `{ provider, name, kind, kindLabel, models }`.
|
|
952
|
-
*/
|
|
953
|
-
async function modelCatalogRows(ctx) {
|
|
954
|
-
let catalog
|
|
955
|
-
try {
|
|
956
|
-
catalog = await ctx.get('sessionController')?.modelCatalog?.()
|
|
957
|
-
} catch {
|
|
958
|
-
catalog = undefined
|
|
959
|
-
}
|
|
960
|
-
const profileOf = createProfileLookup(ctx)
|
|
961
|
-
const groups = []
|
|
962
|
-
for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
|
|
963
|
-
if (group === null || typeof group !== 'object' || typeof group.id !== 'string' || group.id === '') continue
|
|
964
|
-
const kind = kindOf(group.id, profileOf(group.id))
|
|
965
|
-
groups.push({
|
|
966
|
-
provider: group.id,
|
|
967
|
-
name: typeof group.name === 'string' && group.name !== '' ? group.name : group.id,
|
|
968
|
-
kind,
|
|
969
|
-
kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
|
|
970
|
-
models: (Array.isArray(group.models) ? group.models : []).flatMap((model) => (
|
|
971
|
-
model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== ''
|
|
972
|
-
? [{ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id }]
|
|
973
|
-
: []
|
|
974
|
-
)),
|
|
975
|
-
})
|
|
976
|
-
}
|
|
977
|
-
const selected = catalog?.default
|
|
978
|
-
return {
|
|
979
|
-
groups,
|
|
980
|
-
default: selected !== null && typeof selected === 'object' && typeof selected.provider === 'string'
|
|
981
|
-
? { provider: selected.provider, model: typeof selected.model === 'string' ? selected.model : '' }
|
|
982
|
-
: null,
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
/** Read the entirety of a node:http request body as a UTF-8 string. */
|
|
987
|
-
async function readBody(req) {
|
|
988
|
-
const chunks = []
|
|
989
|
-
for await (const chunk of req) chunks.push(chunk)
|
|
990
|
-
return Buffer.concat(chunks).toString('utf8')
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
/** Write a JSON response with a normalized `{ ok, value | error }` envelope. */
|
|
994
|
-
function writeJson(res, statusCode, value) {
|
|
995
|
-
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' })
|
|
996
|
-
res.end(JSON.stringify(value))
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* Apply the composition's own browser-trust fence to one request. `node:http`
|
|
1001
|
-
* routes are unauthenticated by construction (the web server has no global
|
|
1002
|
-
* policy — every route owner decides), so each of ours asks the connection
|
|
1003
|
-
* service first and answers its 401/403 itself when the request is not from an
|
|
1004
|
-
* accepted authority. A deployment without the service keeps the plugin usable.
|
|
1005
|
-
* @param ctx - host context carrying the optional `connection` service.
|
|
1006
|
-
* @param req - the incoming request.
|
|
1007
|
-
* @param res - the response to answer on when the request is rejected.
|
|
1008
|
-
* @returns true when the request was already rejected and answered.
|
|
1009
|
-
*/
|
|
1010
|
-
function rejected(ctx, req, res) {
|
|
1011
|
-
let rejection
|
|
1012
|
-
try {
|
|
1013
|
-
rejection = ctx.get('connection')?.requestRejection?.(req)
|
|
1014
|
-
} catch {
|
|
1015
|
-
rejection = undefined
|
|
1016
|
-
}
|
|
1017
|
-
if (rejection === undefined) return false
|
|
1018
|
-
res.statusCode = rejection
|
|
1019
|
-
res.end()
|
|
1020
|
-
return true
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
/**
|
|
1024
|
-
* Register the /model-usage/* exact Web routes: the usage query for one
|
|
1025
|
-
* conversation model, the read-only provider directory, and the routable model
|
|
1026
|
-
* catalog behind the manual picker.
|
|
1027
|
-
* @param ctx - host context with webServer, credentials, and the session/LLM
|
|
1028
|
-
* services read through `ctx.get()`.
|
|
1029
|
-
*/
|
|
1030
|
-
export function apply(ctx) {
|
|
1031
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1032
|
-
kind: 'exact',
|
|
1033
|
-
path: '/model-usage/balance',
|
|
1034
|
-
handler: async (req, res) => {
|
|
1035
|
-
if (rejected(ctx, req, res)) return
|
|
1036
|
-
const controller = new AbortController()
|
|
1037
|
-
res.once('close', () => controller.abort())
|
|
1038
|
-
try {
|
|
1039
|
-
const payload = req.method === 'POST'
|
|
1040
|
-
? JSON.parse((await readBody(req)) || '{}')
|
|
1041
|
-
: {}
|
|
1042
|
-
writeJson(res, 200, await queryUsage(ctx, controller.signal, payload))
|
|
1043
|
-
} catch (err) {
|
|
1044
|
-
writeJson(res, 200, {
|
|
1045
|
-
ok: false,
|
|
1046
|
-
error: { code: 'internal', message: `查询失败:${err instanceof Error ? err.message : String(err)}`, details: {} },
|
|
1047
|
-
})
|
|
1048
|
-
}
|
|
1049
|
-
},
|
|
1050
|
-
}), 'model-usage: /model-usage/balance')
|
|
1051
|
-
// Read-only provider/key directory (never a key value), for the 说明 dialog.
|
|
1052
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1053
|
-
kind: 'exact',
|
|
1054
|
-
path: '/model-usage/providers',
|
|
1055
|
-
handler: async (req, res) => {
|
|
1056
|
-
if (rejected(ctx, req, res)) return
|
|
1057
|
-
try {
|
|
1058
|
-
writeJson(res, 200, { ok: true, value: await providerRows(ctx) })
|
|
1059
|
-
} catch (err) {
|
|
1060
|
-
writeJson(res, 200, {
|
|
1061
|
-
ok: false,
|
|
1062
|
-
error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
|
|
1063
|
-
})
|
|
1064
|
-
}
|
|
1065
|
-
},
|
|
1066
|
-
}), 'model-usage: /model-usage/providers')
|
|
1067
|
-
// Routable models (with the usage API each route resolves) for the manual picker.
|
|
1068
|
-
ctx.effect(() => ctx.webServer.register({
|
|
1069
|
-
kind: 'exact',
|
|
1070
|
-
path: '/model-usage/models',
|
|
1071
|
-
handler: async (req, res) => {
|
|
1072
|
-
if (rejected(ctx, req, res)) return
|
|
1073
|
-
try {
|
|
1074
|
-
writeJson(res, 200, { ok: true, value: await modelCatalogRows(ctx) })
|
|
1075
|
-
} catch (err) {
|
|
1076
|
-
writeJson(res, 200, {
|
|
1077
|
-
ok: false,
|
|
1078
|
-
error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
|
|
1079
|
-
})
|
|
1080
|
-
}
|
|
1081
|
-
},
|
|
1082
|
-
}), 'model-usage: /model-usage/models')
|
|
1083
|
-
}
|
|
1
|
+
// Node half of dsh-model-usage: hosts the usage query for the model the CURRENT
|
|
2
|
+
// CONVERSATION uses and exposes it to the browser half over loopback-only Web
|
|
3
|
+
// routes. The model route is resolved from the Session's `modelSelection`
|
|
4
|
+
// projection (the same fact the model picker shows), the provider's own
|
|
5
|
+
// credential reference is read from the live `llm-pi-ai` / `llm-deepseek`
|
|
6
|
+
// configuration, and the matching usage API is called with that very key.
|
|
7
|
+
// Secrets never leave the host process — the browser only ever receives the
|
|
8
|
+
// normalized payload plus the credential REFERENCE NAME (never a value).
|
|
9
|
+
//
|
|
10
|
+
// Query kinds, chosen from the resolved provider route and its configured
|
|
11
|
+
// `baseURL`:
|
|
12
|
+
// - commandcode: the Command Code private usage API (default origin
|
|
13
|
+
// https://api.commandcode.ai): `/alpha/whoami?limits=1`,
|
|
14
|
+
// `/alpha/billing/credits`, `/alpha/billing/subscriptions`,
|
|
15
|
+
// `/alpha/usage/summary?since=`. This is exactly what `cmd /usage` renders.
|
|
16
|
+
// - deepseek: the official `GET https://api.deepseek.com/user/balance`.
|
|
17
|
+
// - deepseek-account: the signed-in DeepSeek platform wallet, read through
|
|
18
|
+
// `ctx.deepseekAccount` (no API key involved).
|
|
19
|
+
// - opencode-go: the OpenCode Go / Go Plus subscription allowances at
|
|
20
|
+
// `GET {origin}/zen/go/v1/usage` (undocumented; normalized defensively).
|
|
21
|
+
// - openrouter: `GET {origin}/api/v1/key`, plus `/api/v1/credits` when the key
|
|
22
|
+
// carries no limit of its own.
|
|
23
|
+
// - moonshot: `GET {origin}/v1/users/me/balance`.
|
|
24
|
+
// - siliconflow: `GET {origin}/v1/user/info`.
|
|
25
|
+
// - newapi: any other configured OpenAI-compatible route — best effort against
|
|
26
|
+
// the cc-switch New API convention `GET {origin}/api/user/self`.
|
|
27
|
+
//
|
|
28
|
+
// The routes follow the webServer contract (like the official webhook
|
|
29
|
+
// plugins): `ctx.effect(() => ctx.webServer.register({ kind: 'exact', ... }))`.
|
|
30
|
+
// node:http routes carry no global authentication, so every one of ours asks
|
|
31
|
+
// the composition's connection service for a rejection first and answers its
|
|
32
|
+
// 401/403 itself — the same fence the shipped browser-facing routes apply.
|
|
33
|
+
// No Config schema is declared: the plugin loads through a profile junction or
|
|
34
|
+
// an npm install, so everything is node builtins and validation happens at
|
|
35
|
+
// query time (fail loud in the panel).
|
|
36
|
+
// Only `webServer` is a hard dependency; `connection`, `credentials`, `llm`,
|
|
37
|
+
// `settings`, `sessions`, `sessionProjections`, `sessionController`,
|
|
38
|
+
// `agentDefaultModel` and `deepseekAccount` are read through `ctx.get()` on
|
|
39
|
+
// every use: the plugin still mounts — and says what is missing — in a
|
|
40
|
+
// composition that provides fewer services, and a service that activates later
|
|
41
|
+
// is picked up by the next query.
|
|
42
|
+
import { readFileSync } from 'node:fs'
|
|
43
|
+
import { homedir } from 'node:os'
|
|
44
|
+
|
|
45
|
+
export const inject = ['webServer']
|
|
46
|
+
|
|
47
|
+
const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance'
|
|
48
|
+
/** Provider routes that authenticate with the official DeepSeek API key. */
|
|
49
|
+
const DEEPSEEK_PROVIDERS = Object.freeze(['deepseek-official', 'deepseek'])
|
|
50
|
+
const NEW_API_USER_AGENT = 'cc-switch/1.0'
|
|
51
|
+
const NEW_API_QUOTA_SCALE = 500000
|
|
52
|
+
/** Deadline for one usage API call; a wedged provider must not block the next refresh. */
|
|
53
|
+
const QUERY_TIMEOUT_MS = 30000
|
|
54
|
+
|
|
55
|
+
const COMMAND_CODE_PROVIDER = 'commandcode'
|
|
56
|
+
const COMMAND_CODE_BASE = 'https://api.commandcode.ai'
|
|
57
|
+
/** Web host the human-facing usage page lives on (the API host is `api.`). */
|
|
58
|
+
const COMMAND_CODE_WEB_BASE = 'https://commandcode.ai'
|
|
59
|
+
/** Env var older builds of this plugin (and the cc tooling) used. */
|
|
60
|
+
const COMMAND_CODE_LEGACY_ENV_KEY = 'COMMAND_CODE_API_KEY'
|
|
61
|
+
const COMMAND_CODE_AUTH_FILE = '.commandcode/auth.json'
|
|
62
|
+
|
|
63
|
+
/** Plan id → display name (subset relevant to goat and neighbors). */
|
|
64
|
+
const COMMAND_CODE_PLANS = Object.freeze({
|
|
65
|
+
'individual-go': 'Go',
|
|
66
|
+
'individual-goat': 'GOAT',
|
|
67
|
+
'individual-pro': 'Pro',
|
|
68
|
+
'individual-pro-v1': 'Pro',
|
|
69
|
+
'individual-provider': 'Provider',
|
|
70
|
+
'individual-max': 'Max',
|
|
71
|
+
'individual-ultra': 'Ultra',
|
|
72
|
+
'teams-pro': 'Teams Pro',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** Human labels for the panel's 说明 list, keyed by query kind. */
|
|
76
|
+
const KIND_LABELS = Object.freeze({
|
|
77
|
+
commandcode: 'Command Code 用量(窗口 / 信用)',
|
|
78
|
+
deepseek: 'DeepSeek 官方余额',
|
|
79
|
+
'deepseek-account': 'DeepSeek 账户钱包余额',
|
|
80
|
+
'opencode-go': 'OpenCode Go 订阅用量',
|
|
81
|
+
openrouter: 'OpenRouter 额度',
|
|
82
|
+
moonshot: 'Moonshot / Kimi 余额',
|
|
83
|
+
siliconflow: 'SiliconFlow 余额',
|
|
84
|
+
newapi: 'New API 兼容额度',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** Reference names to fall back on when a setup declares no `apiKeyEnv`. */
|
|
88
|
+
const PROVIDER_KEY_REFS = Object.freeze({
|
|
89
|
+
opencode: 'OPENCODE_API_KEY',
|
|
90
|
+
'opencode-go': 'OPENCODE_API_KEY',
|
|
91
|
+
moonshotai: 'MOONSHOT_API_KEY',
|
|
92
|
+
'moonshotai-cn': 'MOONSHOT_API_KEY',
|
|
93
|
+
'kimi-coding': 'KIMI_API_KEY',
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
/** Normalize a configured base URL: trim and strip trailing slashes. */
|
|
97
|
+
function baseUrlOf(raw) {
|
|
98
|
+
return typeof raw === 'string' ? raw.trim().replace(/\/+$/, '') : ''
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Path part of a configured base URL, lowercased — the same host can serve
|
|
103
|
+
* several products (`opencode.ai/zen` vs `opencode.ai/zen/go`), so the path is
|
|
104
|
+
* what separates a queryable subscription from one with no usage API at all.
|
|
105
|
+
* @param raw - configured `baseURL`.
|
|
106
|
+
* @returns the pathname (starting with `/`), or `""`.
|
|
107
|
+
*/
|
|
108
|
+
function pathOf(raw) {
|
|
109
|
+
const base = baseUrlOf(raw)
|
|
110
|
+
if (base === '') return ''
|
|
111
|
+
try {
|
|
112
|
+
return new URL(base).pathname.toLowerCase()
|
|
113
|
+
} catch {
|
|
114
|
+
return ''
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reduce a configured base URL to its origin (`https://host[:port]`), which is
|
|
120
|
+
* what the billing endpoints hang off — a provider base URL carries the
|
|
121
|
+
* protocol path (`https://api.commandcode.ai/provider/v1`) that usage APIs do
|
|
122
|
+
* not serve.
|
|
123
|
+
* @param raw - configured `baseURL`.
|
|
124
|
+
* @returns the origin, or null when the value is not an absolute http(s) URL.
|
|
125
|
+
*/
|
|
126
|
+
function originOf(raw) {
|
|
127
|
+
const base = baseUrlOf(raw)
|
|
128
|
+
if (base === '') return null
|
|
129
|
+
try {
|
|
130
|
+
const url = new URL(base)
|
|
131
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
|
|
132
|
+
return url.origin
|
|
133
|
+
} catch {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Lowercased hostname of an origin, or `""`. */
|
|
139
|
+
function hostOf(origin) {
|
|
140
|
+
if (origin === null) return ''
|
|
141
|
+
try {
|
|
142
|
+
return new URL(origin).hostname.toLowerCase()
|
|
143
|
+
} catch {
|
|
144
|
+
return ''
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Derive the conventional credential reference for a provider route — the same
|
|
150
|
+
* name the Models settings page stores a typed key under when the profile does
|
|
151
|
+
* not declare `apiKeyEnv` (`commandcode` → `COMMANDCODE_API_KEY`).
|
|
152
|
+
* @param provider - provider route id.
|
|
153
|
+
* @returns the derived reference name.
|
|
154
|
+
*/
|
|
155
|
+
function deriveKeyRef(provider) {
|
|
156
|
+
return `${String(provider).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Read a nested value by path, tolerating every intermediate being absent.
|
|
161
|
+
* @param root - container value (a settings namespace `value`, usually).
|
|
162
|
+
* @param path - key path such as `['providers', 'commandcode']`.
|
|
163
|
+
* @returns the value at the path, or undefined.
|
|
164
|
+
*/
|
|
165
|
+
function atPath(root, path) {
|
|
166
|
+
let node = root
|
|
167
|
+
for (const key of Array.isArray(path) ? path : []) {
|
|
168
|
+
if (node === null || typeof node !== 'object') return undefined
|
|
169
|
+
node = node[key]
|
|
170
|
+
}
|
|
171
|
+
return node
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Apply the cc-switch New API extractor to a `/api/user/self` response body.
|
|
176
|
+
* @param body - parsed JSON response.
|
|
177
|
+
* @returns the normalized balance, or a readable failure.
|
|
178
|
+
*/
|
|
179
|
+
function extractNewApi(body) {
|
|
180
|
+
if (body === null || typeof body !== 'object' || body.success !== true || body.data == null) {
|
|
181
|
+
return { ok: false, message: typeof body?.message === 'string' && body.message !== '' ? body.message : '查询失败' }
|
|
182
|
+
}
|
|
183
|
+
const quota = Number(body.data.quota)
|
|
184
|
+
const used = Number(body.data.used_quota)
|
|
185
|
+
if (!Number.isFinite(quota) || !Number.isFinite(used)) {
|
|
186
|
+
return { ok: false, message: '返回数据缺少 quota / used_quota 字段' }
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
value: {
|
|
191
|
+
planName: typeof body.data.group === 'string' && body.data.group !== '' ? body.data.group : '默认套餐',
|
|
192
|
+
remaining: quota / NEW_API_QUOTA_SCALE,
|
|
193
|
+
used: used / NEW_API_QUOTA_SCALE,
|
|
194
|
+
total: (quota + used) / NEW_API_QUOTA_SCALE,
|
|
195
|
+
unit: 'USD',
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Read a Command Code login file (never logged) — the fallback source for the
|
|
202
|
+
* key when the provider's credential reference is not configured.
|
|
203
|
+
* @returns the parsed file, or undefined.
|
|
204
|
+
*/
|
|
205
|
+
function readCommandCodeAuthFile() {
|
|
206
|
+
// auth.json lives in the OS user home (`command-code login` writes it there),
|
|
207
|
+
// never under DSH_HOME.
|
|
208
|
+
const home = homedir()
|
|
209
|
+
if (home === undefined || home === '') return undefined
|
|
210
|
+
try {
|
|
211
|
+
const raw = readFileSync(`${home.replace(/[\\/]+$/, '')}/${COMMAND_CODE_AUTH_FILE}`, 'utf8')
|
|
212
|
+
return JSON.parse(raw)
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve one credential reference to its value plus a readable source name.
|
|
220
|
+
* Resolution is per call, exactly like the LLM adapters: a key stored from the
|
|
221
|
+
* Models page reaches the next query without a restart.
|
|
222
|
+
* @param ctx - host context, with the optional `credentials` service.
|
|
223
|
+
* @param ref - reference name (`COMMANDCODE_API_KEY`).
|
|
224
|
+
* @param provider - provider route, used for the provider-specific fallbacks.
|
|
225
|
+
* @returns `{ value, source }`, or undefined while unconfigured.
|
|
226
|
+
*/
|
|
227
|
+
async function resolveKey(ctx, ref, provider) {
|
|
228
|
+
try {
|
|
229
|
+
const hit = await ctx.get('credentials')?.resolve?.(ref)
|
|
230
|
+
if (hit !== undefined && hit.value !== '') {
|
|
231
|
+
return { value: hit.value, source: `凭据 ${ref}(${hit.source})` }
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
// An unusable credential store falls through to the environment probes.
|
|
235
|
+
}
|
|
236
|
+
const fromEnv = process.env[ref]
|
|
237
|
+
if (fromEnv !== undefined && fromEnv.trim() !== '') {
|
|
238
|
+
return { value: fromEnv.trim(), source: `环境变量 ${ref}` }
|
|
239
|
+
}
|
|
240
|
+
if (provider === COMMAND_CODE_PROVIDER) {
|
|
241
|
+
const legacy = process.env[COMMAND_CODE_LEGACY_ENV_KEY]
|
|
242
|
+
if (legacy !== undefined && legacy.trim() !== '') {
|
|
243
|
+
return { value: legacy.trim(), source: `环境变量 ${COMMAND_CODE_LEGACY_ENV_KEY}` }
|
|
244
|
+
}
|
|
245
|
+
const parsed = readCommandCodeAuthFile()
|
|
246
|
+
if (typeof parsed?.apiKey === 'string' && parsed.apiKey !== '') {
|
|
247
|
+
return {
|
|
248
|
+
value: parsed.apiKey,
|
|
249
|
+
source: `~/.commandcode/auth.json${typeof parsed.userName === 'string' ? `(${parsed.userName})` : ''}`,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build a per-request provider-profile lookup: the credential reference,
|
|
258
|
+
* endpoint, and display name the LLM adapter itself uses. The directory
|
|
259
|
+
* supplies the settings address, so a hand-declared route in the profile
|
|
260
|
+
* (`llm-pi-ai.providers.commandcode`) and a shipped one
|
|
261
|
+
* (`llm-deepseek.apiKeyEnv`) both resolve the same way.
|
|
262
|
+
*
|
|
263
|
+
* `settings.describe()` projects every active plugin's configuration and is not
|
|
264
|
+
* cheap, so it is read once per lookup and shared by every provider the request
|
|
265
|
+
* touches.
|
|
266
|
+
* @param ctx - host context carrying optional `llm` and `settings`.
|
|
267
|
+
* @returns `(provider) => { displayName, apiKeyEnv, baseURL, api, headers }`,
|
|
268
|
+
* with fields absent from the configuration left undefined.
|
|
269
|
+
*/
|
|
270
|
+
function createProfileLookup(ctx) {
|
|
271
|
+
let entries
|
|
272
|
+
let descriptors
|
|
273
|
+
let loaded = false
|
|
274
|
+
const load = () => {
|
|
275
|
+
if (loaded) return
|
|
276
|
+
loaded = true
|
|
277
|
+
try {
|
|
278
|
+
const rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
|
|
279
|
+
entries = Array.isArray(rows) ? rows : []
|
|
280
|
+
} catch {
|
|
281
|
+
entries = []
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const rows = ctx.get('settings')?.describe?.() ?? []
|
|
285
|
+
descriptors = Array.isArray(rows) ? rows : []
|
|
286
|
+
} catch {
|
|
287
|
+
descriptors = []
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return (provider) => {
|
|
291
|
+
const profile = { displayName: undefined, apiKeyEnv: undefined, baseURL: undefined, api: undefined, headers: undefined }
|
|
292
|
+
load()
|
|
293
|
+
const entry = entries.find((row) => row?.provider === provider)
|
|
294
|
+
if (entry === undefined) return profile
|
|
295
|
+
profile.displayName = typeof entry.displayName === 'string' && entry.displayName !== '' ? entry.displayName : undefined
|
|
296
|
+
const namespace = descriptors.find((row) => row?.ns === entry.settingsNs)
|
|
297
|
+
const configured = atPath(namespace?.value, entry.settingsPath ?? [])
|
|
298
|
+
if (configured === null || typeof configured !== 'object') return profile
|
|
299
|
+
if (typeof configured.displayName === 'string' && configured.displayName !== '') profile.displayName = configured.displayName
|
|
300
|
+
if (typeof configured.apiKeyEnv === 'string' && configured.apiKeyEnv !== '') profile.apiKeyEnv = configured.apiKeyEnv
|
|
301
|
+
if (typeof configured.baseURL === 'string' && configured.baseURL !== '') profile.baseURL = configured.baseURL
|
|
302
|
+
if (typeof configured.api === 'string' && configured.api !== '') profile.api = configured.api
|
|
303
|
+
if (configured.headers !== null && typeof configured.headers === 'object') profile.headers = configured.headers
|
|
304
|
+
return profile
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The credential reference a route resolves its key from: what the profile
|
|
310
|
+
* declares, else the conventional name — with the official DeepSeek route
|
|
311
|
+
* pinned to its own long-standing variable.
|
|
312
|
+
* @param provider - provider route id.
|
|
313
|
+
* @param profile - resolved provider profile.
|
|
314
|
+
* @returns the reference name, or undefined when the route needs no API key.
|
|
315
|
+
*/
|
|
316
|
+
function keyRefOf(provider, profile) {
|
|
317
|
+
if (profile.apiKeyEnv !== undefined) return profile.apiKeyEnv
|
|
318
|
+
if (provider === 'deepseek-account') return undefined
|
|
319
|
+
if (DEEPSEEK_PROVIDERS.includes(provider)) return 'DEEPSEEK_API_KEY'
|
|
320
|
+
if (PROVIDER_KEY_REFS[provider] !== undefined) return PROVIDER_KEY_REFS[provider]
|
|
321
|
+
return deriveKeyRef(provider)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Choose the usage API for one route. The configured endpoint wins over the
|
|
326
|
+
* route name, so a renamed deployment still reaches the right service.
|
|
327
|
+
*
|
|
328
|
+
* `opencode.ai` serves two products on one host: Zen (pay-as-you-go, no usage
|
|
329
|
+
* API at all) and Go/Go Plus (a subscription whose `/zen/go/v1/usage` endpoint
|
|
330
|
+
* exists and validates the API key), so the path decides.
|
|
331
|
+
* @param provider - provider route id.
|
|
332
|
+
* @param profile - resolved provider profile.
|
|
333
|
+
* @returns the query kind, or null when no usage API is known for the route.
|
|
334
|
+
*/
|
|
335
|
+
function kindOf(provider, profile) {
|
|
336
|
+
if (provider === 'deepseek-account') return 'deepseek-account'
|
|
337
|
+
const origin = originOf(profile.baseURL)
|
|
338
|
+
const host = hostOf(origin)
|
|
339
|
+
const path = pathOf(profile.baseURL)
|
|
340
|
+
if (host !== '' && (host === 'commandcode.ai' || host.endsWith('.commandcode.ai'))) return 'commandcode'
|
|
341
|
+
if (host !== '' && (host === 'opencode.ai' || host.endsWith('.opencode.ai'))) {
|
|
342
|
+
return path.includes('/zen/go') ? 'opencode-go' : null
|
|
343
|
+
}
|
|
344
|
+
if (host !== '' && (host === 'openrouter.ai' || host.endsWith('.openrouter.ai'))) return 'openrouter'
|
|
345
|
+
if (host !== '' && (host.endsWith('moonshot.cn') || host.endsWith('moonshot.ai'))) return 'moonshot'
|
|
346
|
+
if (host !== '' && (host.endsWith('siliconflow.cn') || host.endsWith('siliconflow.com'))) return 'siliconflow'
|
|
347
|
+
if (host !== '' && (host === 'deepseek.com' || host.endsWith('.deepseek.com'))) return 'deepseek'
|
|
348
|
+
if (provider === COMMAND_CODE_PROVIDER) return 'commandcode'
|
|
349
|
+
if (provider === 'opencode-go') return 'opencode-go'
|
|
350
|
+
if (provider === 'openrouter') return 'openrouter'
|
|
351
|
+
if (provider === 'moonshotai' || provider === 'moonshotai-cn' || provider === 'kimi-coding') return 'moonshot'
|
|
352
|
+
if (provider === 'siliconflow') return 'siliconflow'
|
|
353
|
+
if (DEEPSEEK_PROVIDERS.includes(provider)) return 'deepseek'
|
|
354
|
+
return origin !== null ? 'newapi' : null
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Fetch one Command Code API endpoint with the shared bearer header. */
|
|
358
|
+
async function commandCodeFetch(base, endpoint, key, signal) {
|
|
359
|
+
const res = await fetch(`${base}${endpoint}`, {
|
|
360
|
+
headers: {
|
|
361
|
+
'content-type': 'application/json',
|
|
362
|
+
authorization: `Bearer ${key}`,
|
|
363
|
+
},
|
|
364
|
+
signal,
|
|
365
|
+
})
|
|
366
|
+
if (!res.ok) {
|
|
367
|
+
throw new Error(`Command Code 接口返回 HTTP ${res.status}`)
|
|
368
|
+
}
|
|
369
|
+
return res.json()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Build a query string skipping null / undefined / empty values — the API
|
|
374
|
+
* rejects an empty `orgId` (personal accounts have no org), and the CLI's
|
|
375
|
+
* own `buildUsageEndpoint` skips them the same way.
|
|
376
|
+
* @param params - key → value map; falsy scalars are omitted.
|
|
377
|
+
* @returns `?a=1&b=2` or `""`.
|
|
378
|
+
*/
|
|
379
|
+
function queryOf(params) {
|
|
380
|
+
const parts = []
|
|
381
|
+
for (const [key, value] of Object.entries(params)) {
|
|
382
|
+
if (value === null || value === undefined || value === '') continue
|
|
383
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|
384
|
+
}
|
|
385
|
+
return parts.length > 0 ? `?${parts.join('&')}` : ''
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Query the Command Code private usage API — the same four calls the CLI's
|
|
390
|
+
* `/usage` command makes. Returns the normalized usage view.
|
|
391
|
+
* @param base - API origin.
|
|
392
|
+
* @param key - the provider key the conversation itself is using.
|
|
393
|
+
* @param signal - abort signal.
|
|
394
|
+
*/
|
|
395
|
+
async function queryCommandCode(base, key, signal) {
|
|
396
|
+
let whoami
|
|
397
|
+
try {
|
|
398
|
+
whoami = await commandCodeFetch(base, '/alpha/whoami?limits=1', key, signal)
|
|
399
|
+
} catch (err) {
|
|
400
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) }
|
|
401
|
+
}
|
|
402
|
+
const orgId = whoami?.org?.id ?? null
|
|
403
|
+
let credits = null
|
|
404
|
+
let subscription = null
|
|
405
|
+
let summary = null
|
|
406
|
+
try {
|
|
407
|
+
;[credits, subscription] = await Promise.all([
|
|
408
|
+
commandCodeFetch(base, `/alpha/billing/credits${queryOf({ orgId })}`, key, signal),
|
|
409
|
+
commandCodeFetch(base, `/alpha/billing/subscriptions${queryOf({ orgId })}`, key, signal),
|
|
410
|
+
])
|
|
411
|
+
} catch (err) {
|
|
412
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) }
|
|
413
|
+
}
|
|
414
|
+
const sub = subscription?.data ?? null
|
|
415
|
+
const periodStart = typeof sub?.currentPeriodStart === 'string' ? sub.currentPeriodStart : null
|
|
416
|
+
const periodEnd = typeof sub?.currentPeriodEnd === 'string' ? Date.parse(sub.currentPeriodEnd) : NaN
|
|
417
|
+
if (periodStart !== null) {
|
|
418
|
+
try {
|
|
419
|
+
summary = await commandCodeFetch(base, `/alpha/usage/summary${queryOf({ orgId, since: periodStart })}`, key, signal)
|
|
420
|
+
} catch {
|
|
421
|
+
// Summary is best-effort; the windows and credits still render.
|
|
422
|
+
summary = null
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const creditInfo = credits?.credits ?? null
|
|
426
|
+
const monthly = Number(creditInfo?.monthlyCredits ?? 0)
|
|
427
|
+
const purchased = Number(creditInfo?.purchasedCredits ?? 0)
|
|
428
|
+
const free = Number(creditInfo?.freeCredits ?? 0)
|
|
429
|
+
const totalCost = Number(summary?.totalCost ?? 0)
|
|
430
|
+
const totalCount = Number(summary?.totalCount ?? 0)
|
|
431
|
+
const planId = typeof sub?.planId === 'string' ? sub.planId : null
|
|
432
|
+
// windowLimits sits at the credits response top level, beside `credits`.
|
|
433
|
+
const windows = credits?.windowLimits ?? null
|
|
434
|
+
const account = typeof whoami?.user?.userName === 'string' && whoami.user.userName !== ''
|
|
435
|
+
? whoami.user.userName
|
|
436
|
+
: typeof whoami?.user?.name === 'string' ? whoami.user.name : null
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
value: {
|
|
440
|
+
planName: planId !== null && COMMAND_CODE_PLANS[planId] !== undefined ? COMMAND_CODE_PLANS[planId] : (planId ?? '未知套餐'),
|
|
441
|
+
account,
|
|
442
|
+
status: typeof sub?.status === 'string' ? sub.status : null,
|
|
443
|
+
cycleEnd: Number.isFinite(periodEnd) ? periodEnd : null,
|
|
444
|
+
credits: {
|
|
445
|
+
monthlyRemaining: monthly,
|
|
446
|
+
purchasedRemaining: purchased,
|
|
447
|
+
freeRemaining: free,
|
|
448
|
+
totalRemaining: monthly + purchased + free,
|
|
449
|
+
totalSpent: totalCost,
|
|
450
|
+
totalCount,
|
|
451
|
+
},
|
|
452
|
+
windows: windows === null ? null : {
|
|
453
|
+
limited: windows.limited === true,
|
|
454
|
+
fiveHour: windows.fiveHour != null ? {
|
|
455
|
+
used: Number(windows.fiveHour.used ?? 0),
|
|
456
|
+
cap: Number(windows.fiveHour.cap ?? 0),
|
|
457
|
+
resetAt: Number(windows.fiveHour.resetAt ?? 0),
|
|
458
|
+
} : null,
|
|
459
|
+
weekly: windows.weekly != null ? {
|
|
460
|
+
used: Number(windows.weekly.used ?? 0),
|
|
461
|
+
cap: Number(windows.weekly.cap ?? 0),
|
|
462
|
+
resetAt: Number(windows.weekly.resetAt ?? 0),
|
|
463
|
+
} : null,
|
|
464
|
+
},
|
|
465
|
+
now: Date.now(),
|
|
466
|
+
usageUrl: `${COMMAND_CODE_WEB_BASE}/usage`,
|
|
467
|
+
},
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Query the official DeepSeek balance endpoint with the route's own key.
|
|
473
|
+
* @param url - balance endpoint (the configured origin when it is DeepSeek's).
|
|
474
|
+
* @param key - the key the conversation is using.
|
|
475
|
+
* @param signal - abort signal.
|
|
476
|
+
*/
|
|
477
|
+
async function queryDeepSeek(url, key, signal) {
|
|
478
|
+
const res = await fetch(url, {
|
|
479
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
480
|
+
signal,
|
|
481
|
+
})
|
|
482
|
+
if (!res.ok) {
|
|
483
|
+
return { ok: false, message: `DeepSeek 余额接口返回 HTTP ${res.status}` }
|
|
484
|
+
}
|
|
485
|
+
return { ok: true, value: await res.json() }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Query a New API deployment's `/api/user/self` (best effort for any other
|
|
490
|
+
* OpenAI-compatible route), carrying the route's configured headers.
|
|
491
|
+
* @param origin - the route's origin.
|
|
492
|
+
* @param key - the key the conversation is using.
|
|
493
|
+
* @param headers - extra headers the profile declares.
|
|
494
|
+
* @param signal - abort signal.
|
|
495
|
+
*/
|
|
496
|
+
async function queryNewApi(origin, key, headers, signal) {
|
|
497
|
+
const res = await fetch(`${origin}/api/user/self`, {
|
|
498
|
+
method: 'GET',
|
|
499
|
+
headers: {
|
|
500
|
+
'Content-Type': 'application/json',
|
|
501
|
+
Authorization: `Bearer ${key}`,
|
|
502
|
+
'User-Agent': NEW_API_USER_AGENT,
|
|
503
|
+
...(headers ?? {}),
|
|
504
|
+
},
|
|
505
|
+
signal,
|
|
506
|
+
})
|
|
507
|
+
if (!res.ok) {
|
|
508
|
+
return { ok: false, message: `New API 余额接口返回 HTTP ${res.status}` }
|
|
509
|
+
}
|
|
510
|
+
const extracted = extractNewApi(await res.json())
|
|
511
|
+
return extracted.ok ? { ok: true, value: extracted.value } : { ok: false, message: extracted.message }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Per-provider usage adapters. Each one answers the same question in the
|
|
516
|
+
// provider's own vocabulary and normalizes to the panel's two building blocks:
|
|
517
|
+
// `windows` (used / limit / resetsAt meters) and `rows` (label → value lines).
|
|
518
|
+
// Whatever the endpoint actually returned rides along as `raw`, so an endpoint
|
|
519
|
+
// whose shape drifts still shows something readable — and reportable.
|
|
520
|
+
|
|
521
|
+
/** Parse a numeric field that may arrive as a number or a numeric string. */
|
|
522
|
+
function numberOf(value) {
|
|
523
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
524
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
525
|
+
const parsed = Number(value)
|
|
526
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
527
|
+
}
|
|
528
|
+
return null
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Parse a reset instant that may arrive as epoch milliseconds or an ISO string. */
|
|
532
|
+
function dateOf(value) {
|
|
533
|
+
const numeric = numberOf(value)
|
|
534
|
+
if (numeric !== null) return numeric < 1e11 ? Math.round(numeric * 1000) : numeric
|
|
535
|
+
if (typeof value === 'string') {
|
|
536
|
+
const parsed = Date.parse(value)
|
|
537
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
538
|
+
}
|
|
539
|
+
return 0
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Round a money value for display without inventing precision. */
|
|
543
|
+
function moneyOf(value) {
|
|
544
|
+
const numeric = numberOf(value)
|
|
545
|
+
return numeric === null ? null : numeric
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Take the first present value among several candidate field names. */
|
|
549
|
+
function firstOf(node, keys) {
|
|
550
|
+
for (const key of keys) {
|
|
551
|
+
if (node[key] !== undefined && node[key] !== null) return node[key]
|
|
552
|
+
}
|
|
553
|
+
return undefined
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Fetch one JSON endpoint with the provider's own key. */
|
|
557
|
+
async function fetchUsageJson(url, key, headers, signal, label) {
|
|
558
|
+
const res = await fetch(url, {
|
|
559
|
+
headers: {
|
|
560
|
+
accept: 'application/json',
|
|
561
|
+
authorization: `Bearer ${key}`,
|
|
562
|
+
...(headers ?? {}),
|
|
563
|
+
},
|
|
564
|
+
signal,
|
|
565
|
+
})
|
|
566
|
+
if (!res.ok) {
|
|
567
|
+
return { ok: false, message: `${label} 接口返回 HTTP ${res.status}` }
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
return { ok: true, value: await res.json() }
|
|
571
|
+
} catch {
|
|
572
|
+
return { ok: false, message: `${label} 返回的不是 JSON` }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** One window meter from a node that names its own used / limit / reset. */
|
|
577
|
+
function windowOf(label, node) {
|
|
578
|
+
if (node === null || typeof node !== 'object') return null
|
|
579
|
+
const used = moneyOf(firstOf(node, ['used', 'usage', 'spent', 'cost', 'usedUsd', 'used_usd']))
|
|
580
|
+
const limit = moneyOf(firstOf(node, ['limit', 'cap', 'quota', 'max', 'total', 'monthlyLimit', 'monthly_limit']))
|
|
581
|
+
if (used === null && limit === null) return null
|
|
582
|
+
return {
|
|
583
|
+
label,
|
|
584
|
+
used: used ?? 0,
|
|
585
|
+
limit: limit ?? 0,
|
|
586
|
+
resetsAt: dateOf(firstOf(node, ['resetsAt', 'resetAt', 'reset', 'resets_at', 'reset_at', 'renewsAt', 'periodEnd'])),
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Normalize the OpenCode Go usage payload. Its exact shape is not documented
|
|
592
|
+
* (verified only as far as `401` without a key), so the candidate field names
|
|
593
|
+
* cover the plausible spellings and anything unrecognized stays visible in
|
|
594
|
+
* `raw`.
|
|
595
|
+
*/
|
|
596
|
+
function normalizeOpenCodeGo(body) {
|
|
597
|
+
const root = body !== null && typeof body === 'object' ? (body.data ?? body) : {}
|
|
598
|
+
const windows = []
|
|
599
|
+
const windowsNode = root.windows ?? root.limits ?? root.usageWindows ?? root.usage_windows ?? root
|
|
600
|
+
for (const [keys, label] of [
|
|
601
|
+
[['fiveHour', 'five_hour', 'fivehour', '5h', 'five_hours'], '5 小时窗口'],
|
|
602
|
+
[['weekly', 'week', '7d', 'sevenDay', 'seven_day'], '7 天窗口'],
|
|
603
|
+
[['monthly', 'month', 'monthlyUsage', 'monthly_usage', '30d'], '月额度'],
|
|
604
|
+
]) {
|
|
605
|
+
const node = firstOf(windowsNode, keys)
|
|
606
|
+
const window = windowOf(label, node)
|
|
607
|
+
if (window !== null) windows.push(window)
|
|
608
|
+
}
|
|
609
|
+
const rows = []
|
|
610
|
+
const plan = firstOf(root, ['plan', 'planName', 'plan_name', 'tier', 'subscription'])
|
|
611
|
+
if (typeof plan === 'string' && plan !== '') rows.push({ label: '套餐', value: plan })
|
|
612
|
+
const status = firstOf(root, ['status', 'state'])
|
|
613
|
+
if (typeof status === 'string' && status !== '') rows.push({ label: '状态', value: status })
|
|
614
|
+
const used = moneyOf(firstOf(root, ['used', 'usage', 'spent', 'cost', 'totalUsed', 'total_used']))
|
|
615
|
+
if (used !== null) rows.push({ label: '已用', value: `$${used.toFixed(2)}` })
|
|
616
|
+
const limit = moneyOf(firstOf(root, ['limit', 'cap', 'quota', 'totalLimit', 'total_limit']))
|
|
617
|
+
if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
|
|
618
|
+
const balance = moneyOf(firstOf(root, ['balance', 'credit', 'credits', 'remaining']))
|
|
619
|
+
if (balance !== null) rows.push({ label: '余额', value: `$${balance.toFixed(2)}` })
|
|
620
|
+
// A per-model breakdown, if this endpoint carries one.
|
|
621
|
+
const modelsNode = firstOf(root, ['models', 'byModel', 'by_model', 'perModel', 'per_model'])
|
|
622
|
+
const models = []
|
|
623
|
+
if (Array.isArray(modelsNode)) {
|
|
624
|
+
for (const entry of modelsNode) {
|
|
625
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
626
|
+
const id = firstOf(entry, ['model', 'id', 'name', 'modelId', 'model_id'])
|
|
627
|
+
const entryUsed = moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost']))
|
|
628
|
+
const entryLimit = moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max']))
|
|
629
|
+
if (id === undefined && entryUsed === null) continue
|
|
630
|
+
models.push({ model: String(id ?? '未知模型'), used: entryUsed, limit: entryLimit })
|
|
631
|
+
}
|
|
632
|
+
} else if (modelsNode !== null && typeof modelsNode === 'object') {
|
|
633
|
+
for (const [id, entry] of Object.entries(modelsNode)) {
|
|
634
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
635
|
+
models.push({
|
|
636
|
+
model: id,
|
|
637
|
+
used: moneyOf(firstOf(entry, ['used', 'usage', 'spent', 'cost'])),
|
|
638
|
+
limit: moneyOf(firstOf(entry, ['limit', 'cap', 'quota', 'max'])),
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return { windows, models, rows, raw: body }
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** OpenCode Go / Go Plus subscription usage (`/zen/go/v1/usage`). */
|
|
646
|
+
async function queryOpenCodeGo(origin, key, headers, signal) {
|
|
647
|
+
const res = await fetchUsageJson(`${origin}/zen/go/v1/usage`, key, headers, signal, 'OpenCode Go')
|
|
648
|
+
if (!res.ok) return res
|
|
649
|
+
return { ok: true, value: normalizeOpenCodeGo(res.value) }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** OpenRouter credits: `/api/v1/key` carries the limit, `/api/v1/credits` the pool. */
|
|
653
|
+
async function queryOpenRouter(origin, key, headers, signal) {
|
|
654
|
+
const res = await fetchUsageJson(`${origin}/api/v1/key`, key, headers, signal, 'OpenRouter')
|
|
655
|
+
if (!res.ok) return res
|
|
656
|
+
const data = res.value?.data ?? {}
|
|
657
|
+
const usage = moneyOf(data.usage) ?? 0
|
|
658
|
+
const limit = moneyOf(data.limit)
|
|
659
|
+
const remaining = moneyOf(data.limit_remaining)
|
|
660
|
+
const rows = [{ label: '已用', value: `$${usage.toFixed(2)}` }]
|
|
661
|
+
if (limit !== null) rows.push({ label: '额度', value: `$${limit.toFixed(2)}` })
|
|
662
|
+
rows.push({ label: '剩余', value: `$${(remaining ?? (limit === null ? 0 : Math.max(0, limit - usage))).toFixed(2)}` })
|
|
663
|
+
if (typeof data.label === 'string' && data.label !== '') rows.push({ label: 'Key', value: data.label })
|
|
664
|
+
if (data.is_free_tier === true) rows.push({ label: '套餐', value: '免费层' })
|
|
665
|
+
const raw = { key: res.value }
|
|
666
|
+
if (limit === null) {
|
|
667
|
+
// A key without its own limit spends from the account credit pool.
|
|
668
|
+
const credits = await fetchUsageJson(`${origin}/api/v1/credits`, key, headers, signal, 'OpenRouter')
|
|
669
|
+
if (credits.ok) {
|
|
670
|
+
const total = moneyOf(credits.value?.data?.total_credits)
|
|
671
|
+
const spent = moneyOf(credits.value?.data?.total_usage)
|
|
672
|
+
if (total !== null) rows.push({ label: '账户总额度', value: `$${total.toFixed(2)}` })
|
|
673
|
+
if (spent !== null) rows.push({ label: '账户已用', value: `$${spent.toFixed(2)}` })
|
|
674
|
+
if (total !== null && spent !== null) rows.push({ label: '账户剩余', value: `$${Math.max(0, total - spent).toFixed(2)}` })
|
|
675
|
+
raw.credits = credits.value
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return { ok: true, value: { rows, raw } }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Moonshot / Kimi balance: `GET /v1/users/me/balance`. */
|
|
682
|
+
async function queryMoonshot(origin, key, headers, signal) {
|
|
683
|
+
const res = await fetchUsageJson(`${origin}/v1/users/me/balance`, key, headers, signal, 'Moonshot')
|
|
684
|
+
if (!res.ok) return res
|
|
685
|
+
const data = res.value?.data ?? {}
|
|
686
|
+
const rows = []
|
|
687
|
+
const available = moneyOf(data.available_balance ?? data.availableBalance)
|
|
688
|
+
const voucher = moneyOf(data.voucher_balance ?? data.voucherBalance)
|
|
689
|
+
const cash = moneyOf(data.cash_balance ?? data.cashBalance)
|
|
690
|
+
if (available !== null) rows.push({ label: '可用余额', value: `¥${available.toFixed(2)}` })
|
|
691
|
+
if (cash !== null) rows.push({ label: '现金余额', value: `¥${cash.toFixed(2)}` })
|
|
692
|
+
if (voucher !== null) rows.push({ label: '代金券', value: `¥${voucher.toFixed(2)}` })
|
|
693
|
+
if (rows.length === 0) return { ok: false, message: 'Moonshot 接口未返回可识别的余额字段' }
|
|
694
|
+
return { ok: true, value: { rows, raw: res.value } }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** SiliconFlow balance: `GET /v1/user/info`. */
|
|
698
|
+
async function querySiliconFlow(origin, key, headers, signal) {
|
|
699
|
+
const res = await fetchUsageJson(`${origin}/v1/user/info`, key, headers, signal, 'SiliconFlow')
|
|
700
|
+
if (!res.ok) return res
|
|
701
|
+
const data = res.value?.data ?? {}
|
|
702
|
+
const rows = []
|
|
703
|
+
const total = moneyOf(data.totalBalance ?? data.total_balance)
|
|
704
|
+
const charge = moneyOf(data.chargeBalance ?? data.charge_balance)
|
|
705
|
+
const gift = moneyOf(data.balance)
|
|
706
|
+
if (total !== null) rows.push({ label: '总余额', value: `¥${total.toFixed(2)}` })
|
|
707
|
+
if (charge !== null) rows.push({ label: '充值余额', value: `¥${charge.toFixed(2)}` })
|
|
708
|
+
if (gift !== null) rows.push({ label: '赠送余额', value: `¥${gift.toFixed(2)}` })
|
|
709
|
+
if (typeof data.name === 'string' && data.name !== '') rows.push({ label: '账户', value: data.name })
|
|
710
|
+
if (rows.length === 0) return { ok: false, message: 'SiliconFlow 接口未返回可识别的余额字段' }
|
|
711
|
+
return { ok: true, value: { rows, raw: res.value } }
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Read the signed-in DeepSeek platform wallet through the account service —
|
|
716
|
+
* the `deepseek-account` route holds no API key at all.
|
|
717
|
+
* @param ctx - host context carrying optional `deepseekAccount`.
|
|
718
|
+
* @returns the normalized wallet view, or a readable failure.
|
|
719
|
+
*/
|
|
720
|
+
async function queryDeepSeekAccount(ctx) {
|
|
721
|
+
const account = ctx.get('deepseekAccount')
|
|
722
|
+
if (account === undefined) {
|
|
723
|
+
return { ok: false, message: '当前部署没有挂载 deepseek-account 服务,无法读取账户钱包' }
|
|
724
|
+
}
|
|
725
|
+
const client = {
|
|
726
|
+
version: '1.0.0',
|
|
727
|
+
locale: 'zh-CN',
|
|
728
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
|
|
729
|
+
}
|
|
730
|
+
const balance = await account.getBalance(client)
|
|
731
|
+
if (balance === null || balance === undefined) {
|
|
732
|
+
return { ok: false, message: '尚未登录 DeepSeek 账户(设置 → 账户里登录后再试)' }
|
|
733
|
+
}
|
|
734
|
+
if (balance.status !== 'ready') {
|
|
735
|
+
return { ok: false, message: 'DeepSeek 账户余额查询失败' }
|
|
736
|
+
}
|
|
737
|
+
const wallets = [...(balance.value ?? []), ...(balance.bonusWallets ?? [])]
|
|
738
|
+
.map((wallet) => ({ currency: wallet.currency, balance: wallet.balance }))
|
|
739
|
+
return { ok: true, value: { wallets } }
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* The model selection the current conversation runs on: the browser's copy of
|
|
744
|
+
* the Session's own projection first, then this Host's live projection, then
|
|
745
|
+
* the deployment default.
|
|
746
|
+
* @param ctx - host context carrying optional session services.
|
|
747
|
+
* @param payload - browser payload; `provider` + `model` win when both exist.
|
|
748
|
+
* @returns `{ provider, model, reasoningEffort? }`, or null while unknown.
|
|
749
|
+
*/
|
|
750
|
+
function resolveSelection(ctx, payload) {
|
|
751
|
+
if (typeof payload?.provider === 'string' && payload.provider !== ''
|
|
752
|
+
&& typeof payload?.model === 'string' && payload.model !== '') {
|
|
753
|
+
return {
|
|
754
|
+
provider: payload.provider,
|
|
755
|
+
model: payload.model,
|
|
756
|
+
...(typeof payload?.reasoningEffort === 'string' ? { reasoningEffort: payload.reasoningEffort } : {}),
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
try {
|
|
760
|
+
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : ''
|
|
761
|
+
const session = sessionId === '' ? undefined : ctx.get('sessions')?.get?.(sessionId)
|
|
762
|
+
const state = session === undefined ? undefined : ctx.get('sessionProjections')?.stateOf?.(session, 'modelSelection')
|
|
763
|
+
const selected = state?.pending ?? state?.lastUsed
|
|
764
|
+
if (selected !== null && selected !== undefined && typeof selected.provider === 'string') return selected
|
|
765
|
+
} catch {
|
|
766
|
+
// A missing or cold Session simply falls through to the deployment default.
|
|
767
|
+
}
|
|
768
|
+
const fallback = ctx.get('agentDefaultModel')?.currentSelection?.()
|
|
769
|
+
return fallback !== null && fallback !== undefined && typeof fallback.provider === 'string' ? fallback : null
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Run the usage query that belongs to one conversation model.
|
|
774
|
+
* @param ctx - host context.
|
|
775
|
+
* @param signal - abort signal for every outbound call.
|
|
776
|
+
* @param payload - browser payload (model selection and/or session id).
|
|
777
|
+
* @returns the normalized `{ ok, value | error }` envelope.
|
|
778
|
+
*/
|
|
779
|
+
async function queryUsage(ctx, signal, payload) {
|
|
780
|
+
const selection = resolveSelection(ctx, payload)
|
|
781
|
+
if (selection === null) {
|
|
782
|
+
return {
|
|
783
|
+
ok: false,
|
|
784
|
+
error: {
|
|
785
|
+
code: 'internal',
|
|
786
|
+
message: '无法确定当前对话使用的模型:会话还没有过模型选择,请先在对话里选一次模型',
|
|
787
|
+
details: {},
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const { provider } = selection
|
|
792
|
+
const profileOf = createProfileLookup(ctx)
|
|
793
|
+
const profile = profileOf(provider)
|
|
794
|
+
const kind = kindOf(provider, profile)
|
|
795
|
+
const providerName = profile.displayName ?? provider
|
|
796
|
+
const shared = { provider, providerName, model: selection.model, reasoningEffort: selection.reasoningEffort }
|
|
797
|
+
if (kind === null) {
|
|
798
|
+
const configured = originOf(profile.baseURL) !== null
|
|
799
|
+
return {
|
|
800
|
+
ok: false,
|
|
801
|
+
error: {
|
|
802
|
+
code: 'internal',
|
|
803
|
+
message: configured
|
|
804
|
+
? `供应商「${providerName}」没有已适配的用量接口:${profile.baseURL} 不对应任何已知的用量 API`
|
|
805
|
+
: `供应商「${providerName}」既没有配置 baseURL,也不是已知的用量接口,无法查询用量`,
|
|
806
|
+
details: {},
|
|
807
|
+
},
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const ref = keyRefOf(provider, profile)
|
|
811
|
+
let key
|
|
812
|
+
if (kind !== 'deepseek-account') {
|
|
813
|
+
if (ref === undefined) {
|
|
814
|
+
return {
|
|
815
|
+
ok: false,
|
|
816
|
+
error: { code: 'internal', message: `供应商「${providerName}」没有可用的凭据引用`, details: {} },
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
const resolved = await resolveKey(ctx, ref, provider)
|
|
820
|
+
if (resolved === undefined) {
|
|
821
|
+
return {
|
|
822
|
+
ok: false,
|
|
823
|
+
error: {
|
|
824
|
+
code: 'internal',
|
|
825
|
+
message: `未找到供应商「${providerName}」的 API Key:当前对话用的是 ${ref},请在 设置 → 模型 里填写`,
|
|
826
|
+
details: {},
|
|
827
|
+
},
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
key = resolved
|
|
831
|
+
}
|
|
832
|
+
const credential = ref === undefined
|
|
833
|
+
? null
|
|
834
|
+
: { ref, source: key?.source ?? null }
|
|
835
|
+
let result
|
|
836
|
+
const deadline = withTimeout(signal, QUERY_TIMEOUT_MS)
|
|
837
|
+
try {
|
|
838
|
+
if (kind === 'commandcode') {
|
|
839
|
+
result = await queryCommandCode(originOf(profile.baseURL) ?? COMMAND_CODE_BASE, key.value, deadline)
|
|
840
|
+
} else if (kind === 'deepseek') {
|
|
841
|
+
const origin = originOf(profile.baseURL)
|
|
842
|
+
const host = hostOf(origin)
|
|
843
|
+
const url = host === 'deepseek.com' || host.endsWith('.deepseek.com')
|
|
844
|
+
? `${origin}/user/balance`
|
|
845
|
+
: DEEPSEEK_BALANCE_URL
|
|
846
|
+
result = await queryDeepSeek(url, key.value, deadline)
|
|
847
|
+
} else if (kind === 'deepseek-account') {
|
|
848
|
+
result = await queryDeepSeekAccount(ctx)
|
|
849
|
+
} else if (kind === 'opencode-go') {
|
|
850
|
+
result = await queryOpenCodeGo(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
851
|
+
} else if (kind === 'openrouter') {
|
|
852
|
+
result = await queryOpenRouter(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
853
|
+
} else if (kind === 'moonshot') {
|
|
854
|
+
result = await queryMoonshot(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
855
|
+
} else if (kind === 'siliconflow') {
|
|
856
|
+
result = await querySiliconFlow(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
857
|
+
} else {
|
|
858
|
+
result = await queryNewApi(originOf(profile.baseURL), key.value, profile.headers, deadline)
|
|
859
|
+
}
|
|
860
|
+
} catch (err) {
|
|
861
|
+
return {
|
|
862
|
+
ok: false,
|
|
863
|
+
error: { code: 'internal', message: queryFailureMessage(err), details: {} },
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (!result.ok) {
|
|
867
|
+
return { ok: false, error: { code: 'internal', message: result.message, details: {} } }
|
|
868
|
+
}
|
|
869
|
+
return { ok: true, value: { mode: kind, ...shared, credential, data: result.value } }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* The read-only provider directory for the panel's 说明 dialog: which routes
|
|
874
|
+
* exist, which credential reference each resolves, and whether that reference
|
|
875
|
+
* is configured. No value ever crosses this boundary.
|
|
876
|
+
* @param ctx - host context.
|
|
877
|
+
* @returns one row per declared provider route.
|
|
878
|
+
*/
|
|
879
|
+
async function providerRows(ctx) {
|
|
880
|
+
const profileOf = createProfileLookup(ctx)
|
|
881
|
+
let rows = []
|
|
882
|
+
try {
|
|
883
|
+
rows = ctx.get('llm')?.listConfigurableProviders?.() ?? []
|
|
884
|
+
} catch {
|
|
885
|
+
rows = []
|
|
886
|
+
}
|
|
887
|
+
if (!Array.isArray(rows)) return []
|
|
888
|
+
const out = []
|
|
889
|
+
for (const row of rows) {
|
|
890
|
+
if (row === null || typeof row !== 'object' || typeof row.provider !== 'string') continue
|
|
891
|
+
const profile = profileOf(row.provider)
|
|
892
|
+
const ref = keyRefOf(row.provider, profile)
|
|
893
|
+
const kind = kindOf(row.provider, profile)
|
|
894
|
+
let configured = false
|
|
895
|
+
let source = null
|
|
896
|
+
if (ref !== undefined) {
|
|
897
|
+
try {
|
|
898
|
+
const info = await ctx.get('credentials')?.describe?.(ref)
|
|
899
|
+
configured = info?.configured === true
|
|
900
|
+
source = typeof info?.source === 'string' ? info.source : null
|
|
901
|
+
} catch {
|
|
902
|
+
configured = false
|
|
903
|
+
}
|
|
904
|
+
} else {
|
|
905
|
+
configured = true
|
|
906
|
+
source = '账户登录'
|
|
907
|
+
}
|
|
908
|
+
out.push({
|
|
909
|
+
provider: row.provider,
|
|
910
|
+
displayName: profile.displayName ?? row.displayName ?? row.provider,
|
|
911
|
+
kind,
|
|
912
|
+
kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
|
|
913
|
+
baseUrl: profile.baseURL ?? null,
|
|
914
|
+
credentialRef: ref ?? null,
|
|
915
|
+
configured,
|
|
916
|
+
source,
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
return out
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* Bound one outbound call. The panel now refreshes on a timer, so a provider
|
|
924
|
+
* that never answers must not hold the one in-flight slot forever — the caller
|
|
925
|
+
* aborts on close, and this adds a deadline of its own.
|
|
926
|
+
* @param signal - caller cancellation, when there is one.
|
|
927
|
+
* @param ms - deadline in milliseconds.
|
|
928
|
+
* @returns a signal that aborts on either cause.
|
|
929
|
+
*/
|
|
930
|
+
function withTimeout(signal, ms) {
|
|
931
|
+
if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return signal
|
|
932
|
+
const deadline = AbortSignal.timeout(ms)
|
|
933
|
+
if (signal === undefined || typeof AbortSignal.any !== 'function') return deadline
|
|
934
|
+
return AbortSignal.any([signal, deadline])
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** Turn one adapter failure into a readable message; a deadline reads as a timeout. */
|
|
938
|
+
function queryFailureMessage(err) {
|
|
939
|
+
if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
|
940
|
+
return `用量接口超时(${Math.round(QUERY_TIMEOUT_MS / 1000)} 秒未返回)`
|
|
941
|
+
}
|
|
942
|
+
return err instanceof Error ? err.message : String(err)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* The routable model catalog, annotated with the usage API each provider route
|
|
947
|
+
* resolves — the data behind the panel's manual model picker. Providers come
|
|
948
|
+
* from the same catalog the model switch uses, so a manual choice is always a
|
|
949
|
+
* route the deployment can actually serve.
|
|
950
|
+
* @param ctx - host context carrying optional `sessionController`.
|
|
951
|
+
* @returns `{ groups, default }`, each group `{ provider, name, kind, kindLabel, models }`.
|
|
952
|
+
*/
|
|
953
|
+
async function modelCatalogRows(ctx) {
|
|
954
|
+
let catalog
|
|
955
|
+
try {
|
|
956
|
+
catalog = await ctx.get('sessionController')?.modelCatalog?.()
|
|
957
|
+
} catch {
|
|
958
|
+
catalog = undefined
|
|
959
|
+
}
|
|
960
|
+
const profileOf = createProfileLookup(ctx)
|
|
961
|
+
const groups = []
|
|
962
|
+
for (const group of Array.isArray(catalog?.groups) ? catalog.groups : []) {
|
|
963
|
+
if (group === null || typeof group !== 'object' || typeof group.id !== 'string' || group.id === '') continue
|
|
964
|
+
const kind = kindOf(group.id, profileOf(group.id))
|
|
965
|
+
groups.push({
|
|
966
|
+
provider: group.id,
|
|
967
|
+
name: typeof group.name === 'string' && group.name !== '' ? group.name : group.id,
|
|
968
|
+
kind,
|
|
969
|
+
kindLabel: kind === null ? '不支持用量查询' : KIND_LABELS[kind] ?? kind,
|
|
970
|
+
models: (Array.isArray(group.models) ? group.models : []).flatMap((model) => (
|
|
971
|
+
model !== null && typeof model === 'object' && typeof model.id === 'string' && model.id !== ''
|
|
972
|
+
? [{ id: model.id, name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id }]
|
|
973
|
+
: []
|
|
974
|
+
)),
|
|
975
|
+
})
|
|
976
|
+
}
|
|
977
|
+
const selected = catalog?.default
|
|
978
|
+
return {
|
|
979
|
+
groups,
|
|
980
|
+
default: selected !== null && typeof selected === 'object' && typeof selected.provider === 'string'
|
|
981
|
+
? { provider: selected.provider, model: typeof selected.model === 'string' ? selected.model : '' }
|
|
982
|
+
: null,
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** Read the entirety of a node:http request body as a UTF-8 string. */
|
|
987
|
+
async function readBody(req) {
|
|
988
|
+
const chunks = []
|
|
989
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
990
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** Write a JSON response with a normalized `{ ok, value | error }` envelope. */
|
|
994
|
+
function writeJson(res, statusCode, value) {
|
|
995
|
+
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' })
|
|
996
|
+
res.end(JSON.stringify(value))
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Apply the composition's own browser-trust fence to one request. `node:http`
|
|
1001
|
+
* routes are unauthenticated by construction (the web server has no global
|
|
1002
|
+
* policy — every route owner decides), so each of ours asks the connection
|
|
1003
|
+
* service first and answers its 401/403 itself when the request is not from an
|
|
1004
|
+
* accepted authority. A deployment without the service keeps the plugin usable.
|
|
1005
|
+
* @param ctx - host context carrying the optional `connection` service.
|
|
1006
|
+
* @param req - the incoming request.
|
|
1007
|
+
* @param res - the response to answer on when the request is rejected.
|
|
1008
|
+
* @returns true when the request was already rejected and answered.
|
|
1009
|
+
*/
|
|
1010
|
+
function rejected(ctx, req, res) {
|
|
1011
|
+
let rejection
|
|
1012
|
+
try {
|
|
1013
|
+
rejection = ctx.get('connection')?.requestRejection?.(req)
|
|
1014
|
+
} catch {
|
|
1015
|
+
rejection = undefined
|
|
1016
|
+
}
|
|
1017
|
+
if (rejection === undefined) return false
|
|
1018
|
+
res.statusCode = rejection
|
|
1019
|
+
res.end()
|
|
1020
|
+
return true
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Register the /model-usage/* exact Web routes: the usage query for one
|
|
1025
|
+
* conversation model, the read-only provider directory, and the routable model
|
|
1026
|
+
* catalog behind the manual picker.
|
|
1027
|
+
* @param ctx - host context with webServer, credentials, and the session/LLM
|
|
1028
|
+
* services read through `ctx.get()`.
|
|
1029
|
+
*/
|
|
1030
|
+
export function apply(ctx) {
|
|
1031
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1032
|
+
kind: 'exact',
|
|
1033
|
+
path: '/model-usage/balance',
|
|
1034
|
+
handler: async (req, res) => {
|
|
1035
|
+
if (rejected(ctx, req, res)) return
|
|
1036
|
+
const controller = new AbortController()
|
|
1037
|
+
res.once('close', () => controller.abort())
|
|
1038
|
+
try {
|
|
1039
|
+
const payload = req.method === 'POST'
|
|
1040
|
+
? JSON.parse((await readBody(req)) || '{}')
|
|
1041
|
+
: {}
|
|
1042
|
+
writeJson(res, 200, await queryUsage(ctx, controller.signal, payload))
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
writeJson(res, 200, {
|
|
1045
|
+
ok: false,
|
|
1046
|
+
error: { code: 'internal', message: `查询失败:${err instanceof Error ? err.message : String(err)}`, details: {} },
|
|
1047
|
+
})
|
|
1048
|
+
}
|
|
1049
|
+
},
|
|
1050
|
+
}), 'model-usage: /model-usage/balance')
|
|
1051
|
+
// Read-only provider/key directory (never a key value), for the 说明 dialog.
|
|
1052
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1053
|
+
kind: 'exact',
|
|
1054
|
+
path: '/model-usage/providers',
|
|
1055
|
+
handler: async (req, res) => {
|
|
1056
|
+
if (rejected(ctx, req, res)) return
|
|
1057
|
+
try {
|
|
1058
|
+
writeJson(res, 200, { ok: true, value: await providerRows(ctx) })
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
writeJson(res, 200, {
|
|
1061
|
+
ok: false,
|
|
1062
|
+
error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
|
|
1063
|
+
})
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
}), 'model-usage: /model-usage/providers')
|
|
1067
|
+
// Routable models (with the usage API each route resolves) for the manual picker.
|
|
1068
|
+
ctx.effect(() => ctx.webServer.register({
|
|
1069
|
+
kind: 'exact',
|
|
1070
|
+
path: '/model-usage/models',
|
|
1071
|
+
handler: async (req, res) => {
|
|
1072
|
+
if (rejected(ctx, req, res)) return
|
|
1073
|
+
try {
|
|
1074
|
+
writeJson(res, 200, { ok: true, value: await modelCatalogRows(ctx) })
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
writeJson(res, 200, {
|
|
1077
|
+
ok: false,
|
|
1078
|
+
error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
|
|
1079
|
+
})
|
|
1080
|
+
}
|
|
1081
|
+
},
|
|
1082
|
+
}), 'model-usage: /model-usage/models')
|
|
1083
|
+
}
|