dsh-tacit 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/client/client.js +1739 -91
- package/docs/README.zh.md +3 -3
- package/lib/analyze.js +114 -30
- package/lib/index.js +3 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +14 -1
- package/lib/schema.js +170 -0
- package/lib/service.js +349 -60
- package/lib/store.js +163 -3
- package/lib/usage.js +708 -0
- package/package.json +1 -1
package/docs/README.zh.md
CHANGED
|
@@ -45,7 +45,7 @@ DeepSeek API Key(Tacit 从不读取它)。
|
|
|
45
45
|
| **零点击学习** | 不顺的轮次和你自己的纠正会在后台被分析,并带上上一轮作为上下文;自动分析每天有上限(默认 30 次) | 每次 $0.001–0.003 |
|
|
46
46
|
| **指令要靠表现留下** | 学到的指令作为一小段系统提示注入,你可以查看、编辑、开关或删除;新指令先是*候选*,如果你的不顺轮次比例变差就会退役 | 免费 |
|
|
47
47
|
| **✨ 改进** | 输入框里的按钮,用 Tacit 学到的东西重写当前草稿,带前后对比预览和 👍/👎 | 每次点击 $0.001–0.002 |
|
|
48
|
-
| **测量而非猜测** | 设置页显示你的真实趋势:不顺轮次比例和每轮 token 数,最早 20 轮 vs 最近 20
|
|
48
|
+
| **测量而非猜测** | 设置页显示你的真实趋势:不顺轮次比例和每轮 token 数,最早 20 轮 vs 最近 20 轮——以及 Tacit 自己的花费:每次调用都被计量并按目录价定价,显示在设置页里 | 免费 |
|
|
49
49
|
|
|
50
50
|
## 它是怎么工作的
|
|
51
51
|
|
|
@@ -63,8 +63,8 @@ DeepSeek API Key(Tacit 从不读取它)。
|
|
|
63
63
|
|
|
64
64
|
Tacit 从不接触你的 API Key(所有调用都经过 harness 自己的模型服务),只通过你会话
|
|
65
65
|
自己的 provider 路由调用白名单里的官方模型,报告和指令都存放在
|
|
66
|
-
`~/.dsh/storages/tacit
|
|
67
|
-
|
|
66
|
+
`~/.dsh/storages/tacit/`,拒绝对自身路由的跨站请求,除了自己的报告和过期的用量记录
|
|
67
|
+
之外从不删除任何东西。美元数字是按公开价格的估算;`dsh-cost-meter` 这样的成本插件会显示真实花费。
|
|
68
68
|
完整的数据流与成本表、以及坦白的限制清单(英文):
|
|
69
69
|
[Privacy, cost & limitations](https://github.com/hackernotfound/dsh-tacit/blob/main/docs/privacy-and-cost.md)。
|
|
70
70
|
|
package/lib/analyze.js
CHANGED
|
@@ -90,11 +90,11 @@ export const ANALYSIS_TOOL = {
|
|
|
90
90
|
|
|
91
91
|
export const IMPROVE_TOOL = {
|
|
92
92
|
name: 'improved',
|
|
93
|
-
description: 'Submit the rewritten draft.',
|
|
93
|
+
description: 'Submit the final rewritten draft, or the draft verbatim when it is already complete.',
|
|
94
94
|
parameters: {
|
|
95
95
|
type: 'object',
|
|
96
96
|
properties: {
|
|
97
|
-
improved: { type: 'string', description: 'the
|
|
97
|
+
improved: { type: 'string', description: 'the final prompt (the draft verbatim if already complete)' },
|
|
98
98
|
rationale: { type: 'string', description: '1-2 sentences on what changed and why' },
|
|
99
99
|
},
|
|
100
100
|
required: ['improved', 'rationale'],
|
|
@@ -347,18 +347,52 @@ export const ANALYSIS_SYSTEM_PROMPT = [
|
|
|
347
347
|
|
|
348
348
|
export const IMPROVE_SYSTEM_PROMPT = [
|
|
349
349
|
'You are a prompt-improvement assistant inside DeepSeek Harness.',
|
|
350
|
-
'The user typed a draft prompt into the composer.
|
|
351
|
-
'
|
|
352
|
-
'
|
|
353
|
-
'
|
|
354
|
-
'
|
|
350
|
+
'The user typed a draft prompt for a coding agent into the composer. You get',
|
|
351
|
+
'ONE pass: return the prompt the user would arrive at after several rounds of',
|
|
352
|
+
'editing. Leave nothing for a second pass.',
|
|
353
|
+
'',
|
|
354
|
+
'A finished prompt has every item below (skip an item only when the draft or',
|
|
355
|
+
'context makes it obviously unnecessary):',
|
|
356
|
+
'- GOAL — one sentence: the outcome and what "done" looks like.',
|
|
357
|
+
'- CONTEXT — the concrete facts the agent would otherwise have to discover:',
|
|
358
|
+
' file paths, URLs, names, versions, decisions already made. Take them from',
|
|
359
|
+
' the draft and from RECENT CONVERSATION CONTEXT. Never invent facts.',
|
|
360
|
+
'- SCOPE — what is in and out; what to leave untouched.',
|
|
361
|
+
'- CONSTRAINTS — what not to do, limits, style, language, budget.',
|
|
362
|
+
'- OUTPUT FORMAT — the shape of the answer: list, table, diff, code only,',
|
|
363
|
+
' prioritized, length.',
|
|
364
|
+
'- EFFICIENCY — what the agent need not explore or verify, so it finishes',
|
|
365
|
+
' in fewer steps and tool calls.',
|
|
366
|
+
'',
|
|
367
|
+
'Rules:',
|
|
368
|
+
'- Preserve the user\'s intent; add nothing they did not ask for or clearly',
|
|
369
|
+
' imply.',
|
|
370
|
+
'- Only fill what is genuinely underspecified. Keep the draft\'s wording where',
|
|
371
|
+
' it already works.',
|
|
372
|
+
'- Be as short as completeness allows: no filler, no role preambles ("You are',
|
|
373
|
+
' an expert…"), no restating what the agent already knows.',
|
|
374
|
+
'- Follow the STYLE RULES, NEGATIVE FEEDBACK and RECURRING MISTAKE PATTERNS',
|
|
375
|
+
' when provided; they describe this user\'s habits.',
|
|
376
|
+
'- Silently check the draft against every item first, then write the whole',
|
|
377
|
+
' prompt once.',
|
|
378
|
+
'- FIXED POINT: if the draft already satisfies every item, return it',
|
|
379
|
+
' VERBATIM (character for character) with the rationale "Already complete."',
|
|
380
|
+
' Never make cosmetic edits — a prompt you improved must come back',
|
|
381
|
+
' unchanged when improved again.',
|
|
382
|
+
'',
|
|
383
|
+
'Example',
|
|
384
|
+
'draft: what do you think we can do to market our plugin today',
|
|
385
|
+
'context: the plugin was just published at https://github.com/x/y',
|
|
386
|
+
'improved: The plugin is now public at https://github.com/x/y. How do we',
|
|
387
|
+
'market it and get it noticed? Give a prioritized list of concrete actions,',
|
|
388
|
+
'easiest first, biggest impact last, with the expected effort for each.',
|
|
355
389
|
'',
|
|
356
390
|
'RESPONSE FORMAT — this is mandatory and machine-parsed:',
|
|
357
391
|
'Your ENTIRE response must be ONE JSON object and nothing else. No preamble,',
|
|
358
392
|
'no narration, no explanations outside the JSON, no markdown fences.',
|
|
359
393
|
'Start directly with "{" and end with "}".',
|
|
360
394
|
'{',
|
|
361
|
-
' "improved": "<the
|
|
395
|
+
' "improved": "<the final prompt — the draft verbatim if already complete>",',
|
|
362
396
|
' "rationale": "<1-2 sentences on what you changed and why>"',
|
|
363
397
|
'}',
|
|
364
398
|
'',
|
|
@@ -989,8 +1023,12 @@ export function lastDownReasons(profile, n = 3) {
|
|
|
989
1023
|
* NEVER returned as the answer (chain of thought is not a report). A
|
|
990
1024
|
* deployment that rejects the reasoning effort gets one retry without it.
|
|
991
1025
|
* Returns the answer text ('' when the model produced nothing usable).
|
|
1026
|
+
*
|
|
1027
|
+
* When `onUsage(record)` is given, it is called once per underlying `run()`
|
|
1028
|
+
* (twice on the reasoning-effort retry) with a usage/cost record. A sink
|
|
1029
|
+
* that throws never fails the model call.
|
|
992
1030
|
*/
|
|
993
|
-
export async function callCoachModel(ctx, { provider, model, system, userText, maxTokens, timeoutMs, tool, sessionId, reasoningEffort = COACH_REASONING_EFFORT }) {
|
|
1031
|
+
export async function callCoachModel(ctx, { provider, model, system, userText, maxTokens, timeoutMs, tool, sessionId, reasoningEffort = COACH_REASONING_EFFORT, onUsage }) {
|
|
994
1032
|
const llm = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get('llm') : undefined
|
|
995
1033
|
if (llm === undefined || typeof llm.stream !== 'function') {
|
|
996
1034
|
const error = new Error('the harness LLM service is unavailable')
|
|
@@ -1003,35 +1041,81 @@ export async function callCoachModel(ctx, { provider, model, system, userText, m
|
|
|
1003
1041
|
content: [{ type: 'text', text: userText }],
|
|
1004
1042
|
source: { kind: 'plugin', plugin: 'dsh-tacit' },
|
|
1005
1043
|
})
|
|
1044
|
+
const toCount = (value) => (Number.isFinite(value) && value >= 0 ? value : 0)
|
|
1006
1045
|
const run = async (effort) => {
|
|
1046
|
+
const startedAt = Date.now()
|
|
1007
1047
|
let text = ''
|
|
1008
1048
|
let toolArgs = ''
|
|
1009
1049
|
let toolDeltas = ''
|
|
1010
|
-
let
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1050
|
+
let finishKind = ''
|
|
1051
|
+
let failure = null
|
|
1052
|
+
let usage = null
|
|
1053
|
+
let thrown = null
|
|
1054
|
+
try {
|
|
1055
|
+
for await (const chunk of llm.stream({
|
|
1056
|
+
provider,
|
|
1057
|
+
model,
|
|
1058
|
+
messages: [message],
|
|
1059
|
+
system,
|
|
1060
|
+
maxTokens,
|
|
1061
|
+
signal: controller.signal,
|
|
1062
|
+
...(effort !== undefined ? { reasoningEffort: effort } : {}),
|
|
1063
|
+
...(tool !== undefined ? { tools: [tool] } : {}),
|
|
1064
|
+
...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
|
|
1065
|
+
})) {
|
|
1066
|
+
if (chunk === null || typeof chunk !== 'object') continue
|
|
1067
|
+
if (chunk.type === 'usage' && chunk.usage !== null && typeof chunk.usage === 'object') {
|
|
1068
|
+
usage = {
|
|
1069
|
+
inputTokens: toCount(chunk.usage.inputTokens),
|
|
1070
|
+
outputTokens: toCount(chunk.usage.outputTokens),
|
|
1071
|
+
cacheReadTokens: toCount(chunk.usage.cacheReadTokens),
|
|
1072
|
+
cacheWriteTokens: toCount(chunk.usage.cacheWriteTokens),
|
|
1073
|
+
reasoningTokens: toCount(chunk.usage.reasoningTokens),
|
|
1074
|
+
}
|
|
1075
|
+
} else if (chunk.type === 'finish') {
|
|
1076
|
+
finishKind = typeof chunk.reason === 'string' ? chunk.reason : (chunk.reason?.kind ?? '')
|
|
1077
|
+
failure = chunk.reason?.failure ?? null
|
|
1078
|
+
} else if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
|
1079
|
+
else if (chunk.type === 'tool-call-delta' && typeof chunk.argumentsDelta === 'string') toolDeltas += chunk.argumentsDelta
|
|
1080
|
+
else if (chunk.type === 'block-end' && chunk.block !== null && typeof chunk.block === 'object'
|
|
1081
|
+
&& chunk.block.type === 'tool-call' && typeof chunk.block.arguments === 'string' && toolArgs === '') {
|
|
1082
|
+
toolArgs = chunk.block.arguments
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (finishKind === 'error' || finishKind === 'aborted') {
|
|
1086
|
+
thrown = Object.assign(new Error(failure?.message ?? finishKind), { code: failure?.code ?? finishKind.toUpperCase() })
|
|
1087
|
+
throw thrown
|
|
1088
|
+
}
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
thrown = thrown ?? error
|
|
1091
|
+
throw error
|
|
1092
|
+
} finally {
|
|
1093
|
+
if (typeof onUsage === 'function') {
|
|
1094
|
+
const code = (thrown !== null && typeof thrown === 'object' ? thrown.code : undefined) ?? failure?.code ?? ''
|
|
1095
|
+
const status = thrown !== null || finishKind === 'error' || finishKind === 'aborted'
|
|
1096
|
+
? 'failed'
|
|
1097
|
+
: (usage === null ? 'unmetered' : 'ok')
|
|
1098
|
+
try {
|
|
1099
|
+
onUsage({
|
|
1100
|
+
startedAt,
|
|
1101
|
+
durationMs: Date.now() - startedAt,
|
|
1102
|
+
model,
|
|
1103
|
+
provider,
|
|
1104
|
+
reasoningEffort: effort ?? null,
|
|
1105
|
+
finish: finishKind,
|
|
1106
|
+
status,
|
|
1107
|
+
code,
|
|
1108
|
+
usage,
|
|
1109
|
+
})
|
|
1110
|
+
} catch {
|
|
1111
|
+
// a sink bug must never fail a model call
|
|
1112
|
+
}
|
|
1029
1113
|
}
|
|
1030
1114
|
}
|
|
1031
1115
|
if (toolArgs.length > 0) return toolArgs
|
|
1032
1116
|
if (toolDeltas.length > 0) return toolDeltas
|
|
1033
|
-
if (text.length === 0 &&
|
|
1034
|
-
console.warn('[tacit] model call ended without an answer (finish: ' +
|
|
1117
|
+
if (text.length === 0 && finishKind.length > 0 && finishKind !== 'stop') {
|
|
1118
|
+
console.warn('[tacit] model call ended without an answer (finish: ' + finishKind + ', maxTokens: ' + String(maxTokens) + ')')
|
|
1035
1119
|
}
|
|
1036
1120
|
return text
|
|
1037
1121
|
}
|
package/lib/index.js
CHANGED
|
@@ -94,6 +94,8 @@ export function apply(ctx, config) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
ctx.effect(() => () => {
|
|
97
|
-
//
|
|
97
|
+
// Reports and the profile are written atomically at call time; only the
|
|
98
|
+
// usage ledger keeps debounced state, so an unload flushes it.
|
|
99
|
+
service.usage.flush()
|
|
98
100
|
}, 'tacit: dispose')
|
|
99
101
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — the price table behind the usage tracker.
|
|
5
|
+
*
|
|
6
|
+
* Wraps the pure `lib/pricing.js` with one optional input: the sibling
|
|
7
|
+
* `dsh-cost-meter` plugin's `costMeter` service. When that service is
|
|
8
|
+
* installed and hands over a usable state, its prices win; otherwise the
|
|
9
|
+
* bundled DeepSeek list prices apply. The service is fully duck-typed and
|
|
10
|
+
* never trusted: `refresh()` never throws, never blocks longer than
|
|
11
|
+
* `timeoutMs`, and any failure (absent, throwing, hanging, junk) leaves the
|
|
12
|
+
* source on the bundled table with a human-readable `error`.
|
|
13
|
+
*
|
|
14
|
+
* A model call must never wait on this — the service refreshes it in the
|
|
15
|
+
* background and every `priceCall` reads whatever snapshot is current.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { priceCall as priceCallWith, normalizeCostMeterState, tierAt, PRICES_AS_OF, BUNDLED_PRICES } from './pricing.js'
|
|
19
|
+
import { COACH_MODELS } from './schema.js'
|
|
20
|
+
|
|
21
|
+
/** A `{cacheHit, cacheMiss, output}` triple as a fresh object (never a reference into a shared table). */
|
|
22
|
+
function copyTriple(triple) {
|
|
23
|
+
return { cacheHit: triple.cacheHit, cacheMiss: triple.cacheMiss, output: triple.output }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A normalized snapshot is only worth using if it actually carries a price. */
|
|
27
|
+
function hasPrices(snapshot) {
|
|
28
|
+
if (snapshot === null || typeof snapshot !== 'object') return false
|
|
29
|
+
const models = snapshot.models !== null && typeof snapshot.models === 'object' ? Object.keys(snapshot.models) : []
|
|
30
|
+
const providers = snapshot.providers !== null && typeof snapshot.providers === 'object' ? Object.keys(snapshot.providers) : []
|
|
31
|
+
return models.length > 0 || providers.length > 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whatever was thrown/rejected, as a short message. */
|
|
35
|
+
function messageOf(error) {
|
|
36
|
+
if (error !== null && typeof error === 'object' && typeof error.message === 'string' && error.message.length > 0) return error.message
|
|
37
|
+
return String(error)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reject after `ms`; the timer is unref'd so a pending refresh never holds the process open. */
|
|
41
|
+
function rejectAfter(ms) {
|
|
42
|
+
let timer = null
|
|
43
|
+
const promise = new Promise((_resolve, reject) => {
|
|
44
|
+
timer = setTimeout(() => reject(new Error(`costMeter getState() timed out after ${ms}ms`)), ms)
|
|
45
|
+
if (typeof timer?.unref === 'function') timer.unref()
|
|
46
|
+
})
|
|
47
|
+
return { promise, cancel: () => { if (timer !== null) clearTimeout(timer) } }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The price source the tracker and the reports read from.
|
|
52
|
+
* `now`/`timeoutMs` are injectable so tests can drive the clock and the
|
|
53
|
+
* hang path without waiting five seconds.
|
|
54
|
+
*/
|
|
55
|
+
export function createPricingSource(ctx, { now = Date.now, timeoutMs = 5000 } = {}) {
|
|
56
|
+
const state = { snapshot: null, source: 'bundled', refreshedAt: 0, error: '' }
|
|
57
|
+
|
|
58
|
+
/** Drop back to the bundled table, remembering why. */
|
|
59
|
+
function fallBack(error) {
|
|
60
|
+
state.snapshot = null
|
|
61
|
+
state.source = 'bundled'
|
|
62
|
+
state.refreshedAt = 0
|
|
63
|
+
state.error = error
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function refresh() {
|
|
67
|
+
const service = ctx !== null && typeof ctx === 'object' && typeof ctx.get === 'function' ? ctx.get('costMeter') : undefined
|
|
68
|
+
if (service === undefined || service === null || typeof service.getState !== 'function') {
|
|
69
|
+
fallBack('the costMeter service is not available — using bundled list prices')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
const timeout = rejectAfter(timeoutMs)
|
|
73
|
+
let raw
|
|
74
|
+
try {
|
|
75
|
+
raw = await Promise.race([Promise.resolve(service.getState()), timeout.promise])
|
|
76
|
+
} catch (error) {
|
|
77
|
+
fallBack(messageOf(error))
|
|
78
|
+
return
|
|
79
|
+
} finally {
|
|
80
|
+
timeout.cancel()
|
|
81
|
+
}
|
|
82
|
+
const snapshot = normalizeCostMeterState(raw)
|
|
83
|
+
if (!hasPrices(snapshot)) {
|
|
84
|
+
fallBack('the costMeter state carried no usable prices — using bundled list prices')
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
state.snapshot = snapshot
|
|
88
|
+
state.source = 'costMeter'
|
|
89
|
+
state.refreshedAt = now()
|
|
90
|
+
state.error = ''
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** {@link priceCallWith} against the current snapshot (bundled when there is none). */
|
|
94
|
+
function priceCall(args) {
|
|
95
|
+
return priceCallWith({ ...args, table: state.snapshot })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What the Pricing card shows about the source itself. `tierNow` is read
|
|
100
|
+
* off the same snapshot `priceCall` prices against — a cost-meter table
|
|
101
|
+
* that turns peak pricing off, shifts the windows, or dates them into the
|
|
102
|
+
* future must not leave the card quoting the bundled schedule.
|
|
103
|
+
*/
|
|
104
|
+
function status() {
|
|
105
|
+
const snapshot = state.snapshot
|
|
106
|
+
const tierNow = snapshot === null
|
|
107
|
+
? tierAt(now())
|
|
108
|
+
: tierAt(now(), { windows: snapshot.windows, effectiveAtMs: snapshot.effectiveAtMs, peakEnabled: snapshot.peakEnabled !== false })
|
|
109
|
+
return {
|
|
110
|
+
source: state.source,
|
|
111
|
+
asOf: typeof snapshot?.asOf === 'string' ? snapshot.asOf : PRICES_AS_OF,
|
|
112
|
+
refreshedAt: state.refreshedAt,
|
|
113
|
+
tierNow,
|
|
114
|
+
error: state.error,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `{model: {offPeak, peak}}` for both coach models — snapshot first, bundled per model otherwise. */
|
|
119
|
+
function rates() {
|
|
120
|
+
const models = state.snapshot?.models
|
|
121
|
+
const out = {}
|
|
122
|
+
for (const model of COACH_MODELS) {
|
|
123
|
+
const entry = models !== null && typeof models === 'object' ? models[model] : undefined
|
|
124
|
+
const source = entry !== null && typeof entry === 'object' && entry.offPeak !== undefined && entry.peak !== undefined
|
|
125
|
+
? entry
|
|
126
|
+
: BUNDLED_PRICES[model]
|
|
127
|
+
out[model] = { offPeak: copyTriple(source.offPeak), peak: copyTriple(source.peak) }
|
|
128
|
+
}
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { refresh, priceCall, status, rates }
|
|
133
|
+
}
|
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 hackernotfound — https://github.com/hackernotfound/dsh-tacit
|
|
3
|
+
/**
|
|
4
|
+
* dsh-tacit — pure pricing (no I/O).
|
|
5
|
+
*
|
|
6
|
+
* Prices one model call from its token usage against either the bundled
|
|
7
|
+
* DeepSeek list prices (peak / off-peak / Beijing-weekend tiers) or a price
|
|
8
|
+
* table sourced from the `dsh-cost-meter` plugin's state. Nothing here
|
|
9
|
+
* touches the network, the store, or the service — `lib/pricing-source.js`
|
|
10
|
+
* is responsible for fetching the cost-meter state, normalizing it with
|
|
11
|
+
* `normalizeCostMeterState`, and handing the result in as `table`.
|
|
12
|
+
*
|
|
13
|
+
* Tier is decided once, at the request's start time (`atMs`) — not at
|
|
14
|
+
* finish, so a call that straddles a boundary is priced consistently.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const PRICES_AS_OF = '2026-08-22'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `reasoningTokens` (DeepSeek adapter) is always a subset of `outputTokens`,
|
|
21
|
+
* never a separate quantity — so it must not be billed again. Kept as a
|
|
22
|
+
* named constant (rather than inlined `false`) so it can be flipped if a
|
|
23
|
+
* future adapter ever reports reasoning as additional to output.
|
|
24
|
+
*/
|
|
25
|
+
export const REASONING_BILLED_SEPARATELY = false
|
|
26
|
+
|
|
27
|
+
/** Provider ids that route through DeepSeek's own API (bundled list prices apply). */
|
|
28
|
+
export const OFFICIAL_PROVIDERS = ['deepseek-official', 'deepseek']
|
|
29
|
+
|
|
30
|
+
/** USD per 1M tokens, as of {@link PRICES_AS_OF}. */
|
|
31
|
+
export const BUNDLED_PRICES = {
|
|
32
|
+
'deepseek-v4-flash': {
|
|
33
|
+
offPeak: { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 },
|
|
34
|
+
peak: { cacheHit: 0.014, cacheMiss: 0.44, output: 1.32 },
|
|
35
|
+
},
|
|
36
|
+
'deepseek-v4-pro': {
|
|
37
|
+
offPeak: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 },
|
|
38
|
+
peak: { cacheHit: 0.044, cacheMiss: 1.32, output: 3.96 },
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Peak hour windows, UTC, `[start, end)`. */
|
|
43
|
+
export const PEAK_WINDOWS_UTC = [{ start: 1, end: 4 }, { start: 6, end: 10 }]
|
|
44
|
+
|
|
45
|
+
/** Beijing-weekend off-peak rule only applies from this moment on. */
|
|
46
|
+
export const WEEKEND_OFFPEAK_FROM = Date.parse('2026-08-22T16:00:00Z')
|
|
47
|
+
|
|
48
|
+
const MS_PER_HOUR = 3600 * 1000
|
|
49
|
+
|
|
50
|
+
function isFiniteNumber(value) {
|
|
51
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isPositiveNumber(value) {
|
|
55
|
+
return isFiniteNumber(value) && value > 0
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlainObject(value) {
|
|
59
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Is the day-of-week of `ms + 8h`, in UTC, a Saturday or Sunday (Beijing calendar)? */
|
|
63
|
+
export function isBeijingWeekend(ms) {
|
|
64
|
+
if (!isFiniteNumber(ms)) return false
|
|
65
|
+
const day = new Date(ms + 8 * MS_PER_HOUR).getUTCDay()
|
|
66
|
+
return day === 0 || day === 6
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The pricing tier in effect at `ms`. `offPeak` when `!peakEnabled`, when
|
|
71
|
+
* `ms < effectiveAtMs`, when it's a Beijing weekend at/after
|
|
72
|
+
* {@link WEEKEND_OFFPEAK_FROM}, or when the UTC hour falls outside every
|
|
73
|
+
* window; `peak` otherwise.
|
|
74
|
+
*/
|
|
75
|
+
export function tierAt(ms, { windows = PEAK_WINDOWS_UTC, effectiveAtMs = 0, peakEnabled = true } = {}) {
|
|
76
|
+
if (!peakEnabled) return 'offPeak'
|
|
77
|
+
if (!isFiniteNumber(ms)) return 'offPeak'
|
|
78
|
+
if (isFiniteNumber(effectiveAtMs) && ms < effectiveAtMs) return 'offPeak'
|
|
79
|
+
if (ms >= WEEKEND_OFFPEAK_FROM && isBeijingWeekend(ms)) return 'offPeak'
|
|
80
|
+
|
|
81
|
+
const hour = new Date(ms).getUTCHours()
|
|
82
|
+
const activeWindows = Array.isArray(windows) ? windows : PEAK_WINDOWS_UTC
|
|
83
|
+
const inWindow = activeWindows.some(
|
|
84
|
+
(w) => isPlainObject(w) && isFiniteNumber(w.start) && isFiniteNumber(w.end) && hour >= w.start && hour < w.end,
|
|
85
|
+
)
|
|
86
|
+
return inWindow ? 'peak' : 'offPeak'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Does `provider` route through DeepSeek's own API (as opposed to a
|
|
91
|
+
* proxy/custom route)? Case-folded: a harness reporting `DeepSeek-Official`
|
|
92
|
+
* names the same route, and an exact match would leave its calls unpriced.
|
|
93
|
+
*/
|
|
94
|
+
export function isOfficialRoute(provider) {
|
|
95
|
+
return typeof provider === 'string' && OFFICIAL_PROVIDERS.includes(provider.toLowerCase())
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A well-formed `{cacheHit, cacheMiss, output}` triple, or `null`. */
|
|
99
|
+
function isRateTriple(triple) {
|
|
100
|
+
return isPlainObject(triple) && isFiniteNumber(triple.cacheHit) && isFiniteNumber(triple.cacheMiss) && isFiniteNumber(triple.output)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Look up `table.models[model]`'s tiered rates for an official-route call, or `null`. */
|
|
104
|
+
function costMeterModelRates(table, model, atMs) {
|
|
105
|
+
const models = isPlainObject(table.models) ? table.models : null
|
|
106
|
+
const entry = models === null ? undefined : models[model]
|
|
107
|
+
if (!isPlainObject(entry) || !isRateTriple(entry.offPeak) || !isRateTriple(entry.peak)) return null
|
|
108
|
+
const tier = tierAt(atMs, {
|
|
109
|
+
windows: table.windows,
|
|
110
|
+
effectiveAtMs: table.effectiveAtMs,
|
|
111
|
+
peakEnabled: table.peakEnabled !== false,
|
|
112
|
+
})
|
|
113
|
+
return { tier, rates: entry[tier] }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Look up `table.providers[provider][model]`'s flat rates, or `null`. The key
|
|
118
|
+
* is case-folded on both sides (`normalizeCostMeterState` lower-cases what it
|
|
119
|
+
* stores): the cost-meter table is a foreign schema whose provider ids are
|
|
120
|
+
* whatever its own user typed, and a casing mismatch must not silently drop
|
|
121
|
+
* the call through to bundled pricing.
|
|
122
|
+
*/
|
|
123
|
+
function costMeterProviderRates(table, provider, model) {
|
|
124
|
+
const providers = isPlainObject(table.providers) ? table.providers : null
|
|
125
|
+
const key = typeof provider === 'string' ? provider.toLowerCase() : provider
|
|
126
|
+
const providerEntry = providers === null ? undefined : providers[key]
|
|
127
|
+
const flat = isPlainObject(providerEntry) ? providerEntry[model] : undefined
|
|
128
|
+
return isRateTriple(flat) ? flat : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the price source and rates for one call. Resolution order:
|
|
133
|
+
* (1) `table.models[model]` when the route is official → `costMeter`, tier
|
|
134
|
+
* from `tierAt` using the table's own windows/effectiveAt/peakEnabled;
|
|
135
|
+
* (2) `table.providers[provider][model]` → `costMeter`, `tier:'flat'`;
|
|
136
|
+
* (3) the bundled list price, when the route is official and the model is
|
|
137
|
+
* known → `bundled`;
|
|
138
|
+
* (4) `null`.
|
|
139
|
+
*
|
|
140
|
+
* Always returns fresh copies of `rates` — never a reference into
|
|
141
|
+
* `BUNDLED_PRICES` or `table`.
|
|
142
|
+
*/
|
|
143
|
+
export function ratesFor({ model, provider, atMs, table = null }) {
|
|
144
|
+
if (isPlainObject(table)) {
|
|
145
|
+
if (isOfficialRoute(provider)) {
|
|
146
|
+
const found = costMeterModelRates(table, model, atMs)
|
|
147
|
+
if (found !== null) {
|
|
148
|
+
return {
|
|
149
|
+
source: 'costMeter',
|
|
150
|
+
tier: found.tier,
|
|
151
|
+
rates: { ...found.rates },
|
|
152
|
+
asOf: typeof table.asOf === 'string' ? table.asOf : PRICES_AS_OF,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const flat = costMeterProviderRates(table, provider, model)
|
|
157
|
+
if (flat !== null) {
|
|
158
|
+
return {
|
|
159
|
+
source: 'costMeter',
|
|
160
|
+
tier: 'flat',
|
|
161
|
+
rates: { ...flat },
|
|
162
|
+
asOf: typeof table.asOf === 'string' ? table.asOf : PRICES_AS_OF,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (isOfficialRoute(provider) && Object.prototype.hasOwnProperty.call(BUNDLED_PRICES, model)) {
|
|
168
|
+
const tier = tierAt(atMs)
|
|
169
|
+
return {
|
|
170
|
+
source: 'bundled',
|
|
171
|
+
tier,
|
|
172
|
+
rates: { ...BUNDLED_PRICES[model][tier] },
|
|
173
|
+
asOf: PRICES_AS_OF,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Read a usage field as a non-negative finite number, defaulting to 0. */
|
|
181
|
+
function tokensOf(usage, key) {
|
|
182
|
+
const value = usage === null || typeof usage !== 'object' ? undefined : usage[key]
|
|
183
|
+
return isFiniteNumber(value) && value >= 0 ? value : 0
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* USD cost of `usage` at `rates`. `inputTokens` is uncached input, billed at
|
|
188
|
+
* `cacheMiss`; `cacheReadTokens` + `cacheWriteTokens` bill at `cacheHit`;
|
|
189
|
+
* `reasoningTokens` is added to output only if {@link REASONING_BILLED_SEPARATELY}.
|
|
190
|
+
*/
|
|
191
|
+
export function costOf(usage, rates) {
|
|
192
|
+
const input = tokensOf(usage, 'inputTokens')
|
|
193
|
+
const output = tokensOf(usage, 'outputTokens')
|
|
194
|
+
const cacheRead = tokensOf(usage, 'cacheReadTokens')
|
|
195
|
+
const cacheWrite = tokensOf(usage, 'cacheWriteTokens')
|
|
196
|
+
const reasoning = tokensOf(usage, 'reasoningTokens')
|
|
197
|
+
const billedOutput = REASONING_BILLED_SEPARATELY ? output + reasoning : output
|
|
198
|
+
const cacheHitTokens = cacheRead + cacheWrite
|
|
199
|
+
|
|
200
|
+
const cacheHit = isFiniteNumber(rates?.cacheHit) ? rates.cacheHit : 0
|
|
201
|
+
const cacheMiss = isFiniteNumber(rates?.cacheMiss) ? rates.cacheMiss : 0
|
|
202
|
+
const outputRate = isFiniteNumber(rates?.output) ? rates.output : 0
|
|
203
|
+
|
|
204
|
+
return (input * cacheMiss + cacheHitTokens * cacheHit + billedOutput * outputRate) / 1e6
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Price one call end to end: resolve rates via {@link ratesFor}, then cost via {@link costOf}. */
|
|
208
|
+
export function priceCall({ model, provider, atMs, usage, table = null }) {
|
|
209
|
+
const priced = ratesFor({ model, provider, atMs, table })
|
|
210
|
+
if (priced === null) return null
|
|
211
|
+
return { ...priced, usd: costOf(usage, priced.rates) }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── costMeter state normalization ───────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
const DEFAULT_EXCHANGE_RATE = 7.2
|
|
217
|
+
|
|
218
|
+
/** A `{cacheHit, cacheMiss, output}` triple, currency-converted; `null` if any rate is invalid. */
|
|
219
|
+
function normalizeTriple(triple, currency, exchangeRate) {
|
|
220
|
+
if (!isRateTriple(triple) || triple.cacheHit < 0 || triple.cacheMiss < 0 || triple.output < 0) return null
|
|
221
|
+
const divisor = currency === 'CNY' ? (isPositiveNumber(exchangeRate) ? exchangeRate : DEFAULT_EXCHANGE_RATE) : 1
|
|
222
|
+
return { cacheHit: triple.cacheHit / divisor, cacheMiss: triple.cacheMiss / divisor, output: triple.output / divisor }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A model price entry (`{cacheHit,cacheMiss,output}` or `{offPeak,peak}`) → `{offPeak, peak}`, or `null`. */
|
|
226
|
+
function normalizeModelEntry(entry, currency, exchangeRate) {
|
|
227
|
+
if (!isPlainObject(entry)) return null
|
|
228
|
+
if (entry.offPeak !== undefined || entry.peak !== undefined) {
|
|
229
|
+
const offPeak = normalizeTriple(entry.offPeak, currency, exchangeRate)
|
|
230
|
+
const peak = normalizeTriple(entry.peak, currency, exchangeRate)
|
|
231
|
+
return offPeak === null || peak === null ? null : { offPeak, peak }
|
|
232
|
+
}
|
|
233
|
+
const flat = normalizeTriple(entry, currency, exchangeRate)
|
|
234
|
+
return flat === null ? null : { offPeak: flat, peak: { ...flat } }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A provider price entry `{input, cachedInput?, output}` → `{cacheMiss, cacheHit, output}`, or `null`. */
|
|
238
|
+
function normalizeProviderModelEntry(entry, currency, exchangeRate) {
|
|
239
|
+
if (!isPlainObject(entry)) return null
|
|
240
|
+
const cachedInput = entry.cachedInput !== undefined ? entry.cachedInput : entry.input
|
|
241
|
+
return normalizeTriple({ cacheMiss: entry.input, cacheHit: cachedInput, output: entry.output }, currency, exchangeRate)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Duck-typed normalization of the `dsh-cost-meter` service state (or its
|
|
246
|
+
* `config` sub-object) into the shape {@link ratesFor} consumes:
|
|
247
|
+
* `{models, providers, windows, effectiveAtMs, peakEnabled, asOf}`.
|
|
248
|
+
* Invalid rates (non-finite or negative) drop the entry that carries them;
|
|
249
|
+
* a non-object input (or config root) yields `null`.
|
|
250
|
+
*/
|
|
251
|
+
/** Epoch ms from either a number or an ISO-ish date string; 0 when it is neither. */
|
|
252
|
+
function normalizeMoment(value) {
|
|
253
|
+
if (isFiniteNumber(value)) return value
|
|
254
|
+
if (typeof value !== 'string' || value.length === 0) return 0
|
|
255
|
+
const parsed = Date.parse(value)
|
|
256
|
+
return Number.isFinite(parsed) ? parsed : 0
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function normalizeCostMeterState(state) {
|
|
260
|
+
if (!isPlainObject(state)) return null
|
|
261
|
+
const config = isPlainObject(state.config) ? state.config : state
|
|
262
|
+
if (!isPlainObject(config)) return null
|
|
263
|
+
|
|
264
|
+
const prices = isPlainObject(config.prices) ? config.prices : {}
|
|
265
|
+
const currency = prices.currency === 'CNY' ? 'CNY' : 'USD'
|
|
266
|
+
const exchangeRate = isPositiveNumber(config.exchangeRate) ? config.exchangeRate : DEFAULT_EXCHANGE_RATE
|
|
267
|
+
|
|
268
|
+
const models = {}
|
|
269
|
+
const rawModels = isPlainObject(prices.models) ? prices.models : {}
|
|
270
|
+
for (const [id, entry] of Object.entries(rawModels)) {
|
|
271
|
+
const normalized = normalizeModelEntry(entry, currency, exchangeRate)
|
|
272
|
+
if (normalized !== null) models[id] = normalized
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const providers = {}
|
|
276
|
+
const rawProviders = isPlainObject(prices.providers) ? prices.providers : {}
|
|
277
|
+
for (const [providerId, providerEntry] of Object.entries(rawProviders)) {
|
|
278
|
+
if (!isPlainObject(providerEntry)) continue
|
|
279
|
+
const rawProviderModels = isPlainObject(providerEntry.models) ? providerEntry.models : {}
|
|
280
|
+
const byModel = {}
|
|
281
|
+
for (const [modelId, entry] of Object.entries(rawProviderModels)) {
|
|
282
|
+
const normalized = normalizeProviderModelEntry(entry, currency, exchangeRate)
|
|
283
|
+
if (normalized !== null) byModel[modelId] = normalized
|
|
284
|
+
}
|
|
285
|
+
// Lower-cased so a lookup can match whatever case the caller reports; two
|
|
286
|
+
// keys differing only by case merge, and the first one listed wins.
|
|
287
|
+
const key = String(providerId).toLowerCase()
|
|
288
|
+
if (Object.keys(byModel).length > 0 && providers[key] === undefined) providers[key] = byModel
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// An array that filters down to nothing says as little as no array at all —
|
|
292
|
+
// and an empty window list would silently price every call off-peak.
|
|
293
|
+
const rawWindows = Array.isArray(config.peakWindows)
|
|
294
|
+
? config.peakWindows.filter((w) => isPlainObject(w) && isFiniteNumber(w.start) && isFiniteNumber(w.end))
|
|
295
|
+
: []
|
|
296
|
+
const windows = rawWindows.length > 0
|
|
297
|
+
? rawWindows.map((w) => ({ start: w.start, end: w.end }))
|
|
298
|
+
: PEAK_WINDOWS_UTC.map((w) => ({ ...w }))
|
|
299
|
+
|
|
300
|
+
const effectiveAtMs = normalizeMoment(config.peakEffectiveAt)
|
|
301
|
+
const peakEnabled = typeof config.peakEnabled === 'boolean' ? config.peakEnabled : true
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
models,
|
|
305
|
+
providers,
|
|
306
|
+
windows,
|
|
307
|
+
effectiveAtMs,
|
|
308
|
+
peakEnabled,
|
|
309
|
+
asOf: new Date().toISOString(),
|
|
310
|
+
}
|
|
311
|
+
}
|