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/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
- 东西。美元数字是按公开价格的估算;`dsh-cost-meter` 这样的成本插件会显示真实花费。
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 rewritten prompt' },
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. Rewrite it to be more',
351
- 'precise, complete, and token-efficient, while PRESERVING its intent.',
352
- 'Do not add requirements the user did not ask for; fill in only what is',
353
- 'genuinely underspecified (scope, constraints, format, acceptance criteria).',
354
- 'Learn from the user\'s recurring mistake patterns when provided.',
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 rewritten prompt>",',
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 finish = ''
1011
- for await (const chunk of llm.stream({
1012
- provider,
1013
- model,
1014
- messages: [message],
1015
- system,
1016
- maxTokens,
1017
- signal: controller.signal,
1018
- ...(effort !== undefined ? { reasoningEffort: effort } : {}),
1019
- ...(tool !== undefined ? { tools: [tool] } : {}),
1020
- ...(typeof sessionId === 'string' && sessionId.length > 0 ? { sessionId } : {}),
1021
- })) {
1022
- if (chunk === null || typeof chunk !== 'object') continue
1023
- if (chunk.type === 'finish' && typeof chunk.reason === 'string') finish = chunk.reason
1024
- if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
1025
- else if (chunk.type === 'tool-call-delta' && typeof chunk.argumentsDelta === 'string') toolDeltas += chunk.argumentsDelta
1026
- else if (chunk.type === 'block-end' && chunk.block !== null && typeof chunk.block === 'object'
1027
- && chunk.block.type === 'tool-call' && typeof chunk.block.arguments === 'string' && toolArgs === '') {
1028
- toolArgs = chunk.block.arguments
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 && finish.length > 0 && finish !== 'stop') {
1034
- console.warn('[tacit] model call ended without an answer (finish: ' + finish + ', maxTokens: ' + String(maxTokens) + ')')
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
- // Nothing to flush: all writes are atomic and complete at call time.
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
+ }