dsh-tacit 0.2.3 → 0.4.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 +5 -5
- package/client/client.js +1793 -99
- package/docs/README.zh.md +3 -3
- package/lib/analyze.js +214 -43
- package/lib/index.js +4 -2
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +14 -1
- package/lib/schema.js +195 -11
- package/lib/service.js +431 -153
- package/lib/store.js +224 -3
- package/lib/usage.js +763 -0
- package/package.json +1 -1
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)
|
|
@@ -130,14 +137,20 @@ export function registerWebRoutes(ctx, service) {
|
|
|
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()) {
|
package/lib/schema.js
CHANGED
|
@@ -21,6 +21,16 @@ export const COACH_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
|
|
|
21
21
|
*/
|
|
22
22
|
export const COACH_PROVIDER = 'deepseek-official'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The model-call failure codes Tacit is allowed to put on the wire. The client
|
|
26
|
+
* renders `err.<code>` for whatever the envelope carries, so a raw provider
|
|
27
|
+
* code (`RATE_LIMIT`, `ERROR`, `ABORTED`, …) would surface as a literal
|
|
28
|
+
* `err.ABORTED` banner — `coachErrorCode` (`lib/service.js`) maps anything
|
|
29
|
+
* outside this list onto one of these. Every entry has an `err.*` key in both
|
|
30
|
+
* dictionaries (`client/src/10-i18n.js`, test-enforced).
|
|
31
|
+
*/
|
|
32
|
+
export const COACH_ERROR_CODES = ['no-llm', 'no-api-key', 'no-credit', 'rate-limited', 'timeout', 'empty-response', 'call-failed']
|
|
33
|
+
|
|
24
34
|
/**
|
|
25
35
|
* The loader-facing plugin config. Wrapped in `z.preprocess` so a patch row
|
|
26
36
|
* without a `config:` block (`undefined`) resolves to all defaults — a bare
|
|
@@ -63,6 +73,12 @@ export const Config = z.preprocess((v) => v ?? {}, z.object({
|
|
|
63
73
|
bootstrapConcurrency: z.number().default(1),
|
|
64
74
|
/** Also learn from a clean turn that follows a messy one (what the user included the second time). Automatic, capped. */
|
|
65
75
|
learnFromGood: z.boolean().default(true),
|
|
76
|
+
/** Days of detailed usage-ledger day files kept before they expire (7-365, clamped in mergeConfig). */
|
|
77
|
+
costHistoryDays: z.number().default(30),
|
|
78
|
+
/** Daily USD spend that triggers the warn/exceeded cost UI; 0 disables (clamped in mergeConfig). */
|
|
79
|
+
costWarnDailyUsd: z.number().default(0),
|
|
80
|
+
/** Same as `costWarnDailyUsd`, over a calendar month; 0 disables (clamped in mergeConfig). */
|
|
81
|
+
costWarnMonthlyUsd: z.number().default(0),
|
|
66
82
|
}))
|
|
67
83
|
|
|
68
84
|
/**
|
|
@@ -88,6 +104,9 @@ const configPatchSchema = z.object({
|
|
|
88
104
|
directiveWorseBy: z.number().optional(),
|
|
89
105
|
bootstrapConcurrency: z.number().optional(),
|
|
90
106
|
learnFromGood: z.boolean().optional(),
|
|
107
|
+
costHistoryDays: z.number().optional(),
|
|
108
|
+
costWarnDailyUsd: z.number().optional(),
|
|
109
|
+
costWarnMonthlyUsd: z.number().optional(),
|
|
91
110
|
})
|
|
92
111
|
|
|
93
112
|
// ── Trajectory projection ──────────────────────────────────────────────────
|
|
@@ -213,15 +232,23 @@ const feedbackEntrySchema = z.object({
|
|
|
213
232
|
* system prompt). `distilled` entries come from analyses; `user` entries are
|
|
214
233
|
* typed in Settings and survive every distillation.
|
|
215
234
|
*/
|
|
216
|
-
const directiveTrialSchema = z.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
235
|
+
const directiveTrialSchema = z.preprocess(
|
|
236
|
+
// Profiles written before corrections were graded named the messy baseline `baselineRate`.
|
|
237
|
+
(raw) => (raw !== null && typeof raw === 'object' && 'baselineRate' in raw && !('baselineMessyRate' in raw) ? { ...raw, baselineMessyRate: raw.baselineRate } : raw),
|
|
238
|
+
z.object({
|
|
239
|
+
/** Finished turns observed while the candidate was injected. */
|
|
240
|
+
turns: z.number().int().min(0),
|
|
241
|
+
/** How many of those were messy. */
|
|
242
|
+
messy: z.number().int().min(0),
|
|
243
|
+
/** How many of those the user corrected with their next message. */
|
|
244
|
+
corrected: z.number().int().min(0).default(0),
|
|
245
|
+
/** Messy-turn rate over the 20 turns before the trial started. */
|
|
246
|
+
baselineMessyRate: z.number(),
|
|
247
|
+
/** Correction rate over those 20 turns; -1 = unknown (older profile), recomputed when the trial next advances. */
|
|
248
|
+
baselineCorrectionRate: z.number().default(-1),
|
|
249
|
+
startedAt: z.number(),
|
|
250
|
+
}),
|
|
251
|
+
)
|
|
225
252
|
|
|
226
253
|
const directiveSchema = z.object({
|
|
227
254
|
id: z.string(),
|
|
@@ -229,8 +256,8 @@ const directiveSchema = z.object({
|
|
|
229
256
|
enabled: z.boolean().default(true),
|
|
230
257
|
source: z.enum(['distilled', 'user']).default('distilled'),
|
|
231
258
|
createdAt: z.number(),
|
|
232
|
-
/** candidate = injected on trial; active = proven (or user-made); retired = made things worse. */
|
|
233
|
-
status: z.enum(['candidate', 'active', 'retired']).default('active'),
|
|
259
|
+
/** queued = waiting for its scope's trial slot; candidate = injected on trial; active = proven (or user-made); retired = made things worse. */
|
|
260
|
+
status: z.enum(['queued', 'candidate', 'active', 'retired']).default('active'),
|
|
234
261
|
trial: directiveTrialSchema.optional(),
|
|
235
262
|
retiredReason: z.string().optional(),
|
|
236
263
|
/** Absolute workspace directory this directive is limited to; absent = every conversation. */
|
|
@@ -288,6 +315,12 @@ export const analyzeArgSchema = z.object({
|
|
|
288
315
|
turn: z.number().int().min(1),
|
|
289
316
|
})
|
|
290
317
|
|
|
318
|
+
/** `/api/tacit/analyze-batch`: one session, up to 50 turns analyzed under a single run. */
|
|
319
|
+
export const analyzeBatchArgSchema = z.object({
|
|
320
|
+
sessionId: z.string().min(1).max(200),
|
|
321
|
+
turns: z.array(z.number().int().min(1)).min(1).max(50),
|
|
322
|
+
})
|
|
323
|
+
|
|
291
324
|
export const improveArgSchema = z.object({
|
|
292
325
|
sessionId: z.string().min(1).max(200),
|
|
293
326
|
draft: z.string().min(1).max(100000),
|
|
@@ -308,3 +341,154 @@ export const appliedArgSchema = z.object({
|
|
|
308
341
|
export const configArgSchema = z.object({
|
|
309
342
|
patch: configPatchSchema,
|
|
310
343
|
})
|
|
344
|
+
|
|
345
|
+
// ── Usage ledger (content-free: no prompts, no responses, no tool args) ────
|
|
346
|
+
|
|
347
|
+
/** Every op a metered model call can be tagged with (Task 1's sink + the distillation/enrichment calls). */
|
|
348
|
+
export const USAGE_OPS = [
|
|
349
|
+
'analysis',
|
|
350
|
+
'analysis-repair',
|
|
351
|
+
'directive-distillation',
|
|
352
|
+
'style-distillation',
|
|
353
|
+
'improve',
|
|
354
|
+
'improve-repair',
|
|
355
|
+
'enrichment',
|
|
356
|
+
]
|
|
357
|
+
|
|
358
|
+
/** Every state one recorded attempt can end in. */
|
|
359
|
+
export const USAGE_ATTEMPT_STATUSES = ['ok', 'failed', 'unmetered']
|
|
360
|
+
|
|
361
|
+
/** Every state a run can be in. `running` is written too: the flush persists live runs. */
|
|
362
|
+
export const USAGE_RUN_STATUSES = ['running', 'success', 'partial', 'failed']
|
|
363
|
+
|
|
364
|
+
/** Every kind of run the tracker groups attempts into. */
|
|
365
|
+
export const USAGE_RUN_TYPES = [
|
|
366
|
+
'bootstrap',
|
|
367
|
+
'analysis',
|
|
368
|
+
'analysis-batch',
|
|
369
|
+
'improve',
|
|
370
|
+
'directive-distillation',
|
|
371
|
+
'style-distillation',
|
|
372
|
+
'prompt-enrichment',
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
/** Raw token counts, zero-filled so totals can be summed without null checks. */
|
|
376
|
+
export const tokenBucketsSchema = z.object({
|
|
377
|
+
inputTokens: z.number().default(0),
|
|
378
|
+
outputTokens: z.number().default(0),
|
|
379
|
+
cacheReadTokens: z.number().default(0),
|
|
380
|
+
cacheWriteTokens: z.number().default(0),
|
|
381
|
+
reasoningTokens: z.number().default(0),
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* One metered model call. Mirrors the sink record `callCoachModel` hands the
|
|
386
|
+
* tracker (`startedAt`..`usage`) plus the identity fields the tracker itself
|
|
387
|
+
* assigns (`id`, `op`, `sessionId`, `turn`) and the priced result. Never
|
|
388
|
+
* carries prompt/response text, tool args, or API keys.
|
|
389
|
+
*/
|
|
390
|
+
export const usageAttemptSchema = z.object({
|
|
391
|
+
id: z.string(),
|
|
392
|
+
op: z.enum(USAGE_OPS),
|
|
393
|
+
startedAt: z.number(),
|
|
394
|
+
durationMs: z.number().default(0),
|
|
395
|
+
model: z.string().default(''),
|
|
396
|
+
provider: z.string().default(''),
|
|
397
|
+
reasoningEffort: z.string().nullable().default(null),
|
|
398
|
+
finish: z.string().default(''),
|
|
399
|
+
status: z.enum(USAGE_ATTEMPT_STATUSES),
|
|
400
|
+
code: z.string().default(''),
|
|
401
|
+
sessionId: z.string().default(''),
|
|
402
|
+
turn: z.number().nullable().default(null),
|
|
403
|
+
usage: tokenBucketsSchema.nullable().default(null),
|
|
404
|
+
/** null when no price table matched the route/model (e.g. a proxy provider). */
|
|
405
|
+
priced: z.object({
|
|
406
|
+
source: z.enum(['bundled', 'costMeter']),
|
|
407
|
+
tier: z.string(),
|
|
408
|
+
rates: z.object({ cacheHit: z.number(), cacheMiss: z.number(), output: z.number() }),
|
|
409
|
+
asOf: z.string(),
|
|
410
|
+
usd: z.number(),
|
|
411
|
+
}).nullable().default(null),
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* A precomputed, already-defaulted instance of a nested object schema.
|
|
416
|
+
* zod's `.default(value)` injects `value` verbatim when a field is absent —
|
|
417
|
+
* it does NOT re-run `value` through the schema — so a literal `{}` default
|
|
418
|
+
* on a nested object would skip that object's own field defaults. Passing
|
|
419
|
+
* `schema.parse({})` instead gives the same "all defaults" shape correctly.
|
|
420
|
+
*/
|
|
421
|
+
const emptyTokenBuckets = tokenBucketsSchema.parse({})
|
|
422
|
+
|
|
423
|
+
/** Aggregate counters shared by a run's totals, the lifetime summary, and every summary bucket. */
|
|
424
|
+
export const usageTotalsSchema = z.object({
|
|
425
|
+
attempts: z.number().default(0),
|
|
426
|
+
billedCalls: z.number().default(0),
|
|
427
|
+
unmeteredCalls: z.number().default(0),
|
|
428
|
+
unpricedCalls: z.number().default(0),
|
|
429
|
+
tokens: tokenBucketsSchema.default(emptyTokenBuckets),
|
|
430
|
+
usdKnown: z.number().default(0),
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const emptyUsageTotals = usageTotalsSchema.parse({})
|
|
434
|
+
|
|
435
|
+
/** One tracker run: a group of attempts sharing a trigger (a single call, an auto-analysis, a bootstrap batch, ...). */
|
|
436
|
+
export const usageRunSchema = z.object({
|
|
437
|
+
runId: z.string(),
|
|
438
|
+
type: z.enum(USAGE_RUN_TYPES),
|
|
439
|
+
trigger: z.string().default(''),
|
|
440
|
+
startedAt: z.number(),
|
|
441
|
+
endedAt: z.number().default(0),
|
|
442
|
+
status: z.enum(USAGE_RUN_STATUSES).default('running'),
|
|
443
|
+
sessionId: z.string().default(''),
|
|
444
|
+
turn: z.number().nullable().default(null),
|
|
445
|
+
workspace: z.string().default(''),
|
|
446
|
+
model: z.string().default(''),
|
|
447
|
+
provider: z.string().default(''),
|
|
448
|
+
results: z.record(z.number()).default({}),
|
|
449
|
+
attempts: z.array(usageAttemptSchema).default([]),
|
|
450
|
+
totals: usageTotalsSchema.default(emptyUsageTotals),
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
/** One day's `usage/YYYY-MM-DD.json` file. */
|
|
454
|
+
export const usageDayFileSchema = z.object({
|
|
455
|
+
version: z.literal(1),
|
|
456
|
+
day: z.string(),
|
|
457
|
+
runs: z.array(usageRunSchema).default([]),
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
const usageDayTotalsSchema = usageTotalsSchema.extend({
|
|
461
|
+
byType: z.record(usageTotalsSchema).default({}),
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
/** `usage/summary.json`: rolling totals kept alongside the day files so reports never have to re-scan every day. */
|
|
465
|
+
export const usageSummarySchema = z.object({
|
|
466
|
+
version: z.literal(1),
|
|
467
|
+
trackingSince: z.number(),
|
|
468
|
+
lifetime: usageTotalsSchema.default(emptyUsageTotals),
|
|
469
|
+
byType: z.record(usageTotalsSchema).default({}),
|
|
470
|
+
byModel: z.record(usageTotalsSchema).default({}),
|
|
471
|
+
days: z.record(usageDayTotalsSchema).default({}),
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Arguments for `/api/tacit/usage`. Declared here (not next to
|
|
476
|
+
* `bootstrapArgSchema`) because `z.enum(USAGE_RUN_TYPES)` needs the run-type
|
|
477
|
+
* list above it. Every field is optional on the wire; `tracker.report()`
|
|
478
|
+
* applies the defaults (`range: '30d'`, `page: 1`, `pageSize: 20`).
|
|
479
|
+
*/
|
|
480
|
+
export const usageArgSchema = z.object({
|
|
481
|
+
range: z.enum(['today', '7d', '30d', 'month', 'all']).optional(),
|
|
482
|
+
type: z.enum(USAGE_RUN_TYPES).optional(),
|
|
483
|
+
status: z.enum(USAGE_RUN_STATUSES).optional(),
|
|
484
|
+
model: z.string().max(64).optional(),
|
|
485
|
+
workspace: z.string().max(200).optional(),
|
|
486
|
+
sessionId: z.string().max(200).optional(),
|
|
487
|
+
page: z.number().int().min(1).max(1000).optional(),
|
|
488
|
+
pageSize: z.number().int().min(1).max(100).optional(),
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
/** Arguments for `/api/tacit/usage-run`: one run id, as minted by `beginRun`. */
|
|
492
|
+
export const usageRunArgSchema = z.object({
|
|
493
|
+
runId: z.string().min(1).max(64),
|
|
494
|
+
})
|