@safetnsr/vet 1.11.1 → 1.12.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/dist/checks/subsidy.d.ts +29 -0
- package/dist/checks/subsidy.js +217 -0
- package/dist/cli.js +26 -3
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CheckResult } from '../types.js';
|
|
2
|
+
interface ModelCost {
|
|
3
|
+
inputTokens: number;
|
|
4
|
+
outputTokens: number;
|
|
5
|
+
cost: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SubsidyData {
|
|
8
|
+
sessionCount: number;
|
|
9
|
+
periodStart: string;
|
|
10
|
+
periodEnd: string;
|
|
11
|
+
models: Record<string, ModelCost>;
|
|
12
|
+
totalCost: number;
|
|
13
|
+
subscriptionCost: number;
|
|
14
|
+
subsidized: number;
|
|
15
|
+
subsidyRate: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function computeSubsidy(entries: Array<Record<string, unknown>>, plan: string): {
|
|
18
|
+
models: Record<string, ModelCost>;
|
|
19
|
+
totalCost: number;
|
|
20
|
+
subscriptionCost: number;
|
|
21
|
+
subsidized: number;
|
|
22
|
+
subsidyRate: number;
|
|
23
|
+
};
|
|
24
|
+
export declare function checkSubsidy(cwd: string): Promise<CheckResult>;
|
|
25
|
+
export declare function runSubsidyCommand(format: 'ascii' | 'json', options?: {
|
|
26
|
+
since?: string;
|
|
27
|
+
plan?: string;
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { findSessionFiles, parseSessionFile } from './receipt.js';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
const PRICING = {
|
|
4
|
+
'claude-opus-4-6': { input: 15, output: 75 },
|
|
5
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
6
|
+
'claude-sonnet-4-5': { input: 3, output: 15 },
|
|
7
|
+
'claude-haiku-3-5': { input: 0.80, output: 4 },
|
|
8
|
+
'gpt-5.4': { input: 2.50, output: 10 },
|
|
9
|
+
'gpt-4o': { input: 2.50, output: 10 },
|
|
10
|
+
'gemini-2.5-pro': { input: 1.25, output: 10 },
|
|
11
|
+
};
|
|
12
|
+
const FALLBACK_PRICING = { input: 3, output: 15 };
|
|
13
|
+
const SUBSCRIPTION_TIERS = {
|
|
14
|
+
'claude-pro': 20,
|
|
15
|
+
'claude-max-5x': 100,
|
|
16
|
+
'claude-max-20x': 200,
|
|
17
|
+
'chatgpt-plus': 20,
|
|
18
|
+
'chatgpt-pro': 200,
|
|
19
|
+
};
|
|
20
|
+
const DEFAULT_SUBSCRIPTION = 20;
|
|
21
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
22
|
+
function getPricing(model) {
|
|
23
|
+
// Try exact match first, then prefix match
|
|
24
|
+
if (PRICING[model])
|
|
25
|
+
return PRICING[model];
|
|
26
|
+
for (const [key, pricing] of Object.entries(PRICING)) {
|
|
27
|
+
if (model.startsWith(key))
|
|
28
|
+
return pricing;
|
|
29
|
+
}
|
|
30
|
+
return FALLBACK_PRICING;
|
|
31
|
+
}
|
|
32
|
+
function getSubscriptionCost(plan) {
|
|
33
|
+
return SUBSCRIPTION_TIERS[plan] ?? DEFAULT_SUBSCRIPTION;
|
|
34
|
+
}
|
|
35
|
+
export function computeSubsidy(entries, plan) {
|
|
36
|
+
const models = {};
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
// Extract model and usage from entry or entry.message
|
|
39
|
+
let model;
|
|
40
|
+
let usage;
|
|
41
|
+
if (entry.model && typeof entry.model === 'string')
|
|
42
|
+
model = entry.model;
|
|
43
|
+
if (entry.usage && typeof entry.usage === 'object')
|
|
44
|
+
usage = entry.usage;
|
|
45
|
+
const msg = entry.message;
|
|
46
|
+
if (msg) {
|
|
47
|
+
if (!model && msg.model && typeof msg.model === 'string')
|
|
48
|
+
model = msg.model;
|
|
49
|
+
if (!usage && msg.usage && typeof msg.usage === 'object')
|
|
50
|
+
usage = msg.usage;
|
|
51
|
+
}
|
|
52
|
+
if (!model || !usage)
|
|
53
|
+
continue;
|
|
54
|
+
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
|
|
55
|
+
const outputTokens = typeof usage.output_tokens === 'number' ? usage.output_tokens : 0;
|
|
56
|
+
if (inputTokens === 0 && outputTokens === 0)
|
|
57
|
+
continue;
|
|
58
|
+
const pricing = getPricing(model);
|
|
59
|
+
const cost = (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output;
|
|
60
|
+
if (!models[model])
|
|
61
|
+
models[model] = { inputTokens: 0, outputTokens: 0, cost: 0 };
|
|
62
|
+
models[model].inputTokens += inputTokens;
|
|
63
|
+
models[model].outputTokens += outputTokens;
|
|
64
|
+
models[model].cost += cost;
|
|
65
|
+
}
|
|
66
|
+
const totalCost = Object.values(models).reduce((sum, m) => sum + m.cost, 0);
|
|
67
|
+
const subscriptionCost = getSubscriptionCost(plan);
|
|
68
|
+
const subsidized = Math.max(0, totalCost - subscriptionCost);
|
|
69
|
+
const subsidyRate = totalCost > 0 ? (subsidized / totalCost) * 100 : 0;
|
|
70
|
+
return { models, totalCost, subscriptionCost, subsidized, subsidyRate };
|
|
71
|
+
}
|
|
72
|
+
// ── ASCII card ───────────────────────────────────────────────────────────────
|
|
73
|
+
function renderCard(data) {
|
|
74
|
+
const W = 43;
|
|
75
|
+
const hr = '─'.repeat(W);
|
|
76
|
+
const pad = (s, w = W) => {
|
|
77
|
+
const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
78
|
+
const diff = w - visible.length;
|
|
79
|
+
return diff > 0 ? s + ' '.repeat(diff) : s;
|
|
80
|
+
};
|
|
81
|
+
const line = (s) => `│ ${pad(s, W - 2)} │`;
|
|
82
|
+
const lines = [];
|
|
83
|
+
lines.push(`┌${hr}┐`);
|
|
84
|
+
const title = 'YOUR AI COST THIS MONTH';
|
|
85
|
+
const lp = Math.floor((W - 2 - title.length) / 2);
|
|
86
|
+
const rp = W - 2 - title.length - lp;
|
|
87
|
+
lines.push(`│${' '.repeat(lp + 1)}${title}${' '.repeat(rp + 1)}│`);
|
|
88
|
+
lines.push(`├${hr}┤`);
|
|
89
|
+
lines.push(line(`sessions analyzed:${' '.repeat(Math.max(1, W - 2 - 18 - String(data.sessionCount).length))}${data.sessionCount}`));
|
|
90
|
+
const period = `${data.periodStart} — ${data.periodEnd}`;
|
|
91
|
+
lines.push(line(`period:${' '.repeat(Math.max(1, W - 2 - 7 - period.length))}${period}`));
|
|
92
|
+
lines.push(`├${hr}┤`);
|
|
93
|
+
// Per-model breakdown sorted by cost desc
|
|
94
|
+
const sorted = Object.entries(data.models).sort((a, b) => b[1].cost - a[1].cost);
|
|
95
|
+
for (const [model, mc] of sorted) {
|
|
96
|
+
const pct = data.totalCost > 0 ? Math.round((mc.cost / data.totalCost) * 100) : 0;
|
|
97
|
+
const costStr = `$${mc.cost.toFixed(2)}`;
|
|
98
|
+
const pctStr = `(${pct}%)`;
|
|
99
|
+
const gap = Math.max(1, W - 2 - model.length - costStr.length - pctStr.length - 4);
|
|
100
|
+
lines.push(line(`${model}${' '.repeat(gap)}${costStr} ${pctStr}`));
|
|
101
|
+
}
|
|
102
|
+
lines.push(`├${hr}┤`);
|
|
103
|
+
const fmtRow = (label, value) => {
|
|
104
|
+
const gap = Math.max(1, W - 2 - label.length - value.length);
|
|
105
|
+
return line(`${label}${' '.repeat(gap)}${value}`);
|
|
106
|
+
};
|
|
107
|
+
lines.push(fmtRow('USED (list price):', `$${data.totalCost.toFixed(2)}`));
|
|
108
|
+
lines.push(fmtRow('PAID (subscription):', `$${data.subscriptionCost.toFixed(2)}`));
|
|
109
|
+
lines.push(fmtRow('SUBSIDIZED:', `$${data.subsidized.toFixed(2)}`));
|
|
110
|
+
lines.push(fmtRow('SUBSIDY RATE:', `${data.subsidyRate.toFixed(1)}%`));
|
|
111
|
+
lines.push(`└${hr}┘`);
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
// ── Check for vet scan ───────────────────────────────────────────────────────
|
|
115
|
+
export async function checkSubsidy(cwd) {
|
|
116
|
+
const files = findSessionFiles();
|
|
117
|
+
const issues = [];
|
|
118
|
+
if (files.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
name: 'subsidy',
|
|
121
|
+
score: 100,
|
|
122
|
+
maxScore: 100,
|
|
123
|
+
issues: [{ severity: 'info', message: 'no session logs found', fixable: false }],
|
|
124
|
+
summary: 'no session logs found',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const allEntries = [];
|
|
128
|
+
for (const f of files) {
|
|
129
|
+
try {
|
|
130
|
+
const { entries } = await parseSessionFile(f);
|
|
131
|
+
allEntries.push(...entries);
|
|
132
|
+
}
|
|
133
|
+
catch { /* skip */ }
|
|
134
|
+
}
|
|
135
|
+
const result = computeSubsidy(allEntries, 'claude-pro');
|
|
136
|
+
if (Object.keys(result.models).length === 0) {
|
|
137
|
+
return {
|
|
138
|
+
name: 'subsidy',
|
|
139
|
+
score: 100,
|
|
140
|
+
maxScore: 100,
|
|
141
|
+
issues: [{ severity: 'info', message: 'no token usage data in sessions', fixable: false }],
|
|
142
|
+
summary: 'no token usage data in sessions',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
issues.push({
|
|
146
|
+
severity: 'info',
|
|
147
|
+
message: `API list price: $${result.totalCost.toFixed(2)}, subscription: $${result.subscriptionCost.toFixed(2)}, subsidy: ${result.subsidyRate.toFixed(1)}%`,
|
|
148
|
+
fixable: false,
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
name: 'subsidy',
|
|
152
|
+
score: 100,
|
|
153
|
+
maxScore: 100,
|
|
154
|
+
issues,
|
|
155
|
+
summary: `used $${result.totalCost.toFixed(2)} at list price (paid $${result.subscriptionCost.toFixed(2)}, subsidy ${result.subsidyRate.toFixed(1)}%)`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
// ── Subcommand ───────────────────────────────────────────────────────────────
|
|
159
|
+
export async function runSubsidyCommand(format, options) {
|
|
160
|
+
const plan = options?.plan || 'claude-pro';
|
|
161
|
+
const since = options?.since;
|
|
162
|
+
let files = findSessionFiles();
|
|
163
|
+
if (since) {
|
|
164
|
+
const sinceDate = new Date(since);
|
|
165
|
+
if (!isNaN(sinceDate.getTime())) {
|
|
166
|
+
files = files.filter(f => {
|
|
167
|
+
try {
|
|
168
|
+
return statSync(f).mtimeMs >= sinceDate.getTime();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (files.length === 0) {
|
|
177
|
+
if (format === 'json') {
|
|
178
|
+
console.log(JSON.stringify({ error: 'no session files found' }));
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
console.error('no claude session files found in ~/.claude/projects/');
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const allEntries = [];
|
|
186
|
+
let earliest = Infinity;
|
|
187
|
+
let latest = -Infinity;
|
|
188
|
+
for (const f of files) {
|
|
189
|
+
try {
|
|
190
|
+
const { entries } = await parseSessionFile(f);
|
|
191
|
+
allEntries.push(...entries);
|
|
192
|
+
const stat = statSync(f);
|
|
193
|
+
if (stat.mtimeMs < earliest)
|
|
194
|
+
earliest = stat.mtimeMs;
|
|
195
|
+
if (stat.mtimeMs > latest)
|
|
196
|
+
latest = stat.mtimeMs;
|
|
197
|
+
}
|
|
198
|
+
catch { /* skip */ }
|
|
199
|
+
}
|
|
200
|
+
const result = computeSubsidy(allEntries, plan);
|
|
201
|
+
const data = {
|
|
202
|
+
sessionCount: files.length,
|
|
203
|
+
periodStart: earliest === Infinity ? 'unknown' : new Date(earliest).toISOString().slice(0, 10),
|
|
204
|
+
periodEnd: latest === -Infinity ? 'unknown' : new Date(latest).toISOString().slice(0, 10),
|
|
205
|
+
models: result.models,
|
|
206
|
+
totalCost: result.totalCost,
|
|
207
|
+
subscriptionCost: result.subscriptionCost,
|
|
208
|
+
subsidized: result.subsidized,
|
|
209
|
+
subsidyRate: result.subsidyRate,
|
|
210
|
+
};
|
|
211
|
+
if (format === 'json') {
|
|
212
|
+
console.log(JSON.stringify(data, null, 2));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
console.log(renderCard(data));
|
|
216
|
+
}
|
|
217
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { checkTests } from './checks/tests.js';
|
|
|
20
20
|
import { checkMap, renderMapReport } from './checks/map.js';
|
|
21
21
|
import { checkPermissions } from './checks/permissions.js';
|
|
22
22
|
import { checkCompact, runCompactCommand } from './checks/compact.js';
|
|
23
|
+
import { checkSubsidy, runSubsidyCommand } from './checks/subsidy.js';
|
|
23
24
|
import { score } from './scorer.js';
|
|
24
25
|
import { reportPretty, reportJSON, reportBadge } from './reporter.js';
|
|
25
26
|
import { clearCache } from './file-cache.js';
|
|
@@ -35,6 +36,13 @@ for (let i = 0; i < args.length; i++) {
|
|
|
35
36
|
flagMap.set('since', args[i + 1]);
|
|
36
37
|
i++;
|
|
37
38
|
}
|
|
39
|
+
else if (args[i].startsWith('--plan=')) {
|
|
40
|
+
flagMap.set('plan', args[i].split('=')[1]);
|
|
41
|
+
}
|
|
42
|
+
else if (args[i] === '--plan' && args[i + 1]) {
|
|
43
|
+
flagMap.set('plan', args[i + 1]);
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
38
46
|
else if (args[i].startsWith('--max-files=')) {
|
|
39
47
|
flagMap.set('max-files', args[i].split('=')[1]);
|
|
40
48
|
}
|
|
@@ -61,6 +69,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
61
69
|
npx @safetnsr/vet map [dir] show agent visibility map
|
|
62
70
|
npx @safetnsr/vet permissions [dir] audit Claude Code config for dangerous grants
|
|
63
71
|
npx @safetnsr/vet compact [log] compaction forensics for claude code sessions
|
|
72
|
+
npx @safetnsr/vet subsidy [--plan tier] [--since date] show AI cost vs subscription
|
|
64
73
|
|
|
65
74
|
${c.dim}categories:${c.reset}
|
|
66
75
|
security (30%) scan, secrets, config, model usage
|
|
@@ -96,7 +105,7 @@ if (flags.has('--version') || flags.has('-v')) {
|
|
|
96
105
|
}
|
|
97
106
|
process.exit(0);
|
|
98
107
|
}
|
|
99
|
-
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact'];
|
|
108
|
+
const COMMANDS = ['init', 'receipt', 'map', 'permissions', 'compact', 'subsidy'];
|
|
100
109
|
const command = COMMANDS.includes(positional[0]) ? positional[0] : undefined;
|
|
101
110
|
const cwd = resolve(positional.find(p => !COMMANDS.includes(p)) || '.');
|
|
102
111
|
const isCI = flags.has('--ci');
|
|
@@ -191,6 +200,19 @@ if (command === 'compact') {
|
|
|
191
200
|
}
|
|
192
201
|
process.exit(0);
|
|
193
202
|
}
|
|
203
|
+
if (command === 'subsidy') {
|
|
204
|
+
try {
|
|
205
|
+
const format = isJSON ? 'json' : 'ascii';
|
|
206
|
+
const plan = flagMap.get('plan') || 'claude-pro';
|
|
207
|
+
const since = flagMap.get('since');
|
|
208
|
+
await runSubsidyCommand(format, { since, plan });
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
console.error(`${c.red}subsidy failed:${c.reset}`, e instanceof Error ? e.message : e);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
process.exit(0);
|
|
215
|
+
}
|
|
194
216
|
if (!isGitRepo(cwd)) {
|
|
195
217
|
console.error(`${c.red}not a git repository${c.reset}. vet operates on git repos.`);
|
|
196
218
|
process.exit(1);
|
|
@@ -242,7 +264,7 @@ async function runChecks() {
|
|
|
242
264
|
}
|
|
243
265
|
}
|
|
244
266
|
// Run ALL independent checks in parallel
|
|
245
|
-
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult,] = await Promise.all([
|
|
267
|
+
const [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, integrityResult, readyResult, debtResult, depsResult, receiptResult, compactResult, subsidyResult, memoryResult, verifyResult, testsResult,] = await Promise.all([
|
|
246
268
|
withTimeout('scan', () => checkScan(cwd)),
|
|
247
269
|
withTimeout('secrets', () => checkSecrets(cwd)),
|
|
248
270
|
withTimeout('config', () => checkConfig(cwd, ignore)),
|
|
@@ -255,6 +277,7 @@ async function runChecks() {
|
|
|
255
277
|
withTimeout('deps', () => checkDeps(cwd)),
|
|
256
278
|
withTimeout('receipt', () => checkReceipt(cwd)),
|
|
257
279
|
withTimeout('compact', () => checkCompact(cwd)),
|
|
280
|
+
withTimeout('subsidy', () => checkSubsidy(cwd)),
|
|
258
281
|
withTimeout('memory', () => checkMemory(cwd)),
|
|
259
282
|
withTimeout('verify', () => checkVerify(cwd, since)),
|
|
260
283
|
withTimeout('tests', () => checkTests(cwd, ignore)),
|
|
@@ -267,7 +290,7 @@ async function runChecks() {
|
|
|
267
290
|
// Clear file cache after all checks complete
|
|
268
291
|
clearCache();
|
|
269
292
|
return score(cwd, {
|
|
270
|
-
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult],
|
|
293
|
+
security: [scanResult, secretsResult, configResult, modelsResult, owaspResult, permissionsResult, subsidyResult],
|
|
271
294
|
integrity: [diffResult, integrityResult, receiptResult, compactResult, memoryResult, verifyResult, testsResult],
|
|
272
295
|
debt: [readyResult, historyResult, debtResult],
|
|
273
296
|
deps: [depsResult],
|