文件预览

main.js

查看 Clawhub Skill Llm Cost Guard 技能包中的文件内容。

文件内容

main.js

#!/usr/bin/env node
'use strict';

/**
 * llm-cost-guard — LLM Token Budget & Spend Monitor
 * OpenClaw skill — main CLI entry point
 */

const fs = require('fs');
const path = require('path');
const os = require('os');

const DATA_PATH = path.join(os.homedir(), '.openclaw', 'workspace', 'llm-cost-guard-data.json');
const CONFIG_PATH = path.join(os.homedir(), '.openclaw', 'workspace', 'llm-cost-guard-config.json');

// Default config
const DEFAULT_CONFIG = {
  dailyCostLimit: 5.00,
  monthlyCostLimit: 50.00,
  perUserDailyCostLimit: null,
  defaultModel: 'gpt-4o-mini',
  alertAt: 0.8,
  dataPath: DATA_PATH,
};

// Pricing per 1M tokens (input / output) in USD — Feb 2026
const PRICING = {
  'gpt-4o': { input: 2.50, output: 10.00 },
  'gpt-4o-mini': { input: 0.15, output: 0.60 },
  'gpt-4-turbo': { input: 10.00, output: 30.00 },
  'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
  'o1': { input: 15.00, output: 60.00 },
  'o1-mini': { input: 3.00, output: 12.00 },
  'o3-mini': { input: 1.10, output: 4.40 },
  'claude-3-5-sonnet': { input: 3.00, output: 15.00 },
  'claude-3-5-haiku': { input: 0.80, output: 4.00 },
  'claude-3-opus': { input: 15.00, output: 75.00 },
  'claude-sonnet-4': { input: 3.00, output: 15.00 },
  'claude-haiku-4': { input: 0.80, output: 4.00 },
  'llama-3.3-70b': { input: 0.59, output: 0.79 },
  'llama-3.1-8b': { input: 0.05, output: 0.08 },
  'mixtral-8x7b': { input: 0.24, output: 0.24 },
  'ollama': { input: 0, output: 0 },
  'default': { input: 1.00, output: 3.00 },
};

function loadConfig() {
  try {
    return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) };
  } catch {
    return { ...DEFAULT_CONFIG };
  }
}

function saveConfig(config) {
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
}

function loadData() {
  try {
    return JSON.parse(fs.readFileSync(DATA_PATH, 'utf8'));
  } catch {
    return { entries: [], lastReset: new Date().toISOString().split('T')[0] };
  }
}

function saveData(data) {
  fs.mkdirSync(path.dirname(DATA_PATH), { recursive: true });
  fs.writeFileSync(DATA_PATH, JSON.stringify(data, null, 2));
}

function getPricing(model) {
  const key = Object.keys(PRICING).find(k =>
    model && (model.toLowerCase().includes(k) || k.includes(model.toLowerCase()))
  );
  return PRICING[key] || PRICING['default'];
}

function calcCost(inputTokens, outputTokens, model) {
  const p = getPricing(model);
  return parseFloat(((inputTokens / 1e6) * p.input + (outputTokens / 1e6) * p.output).toFixed(8));
}

function todayStr() {
  return new Date().toISOString().split('T')[0];
}

function getEntriesForPeriod(entries, period) {
  const today = todayStr();
  if (period === 'today') {
    return entries.filter(e => e.date === today);
  } else if (period === 'month') {
    const month = today.substring(0, 7);
    return entries.filter(e => e.date && e.date.startsWith(month));
  }
  return entries;
}

function summarize(entries) {
  const byModel = {};
  const byUser = {};
  let totalCost = 0;
  let totalInput = 0;
  let totalOutput = 0;

  for (const e of entries) {
    totalCost += e.cost || 0;
    totalInput += e.inputTokens || 0;
    totalOutput += e.outputTokens || 0;

    const m = e.model || 'unknown';
    byModel[m] = byModel[m] || { cost: 0, calls: 0 };
    byModel[m].cost += e.cost || 0;
    byModel[m].calls++;

    if (e.user) {
      byUser[e.user] = byUser[e.user] || { cost: 0, calls: 0 };
      byUser[e.user].cost += e.cost || 0;
      byUser[e.user].calls++;
    }
  }

  return {
    totalCost: parseFloat(totalCost.toFixed(6)),
    totalTokens: totalInput + totalOutput,
    totalCalls: entries.length,
    byModel,
    byUser,
  };
}

// ── Commands ──────────────────────────────────────────────────────────────────

function cmdStatus() {
  const config = loadConfig();
  const data = loadData();
  const todayEntries = getEntriesForPeriod(data.entries, 'today');
  const monthEntries = getEntriesForPeriod(data.entries, 'month');
  const todaySummary = summarize(todayEntries);
  const monthSummary = summarize(monthEntries);

  const dailyPct = config.dailyCostLimit > 0
    ? ((todaySummary.totalCost / config.dailyCostLimit) * 100).toFixed(1)
    : 'N/A';
  const monthPct = config.monthlyCostLimit > 0
    ? ((monthSummary.totalCost / config.monthlyCostLimit) * 100).toFixed(1)
    : 'N/A';

  console.log('\n💰 LLM Cost Guard — Status\n');
  console.log(`📅 Today (${todayStr()})`);
  console.log(`   Spend:  $${todaySummary.totalCost.toFixed(4)} / $${config.dailyCostLimit.toFixed(2)} (${dailyPct}%)`);
  console.log(`   Tokens: ${todaySummary.totalTokens.toLocaleString()} | Calls: ${todaySummary.totalCalls}`);

  const bar = buildBar(parseFloat(dailyPct) / 100);
  console.log(`   ${bar}`);

  if (parseFloat(dailyPct) >= 100) {
    console.log('   ⛔ DAILY BUDGET EXCEEDED');
  } else if (parseFloat(dailyPct) >= 80) {
    console.log('   ⚠️  Approaching daily limit (80%+)');
  }

  console.log(`\n📆 This Month`);
  console.log(`   Spend:  $${monthSummary.totalCost.toFixed(4)} / $${config.monthlyCostLimit.toFixed(2)} (${monthPct}%)`);
  console.log(`   Tokens: ${monthSummary.totalTokens.toLocaleString()} | Calls: ${monthSummary.totalCalls}`);
  console.log('');
}

function buildBar(ratio) {
  ratio = Math.min(1, Math.max(0, ratio));
  const filled = Math.round(ratio * 20);
  const color = ratio >= 1 ? '🔴' : ratio >= 0.8 ? '🟡' : '🟢';
  return `${color} [${'█'.repeat(filled)}${'░'.repeat(20 - filled)}]`;
}

function cmdReport(period = 'today') {
  const data = loadData();
  const entries = getEntriesForPeriod(data.entries, period);
  const summary = summarize(entries);

  console.log(`\n📊 LLM Cost Guard — Report (${period})\n`);
  console.log(`Total: $${summary.totalCost.toFixed(6)} | ${summary.totalTokens.toLocaleString()} tokens | ${summary.totalCalls} calls\n`);

  if (Object.keys(summary.byModel).length > 0) {
    console.log('By Model:');
    Object.entries(summary.byModel)
      .sort((a, b) => b[1].cost - a[1].cost)
      .forEach(([m, s]) => {
        console.log(`  ${m.padEnd(25)} $${s.cost.toFixed(6).padStart(10)}  (${s.calls} calls)`);
      });
  }

  if (Object.keys(summary.byUser).length > 0) {
    console.log('\nBy User:');
    Object.entries(summary.byUser)
      .sort((a, b) => b[1].cost - a[1].cost)
      .forEach(([u, s]) => {
        console.log(`  ${u.padEnd(25)} $${s.cost.toFixed(6).padStart(10)}  (${s.calls} calls)`);
      });
  }

  console.log('');
}

function cmdLog(args) {
  const argMap = {};
  for (let i = 0; i < args.length; i++) {
    if (args[i].startsWith('--')) {
      argMap[args[i].slice(2)] = args[i + 1];
      i++;
    }
  }

  const model = argMap['model'] || loadConfig().defaultModel;
  const inputTokens = parseInt(argMap['input-tokens'] || argMap['input'] || '0', 10);
  const outputTokens = parseInt(argMap['output-tokens'] || argMap['output'] || '0', 10);
  const user = argMap['user'] || null;
  const cost = calcCost(inputTokens, outputTokens, model);

  const data = loadData();
  data.entries.push({
    date: todayStr(),
    ts: new Date().toISOString(),
    model,
    inputTokens,
    outputTokens,
    cost,
    user,
  });
  saveData(data);

  console.log(`✅ Logged: ${model} | in=${inputTokens} out=${outputTokens} tokens | $${cost.toFixed(6)}${user ? ` | user=${user}` : ''}`);
}

function cmdSetLimit(args) {
  const config = loadConfig();
  const type = args[0];
  if (type === 'daily') {
    config.dailyCostLimit = parseFloat(args[1]);
    saveConfig(config);
    console.log(`✅ Daily limit set to $${config.dailyCostLimit}`);
  } else if (type === 'monthly') {
    config.monthlyCostLimit = parseFloat(args[1]);
    saveConfig(config);
    console.log(`✅ Monthly limit set to $${config.monthlyCostLimit}`);
  } else if (type === 'user') {
    config.perUserDailyCostLimit = parseFloat(args[2]);
    saveConfig(config);
    console.log(`✅ Per-user daily limit set to $${config.perUserDailyCostLimit}`);
  } else {
    console.log('Usage: llm-cost-guard set-limit daily|monthly|user <USD>');
  }
}

function cmdReset(args) {
  const period = args[0] || 'today';
  const data = loadData();
  const today = todayStr();

  if (period === 'today') {
    data.entries = data.entries.filter(e => e.date !== today);
    saveData(data);
    console.log('✅ Today\'s data reset.');
  } else if (period === 'all') {
    data.entries = [];
    saveData(data);
    console.log('✅ All data reset.');
  } else {
    console.log('Usage: llm-cost-guard reset [today|all]');
  }
}

function cmdWatch() {
  console.log('👀 Watching LLM calls... (press Ctrl+C to stop)\n');
  const initialCount = loadData().entries.length;
  let lastCount = initialCount;

  const interval = setInterval(() => {
    const data = loadData();
    const current = data.entries.length;
    if (current > lastCount) {
      const newEntries = data.entries.slice(lastCount);
      newEntries.forEach(e => {
        console.log(`  ${e.ts} | ${e.model} | in=${e.inputTokens} out=${e.outputTokens} | $${(e.cost || 0).toFixed(6)}${e.user ? ` | ${e.user}` : ''}`);
      });
      lastCount = current;
    }
  }, 1000);

  process.on('SIGINT', () => {
    clearInterval(interval);
    console.log('\nStopped watching.');
    process.exit(0);
  });
}

function cmdHelp() {
  console.log(`
💰 llm-cost-guard v1.0.0

Commands:
  status              Show current spend vs budget
  report [period]     Full report (period: today|month|all)
  log                 Record an LLM call
    --model <name>    Model name (default: from config)
    --input <n>       Input token count
    --output <n>      Output token count
    --user <key>      User identifier (optional)
  set-limit daily <USD>      Set daily spend limit
  set-limit monthly <USD>    Set monthly spend limit
  set-limit user <key> <USD> Set per-user daily limit
  reset [today|all]   Reset counters
  watch               Live tail of LLM calls
  help                Show this help

Examples:
  llm-cost-guard status
  llm-cost-guard log --model gpt-4o --input 1500 --output 800 --user alice
  llm-cost-guard report month
  llm-cost-guard set-limit daily 5.00
  llm-cost-guard reset today
`);
}

// ── Main ──────────────────────────────────────────────────────────────────────

const [,, cmd, ...rest] = process.argv;

switch (cmd) {
  case 'status': cmdStatus(); break;
  case 'report': cmdReport(rest[0] || 'today'); break;
  case 'log': cmdLog(rest); break;
  case 'set-limit': cmdSetLimit(rest); break;
  case 'reset': cmdReset(rest); break;
  case 'watch': cmdWatch(); break;
  case 'help':
  case '--help':
  case undefined:
    cmdHelp(); break;
  default:
    console.error(`Unknown command: ${cmd}`);
    cmdHelp();
    process.exit(1);
}