import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { SshService } from '../sisters/ssh.service'; import { SisterName } from '../common/sister-name.pipe'; // Claude Sonnet 기준 가격 (USD per 1M tokens) const MODEL_PRICING: Record = { 'claude-sonnet-4': { input: 3.0, output: 15.0 }, 'claude-sonnet-4.6': { input: 3.0, output: 15.0 }, 'claude-3-5-sonnet': { input: 3.0, output: 15.0 }, 'gpt-4o': { input: 2.5, output: 10.0 }, 'gpt-4o-mini': { input: 0.15, output: 0.6 }, default: { input: 3.0, output: 15.0 }, }; const SISTER_NAMES: SisterName[] = ['harang', 'narang', 'darang', 'erang']; export type CostPeriod = 'day' | 'week' | 'month'; @Injectable() export class CostsService { private readonly logger = new Logger(CostsService.name); constructor( private readonly prisma: PrismaService, private readonly ssh: SshService, private readonly config: ConfigService, ) {} async getCosts(period: CostPeriod = 'week') { const since = this.getSince(period); const [summary, bySister, byModel, timeline] = await Promise.all([ this.getSummary(since), this.getBySister(since), this.getByModel(since), this.getTimeline(since, period), ]); return { period, since: since.toISOString(), summary, bySister, byModel, timeline }; } async recordCosts(sisterName: SisterName) { const keyPath = this.config.get('SSH_KEY_PATH'); if (!keyPath) return; const sister = await this.prisma.sisterConfig.findUnique({ where: { name: sisterName } }); if (!sister) return; try { // OpenClaw 세션에서 토큰 사용량 파싱 (python3 → node.js → jq fallback) const result = await this.ssh.executeCommand( sister.ip, sister.user, keyPath, `SESSION_FILE=~/.openclaw/sessions/main.json; \ if [ ! -f "$SESSION_FILE" ]; then echo "unknown 0 0"; \ elif command -v node >/dev/null 2>&1; then \ node -e "try{const d=require('fs').readFileSync(process.env.HOME+'/.openclaw/sessions/main.json','utf8');const j=JSON.parse(d);const u=j.usage||{};console.log((j.model||'unknown')+' '+(u.input_tokens||0)+' '+(u.output_tokens||0))}catch(e){console.log('unknown 0 0')}" 2>/dev/null; \ elif command -v python3 >/dev/null 2>&1; then \ cat $SESSION_FILE | python3 -c "import json,sys;d=json.load(sys.stdin);u=d.get('usage',{});print(d.get('model','unknown'),u.get('input_tokens',0),u.get('output_tokens',0))" 2>/dev/null; \ elif command -v jq >/dev/null 2>&1; then \ echo "$(jq -r '.model // "unknown"' $SESSION_FILE) $(jq -r '.usage.input_tokens // 0' $SESSION_FILE) $(jq -r '.usage.output_tokens // 0' $SESSION_FILE)" 2>/dev/null; \ else echo "unknown 0 0"; fi`, ); const parts = result.stdout.trim().split(' '); if (parts.length < 3) return; const [model, inputStr, outputStr] = parts; const inputTokens = parseInt(inputStr, 10) || 0; const outputTokens = parseInt(outputStr, 10) || 0; const totalTokens = inputTokens + outputTokens; if (totalTokens === 0) return; const pricing = MODEL_PRICING[model] ?? MODEL_PRICING.default; const estimatedUsd = (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output; await this.prisma.costLog.create({ data: { sisterName, model, inputTokens, outputTokens, totalTokens, estimatedUsd, }, }); } catch { this.logger.warn(`Cost recording failed for ${sisterName}`); } } private async getSummary(since: Date) { const result = await this.prisma.costLog.aggregate({ where: { recordedAt: { gte: since } }, _sum: { inputTokens: true, outputTokens: true, totalTokens: true, estimatedUsd: true }, _count: true, }); return { totalTokens: result._sum.totalTokens ?? 0, inputTokens: result._sum.inputTokens ?? 0, outputTokens: result._sum.outputTokens ?? 0, estimatedUsd: Math.round((result._sum.estimatedUsd ?? 0) * 10000) / 10000, recordCount: result._count, }; } private async getBySister(since: Date) { const rows = await this.prisma.costLog.groupBy({ by: ['sisterName'], where: { recordedAt: { gte: since } }, _sum: { totalTokens: true, estimatedUsd: true }, orderBy: { _sum: { estimatedUsd: 'desc' } }, }); return rows.map((r) => ({ name: r.sisterName, totalTokens: r._sum.totalTokens ?? 0, estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000, })); } private async getByModel(since: Date) { const rows = await this.prisma.costLog.groupBy({ by: ['model'], where: { recordedAt: { gte: since } }, _sum: { totalTokens: true, estimatedUsd: true }, _count: true, orderBy: { _sum: { estimatedUsd: 'desc' } }, }); return rows.map((r) => ({ model: r.model, totalTokens: r._sum.totalTokens ?? 0, estimatedUsd: Math.round((r._sum.estimatedUsd ?? 0) * 10000) / 10000, count: r._count, })); } private async getTimeline(since: Date, period: CostPeriod) { const logs = await this.prisma.costLog.findMany({ where: { recordedAt: { gte: since } }, orderBy: { recordedAt: 'asc' }, select: { sisterName: true, totalTokens: true, estimatedUsd: true, recordedAt: true }, }); // 날짜별 집계 const grouped: Record = {}; for (const log of logs) { const dateKey = log.recordedAt.toISOString().slice(0, 10); if (!grouped[dateKey]) { grouped[dateKey] = { date: dateKey, totalTokens: 0, estimatedUsd: 0 }; } grouped[dateKey].totalTokens += log.totalTokens; grouped[dateKey].estimatedUsd += log.estimatedUsd; } return Object.values(grouped).map((d) => ({ ...d, estimatedUsd: Math.round(d.estimatedUsd * 10000) / 10000, })); } private getSince(period: CostPeriod): Date { const now = new Date(); switch (period) { case 'day': return new Date(now.getTime() - 24 * 60 * 60 * 1000); case 'week': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); case 'month': return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); } } }