@marsken/dsh-studio 0.1.10 → 0.1.12
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/AGENTS.md +4 -2
- package/README.md +21 -1
- package/client.js +48 -48
- package/dist/client.js +48 -48
- package/docs/compatibility.md +1 -1
- package/docs/publish.md +2 -0
- package/package.json +4 -2
- package/src/host/domain.js +4 -4
- package/src/host/impact.js +4 -2
- package/src/host/routes/insights.js +5 -0
- package/src/host/routes/requirements.js +20 -1
- package/src/host/session-usage.js +100 -0
- package/src/host/usage.js +124 -0
package/docs/compatibility.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
| 项 | 值 |
|
|
8
8
|
| --- | --- |
|
|
9
|
-
| dsh-studio | `package.json` `version: 0.1.
|
|
9
|
+
| dsh-studio | `package.json` `version: 0.1.12` |
|
|
10
10
|
| DeepSeek Harness | `0.2.2-rc.1`(`dsh --version`) |
|
|
11
11
|
| Node | `v24.22.2`(Linux 目标机);本机 Windows `v24.19.0` |
|
|
12
12
|
| 目标系统 | Ubuntu 24.04,`dsh web` 监听 `18080`,nginx `:80` 反代并注入 `dsh-auth-*` cookie |
|
package/docs/publish.md
CHANGED
|
@@ -127,6 +127,8 @@ token 只写进临时 npmrc(放在仓库外或 `tmp/` 且不提交),发完
|
|
|
127
127
|
| --- | --- | --- |
|
|
128
128
|
| `0.1.0` | 2026-11-13 | 首个公开版:看板 / 需求 / 用例回归 / 效能 / 设计评审四期 |
|
|
129
129
|
| `0.1.1` | 2026-11-13 | 补三张 README 截图(npm 页可看图);默认值与文档去掉公司内网信息 |
|
|
130
|
+
| `0.1.12` | 2026-11-15 | 真实 token 成本(读会话日志 + 价目表计价) |
|
|
131
|
+
| `0.1.11` | 2026-11-15 | 需求变更影响面(接口 + 面板)、可跑用例口径修正、成本口径文档 |
|
|
130
132
|
| `0.1.10` | 2026-11-15 | shell/db 用例可跑、GitLab 核对结果常驻显示、事件带 truncated、角色归并 |
|
|
131
133
|
| `0.1.9` | 2026-11-14 | 准出门禁提示「代码事实核对不通过」(只提醒,不拦) |
|
|
132
134
|
| `0.1.8` | 2026-11-14 | 卡进「待评审」时自动核对代码事实,对不上就写进 history 并记 gate/blocked |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marsken/dsh-studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH Studio: role-based multi-agent workspace (product / architect / developer / QA) with a live work board, GitLab + Feishu connection settings, all configurable from the web GUI.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"test": "node --test tests/repo.test.mjs tests/store-boot.test.mjs tests/client-units.test.mjs tests/notify.test.mjs",
|
|
13
13
|
"check": "node --check index.js && node --check client.src.js && node --test tests/repo.test.mjs tests/store-boot.test.mjs tests/client-units.test.mjs tests/notify.test.mjs",
|
|
14
|
-
"smoke:store": "node scripts/smoke-store.mjs"
|
|
14
|
+
"smoke:store": "node scripts/smoke-store.mjs",
|
|
15
|
+
"mock:gitlab": "node tools/mock-gitlab.mjs",
|
|
16
|
+
"verify:gitlab": "node tools/verify-gitlab-facts.mjs"
|
|
15
17
|
},
|
|
16
18
|
"exports": {
|
|
17
19
|
".": "./index.js",
|
package/src/host/domain.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** dsh-studio 的领域层:板列、卡片/需求/发布的数据形状与清洗、默认配置、编号生成。纯函数,可单测。 */
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto'
|
|
3
3
|
import { sanitizeDbCase } from './db-runner.js'
|
|
4
|
+
import { sanitizePrices } from './usage.js'
|
|
4
5
|
import { sanitizeWebhooks } from './webhooks.js'
|
|
5
6
|
import { sanitizeCaseShell } from './shell-case.js'
|
|
6
7
|
import { LIMIT_DEFAULTS, sanitizeLimits } from './limits.js'
|
|
@@ -465,17 +466,16 @@ export function sanitizeSla(raw) {
|
|
|
465
466
|
}
|
|
466
467
|
return out
|
|
467
468
|
}
|
|
468
|
-
|
|
469
|
-
/** 成本预算:跑多少轮、机上多少小时,超过 warnAt% 就在界面标黄。 */
|
|
469
|
+
/** 成本预算 + 价目表(元/百万 token;空表只报 token 不报钱)。 */
|
|
470
470
|
export function sanitizeCosts(raw) {
|
|
471
471
|
const input = raw !== null && typeof raw === 'object' ? raw : {}
|
|
472
472
|
const budgetRuns = Number(input.budgetRuns)
|
|
473
|
-
const budgetHours = Number(input.budgetHours)
|
|
474
|
-
const warnAt = Number(input.warnAt)
|
|
473
|
+
const budgetHours = Number(input.budgetHours), warnAt = Number(input.warnAt)
|
|
475
474
|
return {
|
|
476
475
|
budgetRuns: Number.isFinite(budgetRuns) && budgetRuns > 0 ? Math.min(Math.round(budgetRuns), 100000) : 500,
|
|
477
476
|
budgetHours: Number.isFinite(budgetHours) && budgetHours > 0 ? Math.min(Math.round(budgetHours), 100000) : 80,
|
|
478
477
|
warnAt: Number.isFinite(warnAt) && warnAt > 0 ? Math.max(10, Math.min(Math.round(warnAt), 100)) : 80,
|
|
478
|
+
prices: sanitizePrices(input.prices), // 元/百万 token;空表只报 token 不报钱
|
|
479
479
|
}
|
|
480
480
|
}
|
|
481
481
|
export function cleanId(value, prefix) {
|
package/src/host/impact.js
CHANGED
|
@@ -13,7 +13,9 @@ export function casesOfTasks(tasks, limit = 40) {
|
|
|
13
13
|
key: `${task.id}:${item.id}`,
|
|
14
14
|
title: String(item.title ?? '').slice(0, 160),
|
|
15
15
|
result: String(item.result ?? 'pending'),
|
|
16
|
-
|
|
16
|
+
// 能自动跑的判断要和执行器一致:接口 / shell / 数据库任一都算。
|
|
17
|
+
http: item.http !== null && item.http !== undefined,
|
|
18
|
+
runnable: item.http != null || item.shell != null || item.db != null,
|
|
17
19
|
})
|
|
18
20
|
}
|
|
19
21
|
}
|
|
@@ -26,7 +28,7 @@ export function impactSummary(tasks) {
|
|
|
26
28
|
return {
|
|
27
29
|
cards: (Array.isArray(tasks) ? tasks : []).length,
|
|
28
30
|
cases: cases.length,
|
|
29
|
-
runnable: cases.filter(item => item.
|
|
31
|
+
runnable: cases.filter(item => item.runnable === true).length,
|
|
30
32
|
failing: cases.filter(item => item.result === 'fail').length,
|
|
31
33
|
}
|
|
32
34
|
}
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
buildDashboard,
|
|
3
3
|
} from '../executor.js'
|
|
4
4
|
import { runSummary } from '../dispatcher.js'
|
|
5
|
+
import { collectSessionUsage, aggregateUsage } from '../session-usage.js'
|
|
6
|
+
import { costOf } from '../usage.js'
|
|
5
7
|
const INJECTED = ['getDispatcher', "buildAnalytics","buildMetrics","burndownReport","sendJson","workloadReport"]
|
|
6
8
|
let host = {
|
|
7
9
|
getDispatcher: () => undefined,
|
|
@@ -74,9 +76,12 @@ export async function handleInsightsRoute({ method, route, req, res, store, url
|
|
|
74
76
|
const level = Math.max(runRate, hourRate) >= 100
|
|
75
77
|
? 'over'
|
|
76
78
|
: Math.max(runRate, hourRate) >= budget.warnAt ? 'warn' : 'ok'
|
|
79
|
+
// 真实 token 用量:读 studio-* 会话日志,按卡 / 按专家汇总;价目表为空时只给 token。
|
|
80
|
+
const usage = aggregateUsage(await collectSessionUsage(), store.snapshot().tasks, budget.prices ?? [], costOf)
|
|
77
81
|
host.sendJson(res, 200, {
|
|
78
82
|
ok: true,
|
|
79
83
|
costs: summary,
|
|
84
|
+
usage,
|
|
80
85
|
budget: {
|
|
81
86
|
...budget,
|
|
82
87
|
runs,
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sanitizeRequirementPool,
|
|
8
8
|
text,
|
|
9
9
|
} from '../domain.js'
|
|
10
|
+
import { casesOfTasks, impactSummary } from '../impact.js'
|
|
10
11
|
import {
|
|
11
12
|
ensureReqFolder,
|
|
12
13
|
mutateReqDoc,
|
|
@@ -405,7 +406,25 @@ export async function handleRequirementRoute({ method, route, req, res, store, u
|
|
|
405
406
|
host.sendJson(res, 200, { ok: true, requirement: result, state: host.publicState(store) })
|
|
406
407
|
return true
|
|
407
408
|
}
|
|
408
|
-
if (method === 'POST' && route === '/requirement/
|
|
409
|
+
if (method === 'POST' && route === '/requirement/impact') {
|
|
410
|
+
// 需求一改,这条需求下面的卡和用例就要重看。这里只算、不改数据。
|
|
411
|
+
const body = await host.readJsonBody(req)
|
|
412
|
+
const id = String(body.id ?? '')
|
|
413
|
+
const snapshot = store.snapshot()
|
|
414
|
+
const requirement = (snapshot.requirements ?? []).find(item => item.id === id)
|
|
415
|
+
if (requirement === undefined) throw new Error(`no requirement ${id}`)
|
|
416
|
+
const tasks = (snapshot.tasks ?? []).filter(task => task.requirementId === id)
|
|
417
|
+
const summary = impactSummary(tasks)
|
|
418
|
+
const cases = casesOfTasks(tasks, 60)
|
|
419
|
+
host.sendJson(res, 200, {
|
|
420
|
+
ok: true,
|
|
421
|
+
summary,
|
|
422
|
+
cases,
|
|
423
|
+
cards: tasks.map(task => ({ id: task.id, code: task.code ?? '', title: task.title ?? '', status: task.status ?? '' })),
|
|
424
|
+
})
|
|
425
|
+
return true
|
|
426
|
+
}
|
|
427
|
+
if (method === 'POST' && route === '/requirement/pull') {
|
|
409
428
|
const body = await host.readJsonBody(req)
|
|
410
429
|
const result = await pullFeishuDoc(body.url ?? body.docUrl ?? '')
|
|
411
430
|
host.sendJson(res, 200, { ok: true, ...result })
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// 从 dsh 的会话日志里读用量:<home>/.dsh/sessions/<ws>/studio-<taskId>-<expert>/session.v4.jsonl.zstd
|
|
2
|
+
// 用 zstd 解压(宿主上本来就有),按 mtime 缓存,避免每刷新一次效能页都重解一遍。
|
|
3
|
+
import { execFile } from 'node:child_process'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import { readdir, stat } from 'node:fs/promises'
|
|
6
|
+
import { homedir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { parseUsageLines, summarizeUsage } from './usage.js'
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile)
|
|
11
|
+
const CACHE = new Map()
|
|
12
|
+
|
|
13
|
+
export function sessionsRoot() {
|
|
14
|
+
return join(homedir(), '.dsh', 'sessions')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** studio-<taskId>-<expertId>:taskId 形如 t-01a3c8d3,剩下都算专家。 */
|
|
18
|
+
export function parseSessionName(name) {
|
|
19
|
+
const match = /^studio-(t-[a-z0-9]+)-(.+)$/.exec(String(name ?? ''))
|
|
20
|
+
if (match === null) return null
|
|
21
|
+
return { taskId: match[1], expert: match[2] }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function readFileUsage(file) {
|
|
25
|
+
let mtimeMs = 0
|
|
26
|
+
try { mtimeMs = (await stat(file)).mtimeMs } catch { return null }
|
|
27
|
+
const cached = CACHE.get(file)
|
|
28
|
+
if (cached !== undefined && cached.mtimeMs === mtimeMs) return cached.usage
|
|
29
|
+
let usage = []
|
|
30
|
+
try {
|
|
31
|
+
const { stdout } = await run('zstd', ['-dc', file], { maxBuffer: 256 * 1024 * 1024 })
|
|
32
|
+
usage = parseUsageLines(stdout.split('\n'))
|
|
33
|
+
} catch {
|
|
34
|
+
// 没装 zstd 或者文件坏了:当作读不到,不抛,页面上显示未知
|
|
35
|
+
usage = []
|
|
36
|
+
}
|
|
37
|
+
CACHE.set(file, { mtimeMs, usage })
|
|
38
|
+
return usage
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 扫一遍所有 studio-* 会话,返回 [{ taskId, expert, at, usage }]。 */
|
|
42
|
+
export async function collectSessionUsage(root = sessionsRoot()) {
|
|
43
|
+
const out = []
|
|
44
|
+
let workspaces = []
|
|
45
|
+
try { workspaces = await readdir(root) } catch { return out }
|
|
46
|
+
for (const workspace of workspaces) {
|
|
47
|
+
const dir = join(root, workspace)
|
|
48
|
+
let entries = []
|
|
49
|
+
try { entries = await readdir(dir) } catch { continue }
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const parsed = parseSessionName(entry)
|
|
52
|
+
if (parsed === null) continue
|
|
53
|
+
const usage = await readFileUsage(join(dir, entry, 'session.v4.jsonl.zstd'))
|
|
54
|
+
if (usage === null || usage.length === 0) continue
|
|
55
|
+
out.push({ taskId: parsed.taskId, expert: parsed.expert, usage })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 按卡 / 按专家汇总 token 与钱;prices 来自 config.costs.prices。 */
|
|
62
|
+
export function aggregateUsage(sessions, tasks, prices, costOf) {
|
|
63
|
+
const byCard = new Map()
|
|
64
|
+
const byExpert = new Map()
|
|
65
|
+
const totals = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
66
|
+
for (const session of sessions) {
|
|
67
|
+
const summary = summarizeUsage(session.usage)
|
|
68
|
+
const money = costOf(prices, session.usage)
|
|
69
|
+
for (const key of ['calls', 'input', 'output', 'cacheRead', 'cacheWrite', 'total']) totals[key] += summary[key]
|
|
70
|
+
const card = byCard.get(session.taskId) ?? { taskId: session.taskId, ...{ calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0, unknownCalls: 0 }
|
|
71
|
+
for (const key of ['calls', 'input', 'output', 'cacheRead', 'cacheWrite', 'total']) card[key] += summary[key]
|
|
72
|
+
if (money.cost !== null) card.cost += money.cost
|
|
73
|
+
card.unknownCalls += money.unknownCalls
|
|
74
|
+
byCard.set(session.taskId, card)
|
|
75
|
+
const expert = byExpert.get(session.expert) ?? { expert: session.expert, calls: 0, total: 0, cost: 0, unknownCalls: 0 }
|
|
76
|
+
expert.calls += summary.calls
|
|
77
|
+
expert.total += summary.total
|
|
78
|
+
if (money.cost !== null) expert.cost += money.cost
|
|
79
|
+
expert.unknownCalls += money.unknownCalls
|
|
80
|
+
byExpert.set(session.expert, expert)
|
|
81
|
+
}
|
|
82
|
+
const taskOf = new Map((Array.isArray(tasks) ? tasks : []).map((task) => [String(task.id ?? ''), task]))
|
|
83
|
+
const cards = [...byCard.values()].map((card) => {
|
|
84
|
+
const task = taskOf.get(card.taskId)
|
|
85
|
+
return { ...card, code: String(task?.code ?? ''), title: String(task?.title ?? ''), project: String(task?.project ?? '') }
|
|
86
|
+
}).sort((a, b) => b.total - a.total)
|
|
87
|
+
const round = (value) => Math.round(value * 10000) / 10000
|
|
88
|
+
return {
|
|
89
|
+
totals: {
|
|
90
|
+
...totals,
|
|
91
|
+
// 有算不出价的调用且一分钱都没算出来时,给 null(未知),不要报成 0
|
|
92
|
+
cost: cards.reduce((sum, card) => sum + card.unknownCalls, 0) > 0 && cards.reduce((sum, card) => sum + card.cost, 0) === 0
|
|
93
|
+
? null
|
|
94
|
+
: round(cards.reduce((sum, card) => sum + card.cost, 0)),
|
|
95
|
+
unknownCalls: cards.reduce((sum, card) => sum + card.unknownCalls, 0),
|
|
96
|
+
},
|
|
97
|
+
cards: cards.slice(0, 50),
|
|
98
|
+
experts: [...byExpert.values()].map((row) => ({ ...row, cost: round(row.cost) })).sort((a, b) => b.total - a.total),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 会话用量解析:从 dsh 的 session.v4.jsonl 里把每次模型调用的 token 取出来。
|
|
2
|
+
// 纯函数,方便单测;读文件那半在 session-usage.js。
|
|
3
|
+
//
|
|
4
|
+
// 日志形状(实测):
|
|
5
|
+
// {"type":"assistant/message","seq":19,"time":...,"data":{"turn":1,"step":1,"message":{...},
|
|
6
|
+
// "usage":{"inputTokens":3033,"outputTokens":116,"cacheReadTokens":17024,"cacheWriteTokens":0,"totalTokens":20173},
|
|
7
|
+
// "stream":[{"type":"chunk","chunk":{"type":"usage",...}}]}}
|
|
8
|
+
// 同一条消息里 usage 会出现两次(消息体 + 流式 chunk),所以只认 assistant/message 上的那一份。
|
|
9
|
+
|
|
10
|
+
/** 从日志里挑出一条记录对应的用量;不是 assistant/message 就返回 null。 */
|
|
11
|
+
export function usageOfEntry(entry) {
|
|
12
|
+
if (entry === null || typeof entry !== 'object') return null
|
|
13
|
+
if (entry.type !== 'assistant/message') return null
|
|
14
|
+
const data = entry.data ?? {}
|
|
15
|
+
const usage = data.usage ?? data.message?.usage ?? null
|
|
16
|
+
if (usage === null || typeof usage !== 'object') return null
|
|
17
|
+
const number = (value) => {
|
|
18
|
+
const parsed = Number(value)
|
|
19
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
|
|
20
|
+
}
|
|
21
|
+
const input = number(usage.inputTokens)
|
|
22
|
+
const output = number(usage.outputTokens)
|
|
23
|
+
const cacheRead = number(usage.cacheReadTokens)
|
|
24
|
+
const cacheWrite = number(usage.cacheWriteTokens)
|
|
25
|
+
return {
|
|
26
|
+
at: Number(entry.time) || 0,
|
|
27
|
+
turn: Number(data.turn) || 0,
|
|
28
|
+
model: String(data.model ?? entry.model ?? ''),
|
|
29
|
+
input,
|
|
30
|
+
output,
|
|
31
|
+
cacheRead,
|
|
32
|
+
cacheWrite,
|
|
33
|
+
total: number(usage.totalTokens) || input + output + cacheRead + cacheWrite,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 解析整个 jsonl(已解压的行数组),返回每条消息的用量。坏行跳过,不抛。 */
|
|
38
|
+
export function parseUsageLines(lines) {
|
|
39
|
+
const out = []
|
|
40
|
+
// 模型名不挂在每条消息上:它在会话头 / 请求上下文里(data.header.config.model 或 data.model),
|
|
41
|
+
// 所以一边扫一边记,后面没有 model 的消息沿用最近一次看到的。
|
|
42
|
+
let model = ''
|
|
43
|
+
for (const line of Array.isArray(lines) ? lines : []) {
|
|
44
|
+
const trimmed = String(line ?? '').trim()
|
|
45
|
+
if (trimmed === '' || trimmed[0] !== '{') continue
|
|
46
|
+
let entry = null
|
|
47
|
+
try { entry = JSON.parse(trimmed) } catch { continue }
|
|
48
|
+
const declared = entry?.data?.model ?? entry?.data?.header?.config?.model
|
|
49
|
+
if (typeof declared === 'string' && declared !== '') model = declared
|
|
50
|
+
const usage = usageOfEntry(entry)
|
|
51
|
+
if (usage !== null) out.push({ ...usage, model: usage.model === '' ? model : usage.model })
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 汇总:token 数按类型分开,别把缓存命中混进普通输入。 */
|
|
57
|
+
export function summarizeUsage(entries) {
|
|
58
|
+
const list = Array.isArray(entries) ? entries : []
|
|
59
|
+
const sum = (field) => list.reduce((total, item) => total + (Number(item?.[field]) || 0), 0)
|
|
60
|
+
return {
|
|
61
|
+
calls: list.length,
|
|
62
|
+
input: sum('input'),
|
|
63
|
+
output: sum('output'),
|
|
64
|
+
cacheRead: sum('cacheRead'),
|
|
65
|
+
cacheWrite: sum('cacheWrite'),
|
|
66
|
+
total: sum('total'),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 订阅套餐通道:*-coding / *-token-plan —— 走包月额度,token 照记但不算钱。 */
|
|
71
|
+
export function isSubscriptionModel(model) {
|
|
72
|
+
return /-(coding|token-plan)$/.test(String(model ?? ''))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 价目表按 model 查:prices 来自 config.costs.prices(单位:元 / 百万 token)。
|
|
77
|
+
* 查不到就返回 null —— 宁可显示"未知",也不能按 0 算成免费。
|
|
78
|
+
*/
|
|
79
|
+
export function priceOf(prices, model) {
|
|
80
|
+
const name = String(model ?? '')
|
|
81
|
+
if (name === '') return null
|
|
82
|
+
const list = Array.isArray(prices) ? prices : []
|
|
83
|
+
const hit = list.find((item) => String(item?.model ?? '') === name)
|
|
84
|
+
if (hit === undefined) return null
|
|
85
|
+
return {
|
|
86
|
+
input: Number(hit.input) || 0,
|
|
87
|
+
output: Number(hit.output) || 0,
|
|
88
|
+
cacheRead: Number(hit.cacheRead) || 0,
|
|
89
|
+
cacheWrite: Number(hit.cacheWrite) || 0,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 算钱:命中价目表才给数字,订阅通道给 0(明确"不额外收费"),
|
|
95
|
+
* 其它情况给 null(未知)。
|
|
96
|
+
*/
|
|
97
|
+
export function costOf(prices, entries) {
|
|
98
|
+
const list = Array.isArray(entries) ? entries : []
|
|
99
|
+
if (list.length === 0) return { cost: 0, priced: true, unknownCalls: 0 }
|
|
100
|
+
let cost = 0
|
|
101
|
+
let unknownCalls = 0
|
|
102
|
+
for (const item of list) {
|
|
103
|
+
if (isSubscriptionModel(item.model)) continue
|
|
104
|
+
const price = priceOf(prices, item.model)
|
|
105
|
+
if (price === null) { unknownCalls += 1; continue }
|
|
106
|
+
const million = 1000000
|
|
107
|
+
cost += (Number(item.input) || 0) / million * price.input
|
|
108
|
+
cost += (Number(item.output) || 0) / million * price.output
|
|
109
|
+
cost += (Number(item.cacheRead) || 0) / million * price.cacheRead
|
|
110
|
+
cost += (Number(item.cacheWrite) || 0) / million * price.cacheWrite
|
|
111
|
+
}
|
|
112
|
+
return { cost: unknownCalls > 0 && cost === 0 ? null : Math.round(cost * 10000) / 10000, priced: unknownCalls === 0, unknownCalls }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function sanitizePrices(raw) {
|
|
116
|
+
if (!Array.isArray(raw)) return []
|
|
117
|
+
return raw.slice(0, 50).map((item) => ({
|
|
118
|
+
model: String(item?.model ?? '').trim().slice(0, 80),
|
|
119
|
+
input: Number(item?.input) || 0,
|
|
120
|
+
output: Number(item?.output) || 0,
|
|
121
|
+
cacheRead: Number(item?.cacheRead) || 0,
|
|
122
|
+
cacheWrite: Number(item?.cacheWrite) || 0,
|
|
123
|
+
})).filter((item) => item.model !== '')
|
|
124
|
+
}
|