dsh-tacit 0.2.2 → 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 +24 -3
- package/client/client.js +1822 -108
- package/docs/README.md +3 -3
- package/docs/README.zh.md +7 -6
- package/lib/analyze.js +289 -49
- package/lib/index.js +16 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +15 -2
- package/lib/schema.js +187 -1
- package/lib/service.js +542 -103
- package/lib/store.js +164 -3
- package/lib/usage.js +708 -0
- package/package.json +11 -5
|
@@ -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
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
* into one ctx.effect so a fiber unload removes every route.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Run `fn(service)` as soon as `serviceName` exists — immediately when it is
|
|
15
|
+
* already registered, otherwise once on the next `internal/service` event for
|
|
16
|
+
* it (the listener removes itself). Exported so the service layer can wait on
|
|
17
|
+
* optional siblings (e.g. `costMeter`) the same way the routes wait on
|
|
18
|
+
* `webServer`.
|
|
19
|
+
*/
|
|
20
|
+
export function withService(ctx, serviceName, fn) {
|
|
14
21
|
const existing = ctx.get !== undefined && typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined
|
|
15
22
|
if (existing !== undefined && existing !== null) {
|
|
16
23
|
fn(existing)
|
|
@@ -126,18 +133,24 @@ export function registerWebRoutes(ctx, service) {
|
|
|
126
133
|
}))
|
|
127
134
|
}
|
|
128
135
|
|
|
129
|
-
route('POST', '/api/tacit/state', () => service.getState())
|
|
136
|
+
route('POST', '/api/tacit/state', (body) => service.getState(body))
|
|
130
137
|
route('POST', '/api/tacit/reports', (body) => service.getReports(body))
|
|
131
138
|
route('POST', '/api/tacit/history', (body) => service.listHistory(body))
|
|
132
139
|
route('POST', '/api/tacit/analyze', (body) => service.analyzeTurn(body))
|
|
140
|
+
route('POST', '/api/tacit/analyze-batch', (body) => service.analyzeBatch(body))
|
|
133
141
|
route('POST', '/api/tacit/improve', (body) => service.improveDraft(body))
|
|
134
142
|
route('POST', '/api/tacit/feedback', (body) => service.feedback(body))
|
|
135
143
|
route('POST', '/api/tacit/applied', (body) => service.applied(body))
|
|
136
144
|
route('POST', '/api/tacit/directives', (body) => service.directives(body))
|
|
137
145
|
route('POST', '/api/tacit/stats', (body) => service.stats(body))
|
|
138
146
|
route('POST', '/api/tacit/bootstrap', (body) => service.bootstrap(body))
|
|
147
|
+
route('POST', '/api/tacit/bootstrap-preview', (body) => service.bootstrapPreview(body))
|
|
139
148
|
route('POST', '/api/tacit/config', (body) => service.updateConfig(body))
|
|
140
149
|
route('POST', '/api/tacit/clear', () => service.clearReports())
|
|
150
|
+
route('POST', '/api/tacit/usage', (body) => service.usageReport(body))
|
|
151
|
+
route('POST', '/api/tacit/usage-run', (body) => service.usageRun(body))
|
|
152
|
+
route('POST', '/api/tacit/usage-clear', () => service.usageClear())
|
|
153
|
+
route('POST', '/api/tacit/pricing-refresh', () => service.pricingRefresh())
|
|
141
154
|
|
|
142
155
|
ctx.effect(() => () => {
|
|
143
156
|
for (const dispose of disposers.splice(0).reverse()) {
|