diff --git a/app/Helpers/AiTokenHelper.php b/app/Helpers/AiTokenHelper.php index de6c20d9..195b43fa 100644 --- a/app/Helpers/AiTokenHelper.php +++ b/app/Helpers/AiTokenHelper.php @@ -2,6 +2,7 @@ namespace App\Helpers; +use App\Models\System\AiPricingConfig; use App\Models\System\AiTokenUsage; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -23,9 +24,10 @@ public static function saveGeminiUsage(array $apiResult, string $model, string $ $completionTokens = $usage['candidatesTokenCount'] ?? 0; $totalTokens = $usage['totalTokenCount'] ?? 0; - // Gemini 2.0 Flash 기준 단가 - $inputPrice = 0.10 / 1_000_000; - $outputPrice = 0.40 / 1_000_000; + // DB 단가 조회 (fallback: 하드코딩 기본값) + $pricing = AiPricingConfig::getActivePricing('gemini'); + $inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.10 / 1_000_000; + $outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000; self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice); } catch (\Exception $e) { @@ -48,9 +50,10 @@ public static function saveClaudeUsage(array $apiResult, string $model, string $ $completionTokens = $usage['output_tokens'] ?? 0; $totalTokens = $promptTokens + $completionTokens; - // Claude 3 Haiku 기준 단가 - $inputPrice = 0.25 / 1_000_000; - $outputPrice = 1.25 / 1_000_000; + // DB 단가 조회 (fallback: 하드코딩 기본값) + $pricing = AiPricingConfig::getActivePricing('claude'); + $inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.25 / 1_000_000; + $outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 1.25 / 1_000_000; self::save($model, $menuName, $promptTokens, $completionTokens, $totalTokens, $inputPrice, $outputPrice); } catch (\Exception $e) { @@ -66,8 +69,10 @@ public static function saveGcsStorageUsage(string $menuName, int $fileSizeBytes) { try { $fileSizeMB = $fileSizeBytes / (1024 * 1024); - // 업로드 API 호출 비용 ($0.005/1000 operations) + 월 스토리지 비용 ($0.02/GB, 7일 기준) - $operationCost = 0.005 / 1000; + // DB 단가 조회 (fallback: 하드코딩 기본값) + $pricing = AiPricingConfig::getActivePricing('google-gcs'); + $unitPrice = $pricing ? (float) $pricing->unit_price : 0.005; + $operationCost = $unitPrice / 1000; $storageCost = ($fileSizeMB / 1024) * 0.02 * (7 / 30); // 7일 보관 기준 $costUsd = $operationCost + $storageCost; @@ -84,8 +89,10 @@ public static function saveGcsStorageUsage(string $menuName, int $fileSizeBytes) public static function saveSttUsage(string $menuName, int $durationSeconds): void { try { - // latest_long 모델: $0.009 per 15 seconds = $0.0006 per second - $costUsd = ceil($durationSeconds / 15) * 0.009; + // DB 단가 조회 (fallback: 하드코딩 기본값) + $pricing = AiPricingConfig::getActivePricing('google-stt'); + $sttUnitPrice = $pricing ? (float) $pricing->unit_price : 0.009; + $costUsd = ceil($durationSeconds / 15) * $sttUnitPrice; self::save('google-speech-to-text', $menuName, $durationSeconds, 0, $durationSeconds, $costUsd / max($durationSeconds, 1), 0); } catch (\Exception $e) { @@ -106,7 +113,7 @@ private static function save( float $outputPricePerToken, ): void { $costUsd = ($promptTokens * $inputPricePerToken) + ($completionTokens * $outputPricePerToken); - $exchangeRate = (float) config('services.gemini.exchange_rate', 1400); + $exchangeRate = AiPricingConfig::getExchangeRate(); $costKrw = $costUsd * $exchangeRate; $tenantId = session('selected_tenant_id', 1); diff --git a/app/Http/Controllers/System/AiTokenUsageController.php b/app/Http/Controllers/System/AiTokenUsageController.php index 8c9dd7d7..94dfacd1 100644 --- a/app/Http/Controllers/System/AiTokenUsageController.php +++ b/app/Http/Controllers/System/AiTokenUsageController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\System; use App\Http\Controllers\Controller; +use App\Models\System\AiPricingConfig; use App\Models\System\AiTokenUsage; use App\Models\Tenants\Tenant; use Illuminate\Http\JsonResponse; @@ -120,4 +121,57 @@ public function list(Request $request): JsonResponse ], ]); } + + public function pricingList(): JsonResponse + { + $configs = AiPricingConfig::orderBy('id')->get(); + + return response()->json([ + 'success' => true, + 'data' => $configs->map(fn ($c) => [ + 'id' => $c->id, + 'provider' => $c->provider, + 'model_name' => $c->model_name, + 'input_price_per_million' => (float) $c->input_price_per_million, + 'output_price_per_million' => (float) $c->output_price_per_million, + 'unit_price' => (float) $c->unit_price, + 'unit_description' => $c->unit_description, + 'exchange_rate' => (float) $c->exchange_rate, + 'is_active' => $c->is_active, + 'description' => $c->description, + ]), + ]); + } + + public function pricingUpdate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'configs' => 'required|array', + 'configs.*.id' => 'required|integer|exists:ai_pricing_configs,id', + 'configs.*.model_name' => 'required|string|max:100', + 'configs.*.input_price_per_million' => 'required|numeric|min:0', + 'configs.*.output_price_per_million' => 'required|numeric|min:0', + 'configs.*.unit_price' => 'required|numeric|min:0', + 'configs.*.exchange_rate' => 'required|numeric|min:0', + 'configs.*.description' => 'nullable|string|max:255', + ]); + + foreach ($validated['configs'] as $item) { + AiPricingConfig::where('id', $item['id'])->update([ + 'model_name' => $item['model_name'], + 'input_price_per_million' => $item['input_price_per_million'], + 'output_price_per_million' => $item['output_price_per_million'], + 'unit_price' => $item['unit_price'], + 'exchange_rate' => $item['exchange_rate'], + 'description' => $item['description'] ?? null, + ]); + } + + AiPricingConfig::clearCache(); + + return response()->json([ + 'success' => true, + 'message' => '단가 설정이 저장되었습니다.', + ]); + } } diff --git a/app/Models/System/AiPricingConfig.php b/app/Models/System/AiPricingConfig.php new file mode 100644 index 00000000..fd7f77d2 --- /dev/null +++ b/app/Models/System/AiPricingConfig.php @@ -0,0 +1,67 @@ + 'decimal:4', + 'output_price_per_million' => 'decimal:4', + 'unit_price' => 'decimal:6', + 'exchange_rate' => 'decimal:2', + 'is_active' => 'boolean', + ]; + + /** + * 활성 단가 설정 조회 (캐시 적용) + */ + public static function getActivePricing(string $provider): ?self + { + return Cache::remember("ai_pricing_{$provider}", 3600, function () use ($provider) { + return self::where('provider', $provider) + ->where('is_active', true) + ->first(); + }); + } + + /** + * 환율 조회 (첫 번째 활성 레코드 기준) + */ + public static function getExchangeRate(): float + { + return Cache::remember('ai_pricing_exchange_rate', 3600, function () { + $config = self::where('is_active', true)->first(); + + return $config ? (float) $config->exchange_rate : 1400.0; + }); + } + + /** + * 캐시 초기화 + */ + public static function clearCache(): void + { + $providers = ['gemini', 'claude', 'google-stt', 'google-gcs']; + foreach ($providers as $provider) { + Cache::forget("ai_pricing_{$provider}"); + } + Cache::forget('ai_pricing_exchange_rate'); + } +} diff --git a/resources/views/system/ai-token-usage/index.blade.php b/resources/views/system/ai-token-usage/index.blade.php index 1099a883..cbeea004 100644 --- a/resources/views/system/ai-token-usage/index.blade.php +++ b/resources/views/system/ai-token-usage/index.blade.php @@ -99,6 +99,86 @@ color: #9ca3af; } .empty-state svg { margin: 0 auto 12px; } + + .modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + } + .modal-content { + background: white; + border-radius: 16px; + width: 90%; + max-width: 800px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + } + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid #e5e7eb; + } + .modal-header h2 { font-size: 18px; font-weight: 700; color: #111827; } + .modal-body { padding: 24px; } + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 16px 24px; + border-top: 1px solid #e5e7eb; + } + + .pricing-table { width: 100%; border-collapse: collapse; } + .pricing-table th { + font-size: 12px; + font-weight: 600; + color: #6b7280; + padding: 8px 10px; + text-align: left; + border-bottom: 2px solid #e5e7eb; + white-space: nowrap; + } + .pricing-table td { + padding: 8px 10px; + border-bottom: 1px solid #f3f4f6; + vertical-align: middle; + } + .pricing-table input[type="text"], + .pricing-table input[type="number"] { + border: 1px solid #d1d5db; + border-radius: 6px; + padding: 5px 8px; + font-size: 13px; + width: 100%; + min-width: 80px; + } + .pricing-table input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59,130,246,0.2); + } + + .toast { + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + z-index: 60; + animation: slideIn 0.3s ease; + } + .toast-success { background: #dcfce7; color: #16a34a; border: 1px solid #bbf7d0; } + .toast-error { background: #fee2e2; color: #dc2626; border: 1px solid #fecaca; } + @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @endpush @@ -139,6 +219,9 @@ const DollarSign = createIcon('dollar-sign'); const Hash = createIcon('hash'); const ArrowUpDown = createIcon('arrow-up-down'); +const Settings = createIcon('settings'); +const X = createIcon('x'); +const Save = createIcon('save'); const getCategory = (menuName) => { if (!menuName) return '-'; @@ -155,12 +238,174 @@ const fmtUsd = (n) => n != null ? '$' + Number(n).toFixed(4) : '$0.0000'; const fmtKrw = (n) => n != null ? Number(n).toLocaleString('ko-KR', { maximumFractionDigits: 0 }) + '원' : '0원'; +const PROVIDER_LABELS = { + 'gemini': { label: 'Gemini', type: 'token' }, + 'claude': { label: 'Claude', type: 'token' }, + 'google-stt': { label: 'Google STT', type: 'unit' }, + 'google-gcs': { label: 'Google GCS', type: 'unit' }, +}; + +function PricingModal({ show, onClose, onSaved }) { + const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (show) { + setLoading(true); + fetch('/system/ai-token-usage/pricing') + .then(r => r.json()) + .then(json => { if (json.success) setConfigs(json.data); }) + .catch(err => console.error('단가 조회 실패:', err)) + .finally(() => setLoading(false)); + } + }, [show]); + + const updateField = (idx, field, value) => { + setConfigs(prev => { + const next = [...prev]; + next[idx] = { ...next[idx], [field]: value }; + return next; + }); + }; + + const handleSave = async () => { + setSaving(true); + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; + const res = await fetch('/system/ai-token-usage/pricing', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + body: JSON.stringify({ configs }), + }); + const json = await res.json(); + if (json.success) { + onSaved('단가 설정이 저장되었습니다.'); + onClose(); + } else { + onSaved(json.message || '저장 실패', true); + } + } catch (err) { + onSaved('저장 중 오류가 발생했습니다.', true); + } finally { + setSaving(false); + } + }; + + if (!show) return null; + + return ( +
로딩 중...
+| 제공자 | +모델명 | +입력 단가 $/1M tokens |
+ 출력 단가 $/1M tokens |
+ 단위 가격 $ per unit |
+ 환율 USD→KRW |
+ 설명 | +
|---|---|---|---|---|---|---|
| + {info.label} + | ++ updateField(idx, 'model_name', e.target.value)} /> + | ++ updateField(idx, 'input_price_per_million', parseFloat(e.target.value) || 0)} /> + | ++ updateField(idx, 'output_price_per_million', parseFloat(e.target.value) || 0)} /> + | +
+ updateField(idx, 'unit_price', parseFloat(e.target.value) || 0)} />
+ {isUnit && c.unit_description && (
+ {c.unit_description}
+ )}
+ |
+ + updateField(idx, 'exchange_rate', parseFloat(e.target.value) || 0)} /> + | ++ updateField(idx, 'description', e.target.value)} /> + | +