token-studio 4.8.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/.nvmrc +1 -0
- package/CHANGELOG.md +89 -0
- package/Dockerfile +17 -0
- package/LICENSE +22 -0
- package/NOTICE.md +21 -0
- package/PRIVACY.md +68 -0
- package/README.en.md +220 -0
- package/README.md +220 -0
- package/config/collectors.json +54 -0
- package/data/.gitkeep +1 -0
- package/docker-compose.yml +17 -0
- package/docs/assets/.gitkeep +1 -0
- package/docs/assets/token-studio-v44-dashboard.png +0 -0
- package/docs/assets/token-studio-v44-live.png +0 -0
- package/docs/assets/token-studio-v44-review-mobile.png +0 -0
- package/docs/assets/token-studio-v44-review.png +0 -0
- package/docs/assets/token-studio-v45-dashboard.png +0 -0
- package/docs/assets/token-studio-v45-live.png +0 -0
- package/docs/assets/token-studio-v45-review-mobile.png +0 -0
- package/docs/assets/token-studio-v45-review.png +0 -0
- package/docs/blog-case-study.md +34 -0
- package/docs/collector-support-matrix.md +65 -0
- package/docs/competitive-notes.md +87 -0
- package/docs/demo-data/README.md +12 -0
- package/docs/demo-data/token-studio-v2-demo.json +146 -0
- package/docs/demo-flow.md +39 -0
- package/docs/first-run.md +95 -0
- package/docs/local-collectors.md +49 -0
- package/docs/public-launch-checklist.md +45 -0
- package/docs/resume-bullets.md +7 -0
- package/docs/statusline.md +52 -0
- package/index.html +16 -0
- package/package.json +36 -0
- package/render.yaml +17 -0
- package/src/auto-attribution.mjs +396 -0
- package/src/ccusage-bridge.mjs +74 -0
- package/src/ccusage-import.mjs +415 -0
- package/src/cli.mjs +643 -0
- package/src/client/dashboard/App.jsx +1734 -0
- package/src/client/dashboard/annotation-presets.js +138 -0
- package/src/client/dashboard/attribution.js +328 -0
- package/src/client/dashboard/components-charts.jsx +622 -0
- package/src/client/dashboard/components-tables.jsx +1531 -0
- package/src/client/dashboard/components-top.jsx +307 -0
- package/src/client/dashboard/import-budget.js +41 -0
- package/src/client/dashboard/model-usage.js +108 -0
- package/src/client/dashboard/onboarding.js +80 -0
- package/src/client/dashboard/styles.css +2606 -0
- package/src/client/live/LiveApp.jsx +226 -0
- package/src/client/live/styles.css +446 -0
- package/src/client/main.jsx +20 -0
- package/src/client/review/ReviewApp.jsx +507 -0
- package/src/client/review/closure-progress.js +165 -0
- package/src/client/review/markdown-report.js +401 -0
- package/src/client/review/model-strategy.js +273 -0
- package/src/client/review/roi-advisor.js +255 -0
- package/src/client/review/roi-evidence.js +78 -0
- package/src/client/review/savings-simulator.js +252 -0
- package/src/client/review/sections-1.jsx +277 -0
- package/src/client/review/sections-2.jsx +927 -0
- package/src/client/review/styles.css +2321 -0
- package/src/client/review/utils.js +345 -0
- package/src/client/shared/utils.js +236 -0
- package/src/closure-check.mjs +537 -0
- package/src/closure-import.mjs +646 -0
- package/src/collect.mjs +247 -0
- package/src/collector-config.mjs +82 -0
- package/src/collector-registry.mjs +333 -0
- package/src/collectors/claude-code.mjs +355 -0
- package/src/collectors/codex.mjs +418 -0
- package/src/collectors/copilot.mjs +19 -0
- package/src/collectors/cursor.mjs +23 -0
- package/src/collectors/gemini.mjs +530 -0
- package/src/collectors/goose.mjs +15 -0
- package/src/collectors/hermes.mjs +206 -0
- package/src/collectors/kimi.mjs +15 -0
- package/src/collectors/openclaw.mjs +400 -0
- package/src/collectors/opencode.mjs +349 -0
- package/src/collectors/qwen.mjs +15 -0
- package/src/collectors/structured-usage.mjs +437 -0
- package/src/collectors/utils.mjs +93 -0
- package/src/db.mjs +1397 -0
- package/src/demo-seed.mjs +39 -0
- package/src/dev.mjs +43 -0
- package/src/live.mjs +428 -0
- package/src/model-policy.mjs +147 -0
- package/src/pricing.mjs +434 -0
- package/src/privacy-check.mjs +126 -0
- package/src/server.mjs +1240 -0
- package/src/source-health.mjs +195 -0
- package/src/statusline.mjs +156 -0
- package/src/terminal-report.mjs +245 -0
- package/src/update-pricing.mjs +8 -0
- package/test/annotation-presets.test.mjs +137 -0
- package/test/api-annotations.test.mjs +202 -0
- package/test/api-auto-attribution.test.mjs +169 -0
- package/test/api-source-health.test.mjs +109 -0
- package/test/api-v2.test.mjs +278 -0
- package/test/api-v43.test.mjs +151 -0
- package/test/api-v44.test.mjs +128 -0
- package/test/attribution-summary.test.mjs +164 -0
- package/test/auto-attribution.test.mjs +116 -0
- package/test/ccusage-bridge.test.mjs +36 -0
- package/test/ccusage-import.test.mjs +93 -0
- package/test/cli-v43.test.mjs +64 -0
- package/test/cli-v45.test.mjs +34 -0
- package/test/cli-v46.test.mjs +129 -0
- package/test/cli-v47.test.mjs +98 -0
- package/test/closure-check.test.mjs +202 -0
- package/test/closure-import.test.mjs +263 -0
- package/test/collector-config.test.mjs +25 -0
- package/test/collector-registry.test.mjs +56 -0
- package/test/csv.test.mjs +19 -0
- package/test/db-annotations.test.mjs +186 -0
- package/test/db-v2.test.mjs +200 -0
- package/test/db-v4.test.mjs +178 -0
- package/test/experimental-collectors.test.mjs +103 -0
- package/test/fixtures/collectors/copilot/usage.jsonl +2 -0
- package/test/fixtures/collectors/cursor/usage.jsonl +2 -0
- package/test/fixtures/collectors/goose/usage.jsonl +2 -0
- package/test/fixtures/collectors/kimi/usage.jsonl +2 -0
- package/test/fixtures/collectors/qwen/usage.jsonl +2 -0
- package/test/import-budget.test.mjs +40 -0
- package/test/live.test.mjs +256 -0
- package/test/markdown-report.test.mjs +193 -0
- package/test/model-policy.test.mjs +34 -0
- package/test/model-strategy.test.mjs +116 -0
- package/test/model-usage.test.mjs +99 -0
- package/test/official-pricing.test.mjs +70 -0
- package/test/onboarding.test.mjs +55 -0
- package/test/privacy-check.test.mjs +33 -0
- package/test/review-closure-progress.test.mjs +99 -0
- package/test/roi-advisor.test.mjs +188 -0
- package/test/roi-evidence.test.mjs +48 -0
- package/test/roi-summary.test.mjs +101 -0
- package/test/savings-simulator.test.mjs +141 -0
- package/test/source-health.test.mjs +62 -0
- package/test/statusline.test.mjs +148 -0
- package/vite.config.js +23 -0
package/src/pricing.mjs
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official pricing calculator.
|
|
3
|
+
*
|
|
4
|
+
* This module intentionally avoids third-party pricing caches. Rates are copied
|
|
5
|
+
* from provider-owned pricing pages and are expressed as USD per 1M tokens.
|
|
6
|
+
* Unknown or research-preview models return 0 and are reported as unpriced.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MTOK = 1_000_000;
|
|
10
|
+
const VERIFIED_AT = '2026-06-11';
|
|
11
|
+
const DEFAULT_ANTHROPIC_CACHE_WRITE_TTL = '5m';
|
|
12
|
+
|
|
13
|
+
export const OFFICIAL_PRICING_SOURCES = [
|
|
14
|
+
{
|
|
15
|
+
provider: 'openai',
|
|
16
|
+
label: 'OpenAI API pricing',
|
|
17
|
+
url: 'https://developers.openai.com/api/docs/pricing',
|
|
18
|
+
note: 'Standard API rates; Batch, Flex, Priority, long-context and data residency modifiers are not applied by default.'
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
provider: 'openai-codex',
|
|
22
|
+
label: 'OpenAI Codex pricing',
|
|
23
|
+
url: 'https://developers.openai.com/codex/pricing',
|
|
24
|
+
note: 'Codex ChatGPT-plan credits are documented separately; API-key mode uses OpenAI API pricing.'
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
provider: 'anthropic',
|
|
28
|
+
label: 'Claude API pricing',
|
|
29
|
+
url: 'https://platform.claude.com/docs/en/about-claude/pricing',
|
|
30
|
+
note: 'First-party Claude API global standard pricing; cache write defaults to 5-minute prompt caching.'
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
provider: 'deepseek',
|
|
34
|
+
label: 'DeepSeek Models & Pricing',
|
|
35
|
+
url: 'https://api-docs.deepseek.com/quick_start/pricing',
|
|
36
|
+
note: 'Overseas USD API prices per 1M tokens.'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
provider: 'xiaomi',
|
|
40
|
+
label: 'Xiaomi MiMo API pricing',
|
|
41
|
+
url: 'https://platform.xiaomimimo.com/docs/en-US/price/pay-as-you-go',
|
|
42
|
+
note: 'Overseas USD API prices per 1M tokens.'
|
|
43
|
+
}
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export const OFFICIAL_PRICE_TABLE = [
|
|
47
|
+
officialRate({
|
|
48
|
+
provider: 'openai',
|
|
49
|
+
model: 'gpt-5.5',
|
|
50
|
+
aliases: ['gpt-5.5'],
|
|
51
|
+
input: 5,
|
|
52
|
+
cachedInput: 0.5,
|
|
53
|
+
output: 30,
|
|
54
|
+
source: 'openai',
|
|
55
|
+
note: 'OpenAI API standard short-context rate.'
|
|
56
|
+
}),
|
|
57
|
+
officialRate({
|
|
58
|
+
provider: 'openai',
|
|
59
|
+
model: 'gpt-5.3-codex',
|
|
60
|
+
aliases: ['gpt-5.3-codex'],
|
|
61
|
+
input: 1.75,
|
|
62
|
+
cachedInput: 0.175,
|
|
63
|
+
output: 14,
|
|
64
|
+
source: 'openai',
|
|
65
|
+
note: 'OpenAI API standard Codex model rate.'
|
|
66
|
+
}),
|
|
67
|
+
officialRate({
|
|
68
|
+
provider: 'openai',
|
|
69
|
+
model: 'gpt-5.3-codex-spark',
|
|
70
|
+
aliases: ['gpt-5.3-codex-spark'],
|
|
71
|
+
source: 'openai-codex',
|
|
72
|
+
unavailableReason: 'OpenAI Codex docs list GPT-5.3-Codex-Spark as research preview and do not publish a USD API token rate.'
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
officialRate({
|
|
76
|
+
provider: 'anthropic',
|
|
77
|
+
model: 'claude-opus-4-8',
|
|
78
|
+
aliases: ['claude-opus-4-8'],
|
|
79
|
+
input: 5,
|
|
80
|
+
cacheWrite5m: 6.25,
|
|
81
|
+
cacheWrite1h: 10,
|
|
82
|
+
cachedInput: 0.5,
|
|
83
|
+
output: 25,
|
|
84
|
+
source: 'anthropic'
|
|
85
|
+
}),
|
|
86
|
+
officialRate({
|
|
87
|
+
provider: 'anthropic',
|
|
88
|
+
model: 'claude-opus-4-7',
|
|
89
|
+
aliases: ['claude-opus-4-7'],
|
|
90
|
+
input: 5,
|
|
91
|
+
cacheWrite5m: 6.25,
|
|
92
|
+
cacheWrite1h: 10,
|
|
93
|
+
cachedInput: 0.5,
|
|
94
|
+
output: 25,
|
|
95
|
+
source: 'anthropic'
|
|
96
|
+
}),
|
|
97
|
+
officialRate({
|
|
98
|
+
provider: 'anthropic',
|
|
99
|
+
model: 'claude-opus-4-6',
|
|
100
|
+
aliases: ['claude-opus-4-6'],
|
|
101
|
+
input: 5,
|
|
102
|
+
cacheWrite5m: 6.25,
|
|
103
|
+
cacheWrite1h: 10,
|
|
104
|
+
cachedInput: 0.5,
|
|
105
|
+
output: 25,
|
|
106
|
+
source: 'anthropic'
|
|
107
|
+
}),
|
|
108
|
+
officialRate({
|
|
109
|
+
provider: 'anthropic',
|
|
110
|
+
model: 'claude-sonnet-4-6',
|
|
111
|
+
aliases: ['claude-sonnet-4-6'],
|
|
112
|
+
input: 3,
|
|
113
|
+
cacheWrite5m: 3.75,
|
|
114
|
+
cacheWrite1h: 6,
|
|
115
|
+
cachedInput: 0.3,
|
|
116
|
+
output: 15,
|
|
117
|
+
source: 'anthropic'
|
|
118
|
+
}),
|
|
119
|
+
officialRate({
|
|
120
|
+
provider: 'anthropic',
|
|
121
|
+
model: 'claude-haiku-4-5',
|
|
122
|
+
aliases: ['claude-haiku-4-5'],
|
|
123
|
+
input: 1,
|
|
124
|
+
cacheWrite5m: 1.25,
|
|
125
|
+
cacheWrite1h: 2,
|
|
126
|
+
cachedInput: 0.1,
|
|
127
|
+
output: 5,
|
|
128
|
+
source: 'anthropic'
|
|
129
|
+
}),
|
|
130
|
+
|
|
131
|
+
officialRate({
|
|
132
|
+
provider: 'deepseek',
|
|
133
|
+
model: 'deepseek-v4-pro',
|
|
134
|
+
aliases: ['deepseek-v4-pro'],
|
|
135
|
+
input: 0.435,
|
|
136
|
+
cachedInput: 0.003625,
|
|
137
|
+
output: 0.87,
|
|
138
|
+
source: 'deepseek'
|
|
139
|
+
}),
|
|
140
|
+
officialRate({
|
|
141
|
+
provider: 'deepseek',
|
|
142
|
+
model: 'deepseek-v4-flash',
|
|
143
|
+
aliases: ['deepseek-v4-flash', 'deepseek-chat', 'deepseek-reasoner'],
|
|
144
|
+
input: 0.14,
|
|
145
|
+
cachedInput: 0.0028,
|
|
146
|
+
output: 0.28,
|
|
147
|
+
source: 'deepseek',
|
|
148
|
+
note: 'DeepSeek docs state deepseek-chat and deepseek-reasoner map to deepseek-v4-flash compatibility modes.'
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
officialRate({
|
|
152
|
+
provider: 'xiaomi',
|
|
153
|
+
model: 'mimo-v2.5-pro',
|
|
154
|
+
aliases: ['mimo-v2.5-pro'],
|
|
155
|
+
input: 0.435,
|
|
156
|
+
cachedInput: 0.0036,
|
|
157
|
+
output: 0.87,
|
|
158
|
+
source: 'xiaomi'
|
|
159
|
+
}),
|
|
160
|
+
officialRate({
|
|
161
|
+
provider: 'xiaomi',
|
|
162
|
+
model: 'mimo-v2.5',
|
|
163
|
+
aliases: ['mimo-v2.5'],
|
|
164
|
+
input: 0.14,
|
|
165
|
+
cachedInput: 0.0028,
|
|
166
|
+
output: 0.28,
|
|
167
|
+
source: 'xiaomi'
|
|
168
|
+
})
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Kept for the collector API shape. No network or third-party cache is used.
|
|
173
|
+
*/
|
|
174
|
+
export async function loadPricing() {
|
|
175
|
+
return {
|
|
176
|
+
mode: 'official-docs',
|
|
177
|
+
verifiedAt: VERIFIED_AT,
|
|
178
|
+
sources: OFFICIAL_PRICING_SOURCES,
|
|
179
|
+
models: OFFICIAL_PRICE_TABLE
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function calculateCost(model, tokens, _pricingData = null, provider = null) {
|
|
184
|
+
return calculateOfficialCost(model, tokens, { provider }).totalUSD;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function calculateOfficialCost(model, tokens = {}, options = {}) {
|
|
188
|
+
const pricing = resolveOfficialPricing(model, options.provider);
|
|
189
|
+
const normalizedTokens = normalizeTokens(tokens);
|
|
190
|
+
|
|
191
|
+
if (!pricing || !pricing.priced) {
|
|
192
|
+
return {
|
|
193
|
+
model: normalizeModelId(model),
|
|
194
|
+
resolvedModel: pricing?.model || null,
|
|
195
|
+
provider: pricing?.provider || null,
|
|
196
|
+
priced: false,
|
|
197
|
+
status: pricing?.unavailableReason ? 'unpriced' : 'unknown-model',
|
|
198
|
+
reason: pricing?.unavailableReason || 'No official USD token price is configured for this model.',
|
|
199
|
+
tokens: normalizedTokens,
|
|
200
|
+
ratesPerMTok: null,
|
|
201
|
+
totalUSD: 0,
|
|
202
|
+
source: pricing?.source || null
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const cacheWriteMode = normalizeAnthropicCacheWriteTtl(options.anthropicCacheWriteTtl);
|
|
207
|
+
const rates = ratesForCalculation(pricing.ratesPerMTok, pricing.provider, cacheWriteMode);
|
|
208
|
+
const outputTokens = normalizedTokens.output + normalizedTokens.reasoning;
|
|
209
|
+
const inputUSD = costPart(normalizedTokens.input, rates.input);
|
|
210
|
+
const cachedInputUSD = costPart(normalizedTokens.cacheRead, rates.cachedInput);
|
|
211
|
+
const cacheWriteUSD = costPart(normalizedTokens.cacheWrite, rates.cacheWrite);
|
|
212
|
+
const outputUSD = costPart(outputTokens, rates.output);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
model: normalizeModelId(model),
|
|
216
|
+
resolvedModel: pricing.model,
|
|
217
|
+
provider: pricing.provider,
|
|
218
|
+
priced: true,
|
|
219
|
+
status: 'priced',
|
|
220
|
+
reason: null,
|
|
221
|
+
tokens: normalizedTokens,
|
|
222
|
+
ratesPerMTok: rates,
|
|
223
|
+
parts: {
|
|
224
|
+
inputUSD,
|
|
225
|
+
cachedInputUSD,
|
|
226
|
+
cacheWriteUSD,
|
|
227
|
+
outputUSD
|
|
228
|
+
},
|
|
229
|
+
totalUSD: inputUSD + cachedInputUSD + cacheWriteUSD + outputUSD,
|
|
230
|
+
source: pricing.source,
|
|
231
|
+
note: pricing.note || null
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function resolveOfficialPricing(model, provider = null) {
|
|
236
|
+
const normalized = normalizeModelId(model);
|
|
237
|
+
if (!normalized || normalized === '<synthetic>') return null;
|
|
238
|
+
|
|
239
|
+
const candidates = modelCandidates(normalized, provider);
|
|
240
|
+
const sorted = OFFICIAL_PRICE_TABLE
|
|
241
|
+
.slice()
|
|
242
|
+
.sort((a, b) => longestAliasLength(b) - longestAliasLength(a));
|
|
243
|
+
|
|
244
|
+
for (const rate of sorted) {
|
|
245
|
+
if (matchesRate(rate, candidates)) return rate;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function officialPricingMetadata(rows = []) {
|
|
252
|
+
const byModel = new Map();
|
|
253
|
+
let totalTokens = 0;
|
|
254
|
+
let pricedTokens = 0;
|
|
255
|
+
let pricedCostUSD = 0;
|
|
256
|
+
|
|
257
|
+
for (const row of rows) {
|
|
258
|
+
const tokens = tokenTotal(row);
|
|
259
|
+
totalTokens += tokens;
|
|
260
|
+
const cost = Number(row.costUSD || 0);
|
|
261
|
+
const priced = row.pricingStatus === 'priced' || cost > 0;
|
|
262
|
+
if (priced) {
|
|
263
|
+
pricedTokens += tokens;
|
|
264
|
+
pricedCostUSD += cost;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const model = row.model || row.pricingModel || 'unknown';
|
|
268
|
+
const current = byModel.get(model) || { model, totalTokens: 0, rows: 0, reason: row.pricingReason || 'No official USD price.' };
|
|
269
|
+
current.totalTokens += tokens;
|
|
270
|
+
current.rows += 1;
|
|
271
|
+
byModel.set(model, current);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
mode: 'official-price-conversion',
|
|
276
|
+
currency: 'USD',
|
|
277
|
+
verifiedAt: VERIFIED_AT,
|
|
278
|
+
totalTokens,
|
|
279
|
+
pricedTokens,
|
|
280
|
+
unpricedTokens: Math.max(0, totalTokens - pricedTokens),
|
|
281
|
+
pricedShare: totalTokens ? pricedTokens / totalTokens : 1,
|
|
282
|
+
pricedCostUSD,
|
|
283
|
+
sources: OFFICIAL_PRICING_SOURCES,
|
|
284
|
+
unpricedModels: Array.from(byModel.values())
|
|
285
|
+
.sort((a, b) => b.totalTokens - a.totalTokens)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function attachOfficialPricing(row, model = row?.model, provider = null) {
|
|
290
|
+
const tokens = {
|
|
291
|
+
input: row?.inputTokens ?? row?.input,
|
|
292
|
+
output: row?.outputTokens ?? row?.output,
|
|
293
|
+
cacheRead: row?.cacheReadTokens ?? row?.cacheRead,
|
|
294
|
+
cacheWrite: row?.cacheCreationTokens ?? row?.cacheWrite,
|
|
295
|
+
reasoning: row?.reasoningOutputTokens ?? row?.reasoning
|
|
296
|
+
};
|
|
297
|
+
const cost = calculateOfficialCost(model, tokens, { provider });
|
|
298
|
+
return {
|
|
299
|
+
...row,
|
|
300
|
+
costUSD: cost.totalUSD,
|
|
301
|
+
pricingStatus: cost.status,
|
|
302
|
+
pricingModel: cost.resolvedModel || cost.model || model || null,
|
|
303
|
+
pricingProvider: cost.provider || null,
|
|
304
|
+
pricingReason: cost.reason || null,
|
|
305
|
+
pricingSource: cost.source?.url || null,
|
|
306
|
+
pricingSourceLabel: cost.source?.label || null
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function officialRate({
|
|
311
|
+
provider,
|
|
312
|
+
model,
|
|
313
|
+
aliases,
|
|
314
|
+
input,
|
|
315
|
+
cachedInput,
|
|
316
|
+
cacheWrite5m,
|
|
317
|
+
cacheWrite1h,
|
|
318
|
+
output,
|
|
319
|
+
source,
|
|
320
|
+
note,
|
|
321
|
+
unavailableReason
|
|
322
|
+
}) {
|
|
323
|
+
const sourceMeta = OFFICIAL_PRICING_SOURCES.find(item => item.provider === source) || null;
|
|
324
|
+
const priced = input != null && output != null && !unavailableReason;
|
|
325
|
+
return {
|
|
326
|
+
provider,
|
|
327
|
+
model,
|
|
328
|
+
aliases: aliases.map(normalizeModelId),
|
|
329
|
+
priced,
|
|
330
|
+
unavailableReason: unavailableReason || null,
|
|
331
|
+
ratesPerMTok: priced ? {
|
|
332
|
+
input: Number(input),
|
|
333
|
+
cachedInput: Number(cachedInput ?? input),
|
|
334
|
+
cacheWrite5m: Number(cacheWrite5m ?? input),
|
|
335
|
+
cacheWrite1h: Number(cacheWrite1h ?? cacheWrite5m ?? input),
|
|
336
|
+
output: Number(output)
|
|
337
|
+
} : null,
|
|
338
|
+
source: sourceMeta,
|
|
339
|
+
note: note || sourceMeta?.note || null
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function ratesForCalculation(rates, provider, cacheWriteMode) {
|
|
344
|
+
return {
|
|
345
|
+
input: validRate(rates.input),
|
|
346
|
+
cachedInput: validRate(rates.cachedInput),
|
|
347
|
+
cacheWrite: validRate(
|
|
348
|
+
provider === 'anthropic' && cacheWriteMode === '1h'
|
|
349
|
+
? rates.cacheWrite1h
|
|
350
|
+
: rates.cacheWrite5m
|
|
351
|
+
),
|
|
352
|
+
output: validRate(rates.output)
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function normalizeAnthropicCacheWriteTtl(value = globalThis.process?.env?.ANTHROPIC_CACHE_WRITE_TTL) {
|
|
357
|
+
const normalized = String(value || DEFAULT_ANTHROPIC_CACHE_WRITE_TTL).trim().toLowerCase();
|
|
358
|
+
return normalized === '1h' || normalized === 'hour' || normalized === '3600' ? '1h' : '5m';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function modelCandidates(model, provider) {
|
|
362
|
+
const normalized = normalizeModelId(model);
|
|
363
|
+
const bare = normalized.split('/').at(-1);
|
|
364
|
+
const values = [
|
|
365
|
+
normalized,
|
|
366
|
+
bare,
|
|
367
|
+
normalizeVersionSeparator(normalized),
|
|
368
|
+
normalizeVersionSeparator(bare)
|
|
369
|
+
].filter(Boolean);
|
|
370
|
+
const providerHint = normalizeProvider(provider);
|
|
371
|
+
if (providerHint) {
|
|
372
|
+
values.push(`${providerHint}/${bare}`);
|
|
373
|
+
}
|
|
374
|
+
return Array.from(new Set(values));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function matchesRate(rate, candidates) {
|
|
378
|
+
return candidates.some(candidate =>
|
|
379
|
+
rate.aliases.some(alias =>
|
|
380
|
+
candidate === alias ||
|
|
381
|
+
candidate.startsWith(`${alias}-`) ||
|
|
382
|
+
candidate.startsWith(`${alias}:`)
|
|
383
|
+
)
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function longestAliasLength(rate) {
|
|
388
|
+
return Math.max(...rate.aliases.map(alias => alias.length));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function normalizeTokens(tokens = {}) {
|
|
392
|
+
return {
|
|
393
|
+
input: positive(tokens.input),
|
|
394
|
+
output: positive(tokens.output),
|
|
395
|
+
cacheRead: positive(tokens.cacheRead ?? tokens.cache_read),
|
|
396
|
+
cacheWrite: positive(tokens.cacheWrite ?? tokens.cache_write),
|
|
397
|
+
reasoning: positive(tokens.reasoning)
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function tokenTotal(row = {}) {
|
|
402
|
+
return positive(row.totalTokens ?? row.total_tokens)
|
|
403
|
+
|| positive(row.inputTokens) + positive(row.outputTokens)
|
|
404
|
+
+ positive(row.cacheReadTokens) + positive(row.cacheCreationTokens)
|
|
405
|
+
+ positive(row.reasoningOutputTokens);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function costPart(tokens, ratePerMTok) {
|
|
409
|
+
return positive(tokens) * validRate(ratePerMTok) / MTOK;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function validRate(value) {
|
|
413
|
+
const number = Number(value ?? 0);
|
|
414
|
+
return Number.isFinite(number) && number >= 0 ? number : 0;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function positive(value) {
|
|
418
|
+
const number = Number(value ?? 0);
|
|
419
|
+
return Number.isFinite(number) && number > 0 ? number : 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function normalizeProvider(value) {
|
|
423
|
+
return String(value || '').trim().toLowerCase().replace(/_/g, '-');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function normalizeModelId(value) {
|
|
427
|
+
return String(value || '').trim().toLowerCase().replace(/(?<=\d)\.(?=\d)/g, '-');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function normalizeVersionSeparator(id) {
|
|
431
|
+
const text = String(id || '');
|
|
432
|
+
const normalized = text.replace(/(?<=\d)\.(?=\d)/g, '-');
|
|
433
|
+
return normalized === text ? null : normalized;
|
|
434
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const TEXT_EXTENSIONS = new Set([
|
|
6
|
+
'.js', '.jsx', '.mjs', '.json', '.md', '.txt', '.yml', '.yaml', '.toml',
|
|
7
|
+
'.html', '.css', '.svg', '.env', '.gitignore', '.nvmrc'
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const SECRET_PATTERNS = [
|
|
11
|
+
{ id: 'env-file', severity: 'high', testPath: path => /^\.env(?:\.|$)/i.test(path) || /[/\\]\.env(?:\.|$)/i.test(path), message: 'Environment files must not be published.' },
|
|
12
|
+
{ id: 'sqlite-db', severity: 'high', testPath: path => /(?:^|[/\\])data[/\\].+\.sqlite(?:3)?$/i.test(path), message: 'Real SQLite usage databases must not be published.' },
|
|
13
|
+
{ id: 'ai-log-dir', severity: 'high', testPath: path => /(^|[/\\])\.(claude|codex)([/\\]|$)/i.test(path), message: 'Local AI tool log directories must not be published.' },
|
|
14
|
+
{ id: 'export-file', severity: 'medium', testPath: path => /token-studio-(annotations|review).*\.(json|csv|md)$/i.test(path), message: 'Generated exports should be reviewed before publishing.' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const CONTENT_PATTERNS = [
|
|
18
|
+
{ id: 'personal-windows-path', severity: 'high', re: /C:\\Users\\|C:\/Users\//i, message: 'Personal Windows user paths found in tracked text.' },
|
|
19
|
+
{ id: 'real-project-path', severity: 'medium', re: /D:[\\/](AIResume|HighROIProjects)[\\/](?!ryan__token-studio-roi|token-studio-roi-demo)/i, message: 'Real local project path found in tracked text.' },
|
|
20
|
+
{ id: 'personal-handle', severity: 'high', re: new RegExp(`guoye${'yang'}|\\u90ed\\u70e8\\u626c`, 'i'), message: 'Forbidden public identity string found; use ryan instead.' },
|
|
21
|
+
{ id: 'conversation-content', severity: 'medium', re: /(conversation|transcript|prompt|response)\s*[:=]\s*["'`][^"'`]{80,}/i, message: 'Possible raw conversation content found in tracked text.' },
|
|
22
|
+
{ id: 'api-secret', severity: 'high', re: /(sk-[A-Za-z0-9_-]{20,}|api[_-]?key\s*[:=]\s*["'][^"']{12,})/i, message: 'Possible API key or secret found.' }
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function runPrivacyCheck({ cwd = process.cwd(), includeUntracked = false } = {}) {
|
|
26
|
+
const files = trackedFiles(cwd, includeUntracked);
|
|
27
|
+
const issues = [];
|
|
28
|
+
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const fullPath = join(cwd, file);
|
|
31
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
32
|
+
if (pattern.testPath(file)) {
|
|
33
|
+
issues.push(issue(pattern, file));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!isReadableTextFile(fullPath)) continue;
|
|
37
|
+
const text = readFileSync(fullPath, 'utf8');
|
|
38
|
+
for (const pattern of CONTENT_PATTERNS) {
|
|
39
|
+
const match = text.match(pattern.re);
|
|
40
|
+
if (match) {
|
|
41
|
+
issues.push({
|
|
42
|
+
...issue(pattern, file),
|
|
43
|
+
excerpt: safeExcerpt(match[0])
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ok: !issues.some(item => item.severity === 'high'),
|
|
51
|
+
checkedFiles: files.length,
|
|
52
|
+
issues
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function formatPrivacyCheckReport(result) {
|
|
57
|
+
const lines = [
|
|
58
|
+
'Token Studio Privacy Check',
|
|
59
|
+
`status=${result.ok ? 'ok' : 'blocked'}`,
|
|
60
|
+
`checkedFiles=${result.checkedFiles}`,
|
|
61
|
+
`issues=${result.issues.length}`
|
|
62
|
+
];
|
|
63
|
+
if (!result.issues.length) {
|
|
64
|
+
lines.push('No publish-blocking privacy issues found.');
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
for (const item of result.issues) {
|
|
69
|
+
lines.push(`- [${item.severity}] ${item.id}: ${item.file}`);
|
|
70
|
+
lines.push(` ${item.message}`);
|
|
71
|
+
if (item.excerpt) lines.push(` excerpt: ${item.excerpt}`);
|
|
72
|
+
}
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function trackedFiles(cwd, includeUntracked) {
|
|
77
|
+
const args = includeUntracked
|
|
78
|
+
? ['ls-files', '--cached', '--others', '--exclude-standard']
|
|
79
|
+
: ['ls-files'];
|
|
80
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
81
|
+
if (result.status === 0 && result.stdout.trim()) {
|
|
82
|
+
return result.stdout.split(/\r?\n/).filter(Boolean);
|
|
83
|
+
}
|
|
84
|
+
return fallbackFiles(cwd);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fallbackFiles(cwd) {
|
|
88
|
+
const result = spawnSync('powershell', [
|
|
89
|
+
'-NoProfile',
|
|
90
|
+
'-Command',
|
|
91
|
+
"Get-ChildItem -Recurse -File | Where-Object { $_.FullName -notmatch '\\\\(node_modules|dist|data|\\.git)\\\\' } | ForEach-Object { Resolve-Path -Relative $_.FullName }"
|
|
92
|
+
], { cwd, encoding: 'utf8' });
|
|
93
|
+
if (result.status !== 0) return [];
|
|
94
|
+
return result.stdout
|
|
95
|
+
.split(/\r?\n/)
|
|
96
|
+
.map(line => line.trim().replace(/^\.[\\/]/, ''))
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isReadableTextFile(path) {
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(path) || statSync(path).size > 512 * 1024) return false;
|
|
103
|
+
const lower = path.toLowerCase();
|
|
104
|
+
const ext = lower.slice(lower.lastIndexOf('.'));
|
|
105
|
+
return TEXT_EXTENSIONS.has(ext) || !lower.includes('.');
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function issue(pattern, file) {
|
|
112
|
+
return {
|
|
113
|
+
id: pattern.id,
|
|
114
|
+
severity: pattern.severity,
|
|
115
|
+
file: normalizePath(file),
|
|
116
|
+
message: pattern.message
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function safeExcerpt(value) {
|
|
121
|
+
return String(value).replace(/\s+/g, ' ').slice(0, 160);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizePath(path) {
|
|
125
|
+
return relative(process.cwd(), join(process.cwd(), path)).replace(/\\/g, '/');
|
|
126
|
+
}
|