@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.
@@ -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],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safetnsr/vet",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "vet your AI-generated code — one command, one score card, one letter grade",
5
5
  "type": "module",
6
6
  "bin": {