Commit 053dec24 authored by platyhouse's avatar platyhouse

# AI CLI 도구 기능 개선

## 설정 파일 편집 기능 추가

- ptyAIGetMessage.php: `--edit` 옵션 추가 - vi 에디터로 ~/.ptyAIConfig.ini 설정 파일 직접 편집 가능

## AI API 통계 조회 도구 신규 추가

- ptyAIStatistics.php: AI API 섹션별 사용량 및 비용 통계 조회 CLI 도구 신규 작성
  - 지원 provider: anthropic, openai, google, ollama
  - Anthropic: Admin API를 통한 사용량/비용 통계 조회 (adminApiKey 필요)
  - OpenAI: Usage/Costs API를 통한 사용량/비용 조회 및 모델 목록
  - Google: Gemini 모델 목록 및 토큰 제한 정보 조회
  - Ollama: 로컬 모델 목록 및 실행 중인 모델 상태 조회
  - 옵션: `--ai`, `--days`, `--daily`, `--verbose`, `--json`, `--edit`, `--help`
parent f3fd6732
......@@ -30,6 +30,13 @@ $anthropicBetaOverride = $options['anthropic-beta'] ?? null;
$verbose = isset($options['verbose']);
$jsonOutput = isset($options['json']);
// --edit 옵션: vi로 설정 파일 열기
if (isset($options['edit'])) {
$configPath = ptyAIConfig::getConfigPath();
passthru("vi " . escapeshellarg($configPath));
exit(0);
}
// 도움말 또는 필수 인자 확인
if (empty($positionalArgs) || isset($options['help'])) {
fwrite(STDERR, "사용법: {$argv[0]} \"메시지\" [옵션]\n");
......@@ -40,6 +47,7 @@ if (empty($positionalArgs) || isset($options['help'])) {
fwrite(STDERR, " --anthropic-beta=기능명 Claude 베타 기능 오버라이드\n");
fwrite(STDERR, " --verbose 상세 정보 출력 (토큰 사용량, curl 등)\n");
fwrite(STDERR, " --json JSON 형식으로 출력\n");
fwrite(STDERR, " --edit 설정 파일을 vi로 열기\n");
fwrite(STDERR, " --help 도움말 출력\n");
fwrite(STDERR, "\n");
fwrite(STDERR, "모델 예시:\n");
......
#!/usr/bin/env php
<?php
/**
* ptyAIStatistics
*
* AI API 섹션별 사용량 및 통계 정보 조회 CLI 도구
*
* 설정 파일: ~/.ptyAIConfig.ini
*
* Usage: ./ptyAIStatistics [--ai=섹션명] [--verbose] [--json]
*
* 지원 provider:
* - anthropic: Claude API (Admin API 필요)
* - openai: ChatGPT API (Usage/Costs API)
* - google: Gemini API (모델 목록)
* - ollama: Ollama (로컬 모델 목록)
*/
namespace platyFramework;
require_once __DIR__ . '/ptyLibrary_PHP/cli/ptyCliOptionParser.php';
require_once __DIR__ . '/ptyLibrary_PHP/ai/ptyAIConfig.php';
// 인자 파싱
$parsed = ptyCliOptionParser::parse($argv);
$options = $parsed['options'];
$aiSection = $options['ai'] ?? null; // null이면 모든 섹션
$verbose = isset($options['verbose']);
$jsonOutput = isset($options['json']);
$days = isset($options['days']) ? (int)$options['days'] : 30;
$showDaily = isset($options['daily']);
// --edit 옵션: vi로 설정 파일 열기
if (isset($options['edit'])) {
$configPath = ptyAIConfig::getConfigPath();
passthru("vi " . escapeshellarg($configPath));
exit(0);
}
// 도움말
if (isset($options['help'])) {
fwrite(STDERR, "사용법: {$argv[0]} [옵션]\n");
fwrite(STDERR, "\n");
fwrite(STDERR, "옵션:\n");
fwrite(STDERR, " --ai=섹션명 특정 섹션만 조회 (미지정시 전체)\n");
fwrite(STDERR, " --days=일수 조회 기간 (기본값: 30일)\n");
fwrite(STDERR, " --daily 날짜별 상세 출력\n");
fwrite(STDERR, " --verbose 상세 정보 출력 (API 요청/응답)\n");
fwrite(STDERR, " --json JSON 형식으로 출력\n");
fwrite(STDERR, " --edit 설정 파일을 vi로 열기\n");
fwrite(STDERR, " --help 도움말 출력\n");
fwrite(STDERR, "\n");
fwrite(STDERR, "Provider별 조회 정보:\n");
fwrite(STDERR, " anthropic: 사용량 통계 (adminApiKey=sk-ant-admin... 필요)\n");
fwrite(STDERR, " openai: 사용량 및 비용 통계 (adminApiKey=sk-admin... 필요)\n");
fwrite(STDERR, " google: 모델 목록 및 Rate Limit 정보\n");
fwrite(STDERR, " ollama: 로컬 모델 목록 및 실행 중인 모델\n");
fwrite(STDERR, "\n");
fwrite(STDERR, "INI 설정 예시 (adminApiKey 사용):\n");
fwrite(STDERR, " [claude-admin]\n");
fwrite(STDERR, " provider=anthropic\n");
fwrite(STDERR, " apiKey=sk-ant-api03-xxx\n");
fwrite(STDERR, " adminApiKey=sk-ant-admin01-xxx\n");
fwrite(STDERR, "\n");
fwrite(STDERR, " [openai-admin]\n");
fwrite(STDERR, " provider=openai\n");
fwrite(STDERR, " apiKey=sk-proj-xxx\n");
fwrite(STDERR, " adminApiKey=sk-admin-xxx\n");
fwrite(STDERR, "\n");
// 현재 설정 파일 내용 표시
$configPath = ptyAIConfig::getConfigPath();
if (file_exists($configPath)) {
fwrite(STDERR, "설정 파일: {$configPath}\n");
fwrite(STDERR, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
$sections = parse_ini_file($configPath, true);
if ($sections) {
foreach ($sections as $name => $config) {
$provider = $config['provider'] ?? '(없음)';
$model = $config['model'] ?? ptyAIConfig::getDefaultModel($provider);
$apiKey = isset($config['apiKey']) ? substr($config['apiKey'], 0, 12) . '...' : '(없음)';
fwrite(STDERR, "[{$name}] provider={$provider}, model={$model}, apiKey={$apiKey}\n");
}
}
fwrite(STDERR, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
} else {
fwrite(STDERR, "설정 파일 없음: {$configPath}\n");
}
exit(0);
}
// 설정 파일 확인
$configPath = ptyAIConfig::getConfigPath();
if (!file_exists($configPath)) {
fwrite(STDERR, "Error: 설정 파일을 찾을 수 없습니다: {$configPath}\n");
fwrite(STDERR, ptyAIConfig::getConfigExample() . "\n");
exit(1);
}
$allSections = parse_ini_file($configPath, true);
if (!$allSections) {
fwrite(STDERR, "Error: 설정 파일을 파싱할 수 없습니다.\n");
exit(1);
}
// 조회할 섹션 결정
$sectionsToCheck = [];
if ($aiSection) {
if (!isset($allSections[$aiSection])) {
fwrite(STDERR, "Error: [{$aiSection}] 섹션을 찾을 수 없습니다.\n");
fwrite(STDERR, "사용 가능한 섹션: " . implode(', ', array_keys($allSections)) . "\n");
exit(1);
}
$sectionsToCheck[$aiSection] = $allSections[$aiSection];
} else {
$sectionsToCheck = $allSections;
}
// 결과 수집
$results = [];
$startTime = strtotime("-{$days} days");
$endTime = time();
foreach ($sectionsToCheck as $sectionName => $sectionConfig) {
$provider = strtolower($sectionConfig['provider'] ?? '');
if (!$verbose && !$jsonOutput) {
echo "\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
echo "섹션: [{$sectionName}]\n";
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
}
$result = [
'section' => $sectionName,
'provider' => $provider,
'model' => $sectionConfig['model'] ?? ptyAIConfig::getDefaultModel($provider),
'status' => 'unknown',
'data' => null,
'error' => null,
];
try {
switch ($provider) {
case 'anthropic':
$result = array_merge($result, getAnthropicStats($sectionConfig, $startTime, $endTime, $verbose, $showDaily));
break;
case 'openai':
$result = array_merge($result, getOpenAIStats($sectionConfig, $startTime, $endTime, $verbose, $showDaily, $days));
break;
case 'google':
$result = array_merge($result, getGoogleStats($sectionConfig, $verbose));
break;
case 'ollama':
$result = array_merge($result, getOllamaStats($sectionConfig, $verbose));
break;
default:
$result['status'] = 'unsupported';
$result['error'] = "지원되지 않는 provider: {$provider}";
}
} catch (\Exception $e) {
$result['status'] = 'error';
$result['error'] = $e->getMessage();
}
// 콘솔 출력 (JSON 모드가 아닐 때)
if (!$jsonOutput) {
printSectionResult($result, $verbose, $showDaily);
}
$results[] = $result;
}
// JSON 출력
if ($jsonOutput) {
echo json_encode([
'query' => [
'start_time' => date('Y-m-d H:i:s', $startTime),
'end_time' => date('Y-m-d H:i:s', $endTime),
'days' => $days,
],
'sections' => $results,
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
}
/**
* Anthropic (Claude) 통계 조회
* Admin API Key 필요 (sk-ant-admin...)
* INI에서 adminApiKey 필드 또는 apiKey가 admin key인 경우 사용
*/
function getAnthropicStats($config, $startTime, $endTime, $verbose, $showDaily = false)
{
$apiKey = trim($config['apiKey'] ?? '', '"\'');
$adminApiKey = trim($config['adminApiKey'] ?? '', '"\'');
$result = [
'status' => 'ok',
'data' => [],
];
// Admin API Key 확인 (adminApiKey 필드 우선, 없으면 apiKey 확인)
$effectiveAdminKey = null;
if ($adminApiKey && strpos($adminApiKey, 'sk-ant-admin') === 0) {
$effectiveAdminKey = $adminApiKey;
$result['data']['key_type'] = 'admin (adminApiKey)';
} elseif (strpos($apiKey, 'sk-ant-admin') === 0) {
$effectiveAdminKey = $apiKey;
$result['data']['key_type'] = 'admin (apiKey)';
}
if (!$effectiveAdminKey) {
// Admin Key 없음: 연결 테스트만 수행
$result['data']['key_type'] = 'standard';
$result['data']['note'] = 'Admin API Key (sk-ant-admin...) 필요. INI에 adminApiKey 추가 또는 콘솔에서 확인: https://console.anthropic.com';
$result['status'] = 'limited';
return $result;
}
// Admin API로 사용량 조회
// https://platform.claude.com/docs/en/build-with-claude/usage-cost-api
// Usage endpoint: /v1/organizations/usage_report/messages
$usageUrl = 'https://api.anthropic.com/v1/organizations/usage_report/messages';
$params = [
'starting_at' => date('Y-m-d\TH:i:s\Z', $startTime),
'ending_at' => date('Y-m-d\TH:i:s\Z', $endTime),
'group_by[]' => 'model',
];
$response = curlRequest($usageUrl . '?' . http_build_query($params), [
'x-api-key: ' . $effectiveAdminKey,
'anthropic-version: 2023-06-01',
], $verbose);
if ($response['http_code'] === 200 && $response['body']) {
$data = json_decode($response['body'], true);
$result['data']['usage'] = $data;
// 요약 계산
$summary = calculateAnthropicUsageSummary($data);
if ($summary) {
$result['data']['summary'] = $summary;
}
} else {
$errorBody = json_decode($response['body'], true);
$result['data']['usage_error'] = $errorBody['error']['message'] ?? "HTTP {$response['http_code']}";
}
// Cost API 호출
// Cost endpoint: /v1/organizations/cost_report
$costUrl = 'https://api.anthropic.com/v1/organizations/cost_report';
$costParams = [
'starting_at' => date('Y-m-d\TH:i:s\Z', $startTime),
'ending_at' => date('Y-m-d\TH:i:s\Z', $endTime),
];
$costResponse = curlRequest($costUrl . '?' . http_build_query($costParams), [
'x-api-key: ' . $effectiveAdminKey,
'anthropic-version: 2023-06-01',
], $verbose);
if ($costResponse['http_code'] === 200 && $costResponse['body']) {
$costData = json_decode($costResponse['body'], true);
$result['data']['costs'] = $costData;
// 비용 요약 계산
$costSummary = calculateAnthropicCostSummary($costData);
if ($costSummary) {
$result['data']['cost_summary'] = $costSummary;
}
} else {
$errorBody = json_decode($costResponse['body'], true);
$result['data']['costs_error'] = $errorBody['error']['message'] ?? "HTTP {$costResponse['http_code']}";
}
return $result;
}
/**
* Anthropic 사용량 요약 계산
* 응답 구조: data[].results[].{model, input_tokens, output_tokens, ...}
*/
function calculateAnthropicUsageSummary($data)
{
if (!isset($data['data']) || empty($data['data'])) {
return null;
}
$totalInputTokens = 0;
$totalOutputTokens = 0;
$totalCacheCreationTokens = 0;
$totalCacheReadTokens = 0;
$modelUsage = [];
// 중첩된 data[].results[] 구조 처리
foreach ($data['data'] as $bucket) {
if (!isset($bucket['results']) || empty($bucket['results'])) {
continue;
}
foreach ($bucket['results'] as $item) {
$model = $item['model'] ?? 'unknown';
$input = $item['input_tokens'] ?? 0;
$output = $item['output_tokens'] ?? 0;
$cacheCreation = $item['cache_creation_input_tokens'] ?? 0;
$cacheRead = $item['cache_read_input_tokens'] ?? 0;
$totalInputTokens += $input;
$totalOutputTokens += $output;
$totalCacheCreationTokens += $cacheCreation;
$totalCacheReadTokens += $cacheRead;
if (!isset($modelUsage[$model])) {
$modelUsage[$model] = ['input' => 0, 'output' => 0];
}
$modelUsage[$model]['input'] += $input;
$modelUsage[$model]['output'] += $output;
}
}
// 데이터가 없으면 null 반환
if ($totalInputTokens === 0 && $totalOutputTokens === 0 && empty($modelUsage)) {
return null;
}
return [
'total_input_tokens' => $totalInputTokens,
'total_output_tokens' => $totalOutputTokens,
'total_tokens' => $totalInputTokens + $totalOutputTokens,
'cache_creation_tokens' => $totalCacheCreationTokens,
'cache_read_tokens' => $totalCacheReadTokens,
'by_model' => $modelUsage,
];
}
/**
* Anthropic 비용 요약 계산
* 응답 구조: data[].results[].{amount_cents, ...}
*/
function calculateAnthropicCostSummary($data)
{
if (!isset($data['data']) || empty($data['data'])) {
return null;
}
$totalCost = 0.0;
$hasData = false;
// 중첩된 data[].results[] 구조 처리
foreach ($data['data'] as $bucket) {
if (!isset($bucket['results']) || empty($bucket['results'])) {
continue;
}
$hasData = true;
foreach ($bucket['results'] as $item) {
// amount_cents 또는 amount 필드 확인 (cents 단위)
$amountCents = $item['amount_cents'] ?? $item['amount'] ?? 0;
$totalCost += floatval($amountCents) / 100;
}
}
// 데이터가 없으면 null 반환
if (!$hasData) {
return null;
}
return [
'total_usd' => round($totalCost, 4),
'currency' => 'USD',
];
}
/**
* OpenAI 통계 조회
* adminApiKey가 있으면 사용, 없으면 apiKey 사용
*/
function getOpenAIStats($config, $startTime, $endTime, $verbose, $showDaily = false, $days = 30)
{
$apiKey = trim($config['apiKey'] ?? '', '"\'');
$adminApiKey = trim($config['adminApiKey'] ?? '', '"\'');
$result = [
'status' => 'ok',
'data' => [],
];
// Admin API Key 확인 (adminApiKey 우선)
$effectiveKey = null;
if ($adminApiKey && strpos($adminApiKey, 'sk-admin') === 0) {
$effectiveKey = $adminApiKey;
$result['data']['key_type'] = 'admin (adminApiKey)';
} elseif ($apiKey) {
$effectiveKey = $apiKey;
$result['data']['key_type'] = 'standard (apiKey)';
}
if (!$effectiveKey) {
$result['status'] = 'error';
$result['error'] = 'API Key가 없습니다.';
return $result;
}
// 모델 목록 조회
$modelsUrl = 'https://api.openai.com/v1/models';
$modelsResponse = curlRequest($modelsUrl, [
'Authorization: Bearer ' . $effectiveKey,
], $verbose);
if ($modelsResponse['http_code'] === 200 && $modelsResponse['body']) {
$modelsData = json_decode($modelsResponse['body'], true);
$modelNames = array_column($modelsData['data'] ?? [], 'id');
sort($modelNames);
$result['data']['available_models_count'] = count($modelNames);
// GPT 관련 모델만 필터링
$gptModels = array_filter($modelNames, function($m) {
return preg_match('/^(gpt-|o1|o3|dall-e|whisper|tts|text-embedding)/i', $m);
});
$result['data']['main_models'] = array_values($gptModels);
}
// Usage API 호출 (completions)
$usageUrl = 'https://api.openai.com/v1/organization/usage/completions';
$params = [
'start_time' => $startTime,
'bucket_width' => '1d',
'group_by' => ['model'],
'limit' => $days + 1, // 조회 기간만큼 버킷 요청
];
$usageResponse = curlRequest($usageUrl . '?' . http_build_query($params), [
'Authorization: Bearer ' . $effectiveKey,
'Content-Type: application/json',
], $verbose);
if ($usageResponse['http_code'] === 200 && $usageResponse['body']) {
$usageData = json_decode($usageResponse['body'], true);
$result['data']['usage'] = $usageData;
// 요약 및 날짜별 계산
$totalInputTokens = 0;
$totalOutputTokens = 0;
$totalRequests = 0;
$dailyUsage = [];
foreach ($usageData['data'] ?? [] as $bucket) {
$date = isset($bucket['start_time']) ? date('Y-m-d', $bucket['start_time']) : null;
$dayInput = 0;
$dayOutput = 0;
$dayRequests = 0;
foreach ($bucket['results'] ?? [] as $r) {
$input = $r['input_tokens'] ?? 0;
$output = $r['output_tokens'] ?? 0;
$requests = $r['num_model_requests'] ?? 0;
$totalInputTokens += $input;
$totalOutputTokens += $output;
$totalRequests += $requests;
$dayInput += $input;
$dayOutput += $output;
$dayRequests += $requests;
}
if ($date && ($dayInput > 0 || $dayOutput > 0 || $dayRequests > 0)) {
$dailyUsage[$date] = [
'input_tokens' => $dayInput,
'output_tokens' => $dayOutput,
'requests' => $dayRequests,
];
}
}
$result['data']['summary'] = [
'total_input_tokens' => $totalInputTokens,
'total_output_tokens' => $totalOutputTokens,
'total_tokens' => $totalInputTokens + $totalOutputTokens,
'total_requests' => $totalRequests,
];
if ($showDaily && !empty($dailyUsage)) {
krsort($dailyUsage); // 최신 날짜 먼저
$result['data']['daily_usage'] = $dailyUsage;
}
} else {
$errorBody = json_decode($usageResponse['body'], true);
$result['data']['usage_error'] = $errorBody['error']['message'] ?? "HTTP {$usageResponse['http_code']}";
}
// Costs API 호출
$costsUrl = 'https://api.openai.com/v1/organization/costs';
$costsParams = [
'start_time' => $startTime,
'bucket_width' => '1d',
'limit' => $days + 1, // 조회 기간만큼 버킷 요청
];
$costsResponse = curlRequest($costsUrl . '?' . http_build_query($costsParams), [
'Authorization: Bearer ' . $effectiveKey,
'Content-Type: application/json',
], $verbose);
if ($costsResponse['http_code'] === 200 && $costsResponse['body']) {
$costsData = json_decode($costsResponse['body'], true);
// 총 비용 및 날짜별 계산
$totalCost = 0;
$dailyCosts = [];
foreach ($costsData['data'] ?? [] as $bucket) {
$date = isset($bucket['start_time']) ? date('Y-m-d', $bucket['start_time']) : null;
$dayCost = 0;
foreach ($bucket['results'] ?? [] as $r) {
$cost = floatval($r['amount']['value'] ?? 0);
$totalCost += $cost;
$dayCost += $cost;
}
if ($date && $dayCost > 0) {
$dailyCosts[$date] = round($dayCost, 4);
}
}
$result['data']['costs'] = [
'total_usd' => round($totalCost, 4),
'currency' => 'USD',
'period_days' => round(($endTime - $startTime) / 86400),
];
if ($showDaily && !empty($dailyCosts)) {
krsort($dailyCosts); // 최신 날짜 먼저
$result['data']['daily_costs'] = $dailyCosts;
}
} else {
$errorBody = json_decode($costsResponse['body'], true);
$result['data']['costs_error'] = $errorBody['error']['message'] ?? "HTTP {$costsResponse['http_code']}";
}
// 잔액 조회는 API로 불가능 (브라우저 전용)
// https://community.openai.com/t/openai-credit-balance-api/1280067
$result['data']['balance_note'] = '잔액 확인: https://platform.openai.com/settings/organization/billing/overview';
return $result;
}
/**
* Google (Gemini) 통계 조회
*/
function getGoogleStats($config, $verbose)
{
$apiKey = trim($config['apiKey'] ?? '', '"\'');
$result = [
'status' => 'ok',
'data' => [],
];
// 모델 목록 조회
$modelsUrl = "https://generativelanguage.googleapis.com/v1beta/models?key={$apiKey}";
$response = curlRequest($modelsUrl, [], $verbose);
if ($response['http_code'] === 200 && $response['body']) {
$data = json_decode($response['body'], true);
$models = [];
foreach ($data['models'] ?? [] as $model) {
$models[] = [
'name' => str_replace('models/', '', $model['name'] ?? ''),
'displayName' => $model['displayName'] ?? '',
'inputTokenLimit' => $model['inputTokenLimit'] ?? 0,
'outputTokenLimit' => $model['outputTokenLimit'] ?? 0,
];
}
$result['data']['models'] = $models;
$result['data']['models_count'] = count($models);
$result['data']['note'] = '사용량 통계는 Google AI Studio에서 확인: https://aistudio.google.com';
} else {
$result['status'] = 'error';
$errorBody = json_decode($response['body'], true);
$result['error'] = $errorBody['error']['message'] ?? "HTTP {$response['http_code']}";
}
return $result;
}
/**
* Ollama 통계 조회
*/
function getOllamaStats($config, $verbose)
{
$apiUrl = rtrim(trim($config['apiUrl'] ?? 'http://localhost:11434', '"\''), '/');
$result = [
'status' => 'ok',
'data' => [
'server_url' => $apiUrl,
],
];
// 모델 목록 조회
$tagsUrl = "{$apiUrl}/api/tags";
$tagsResponse = curlRequest($tagsUrl, [], $verbose);
if ($tagsResponse['http_code'] === 200 && $tagsResponse['body']) {
$tagsData = json_decode($tagsResponse['body'], true);
$models = [];
foreach ($tagsData['models'] ?? [] as $model) {
$models[] = [
'name' => $model['name'] ?? '',
'size' => formatBytes($model['size'] ?? 0),
'modified' => $model['modified_at'] ?? '',
'family' => $model['details']['family'] ?? '',
'parameter_size' => $model['details']['parameter_size'] ?? '',
'quantization' => $model['details']['quantization_level'] ?? '',
];
}
$result['data']['models'] = $models;
$result['data']['models_count'] = count($models);
} else {
$result['status'] = 'error';
$result['error'] = $tagsResponse['error'] ?? "연결 실패 (HTTP {$tagsResponse['http_code']})";
return $result;
}
// 실행 중인 모델 조회
$psUrl = "{$apiUrl}/api/ps";
$psResponse = curlRequest($psUrl, [], $verbose);
if ($psResponse['http_code'] === 200 && $psResponse['body']) {
$psData = json_decode($psResponse['body'], true);
$running = [];
foreach ($psData['models'] ?? [] as $model) {
$running[] = [
'name' => $model['name'] ?? '',
'size' => formatBytes($model['size'] ?? 0),
'vram' => formatBytes($model['size_vram'] ?? 0),
'expires' => $model['expires_at'] ?? '',
];
}
$result['data']['running_models'] = $running;
$result['data']['running_count'] = count($running);
}
return $result;
}
/**
* cURL 요청 헬퍼
*/
function curlRequest($url, $headers = [], $verbose = false)
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
if ($verbose) {
fwrite(STDERR, "━━━━━━━━━━━━ CURL REQUEST ━━━━━━━━━━━━\n");
fwrite(STDERR, "URL: {$url}\n");
if (!empty($headers)) {
fwrite(STDERR, "Headers:\n");
foreach ($headers as $h) {
// API Key 마스킹
if (preg_match('/^(Authorization|x-api-key):/i', $h)) {
$h = preg_replace('/([a-zA-Z0-9_-]{12})[a-zA-Z0-9_-]+/', '$1...', $h);
}
fwrite(STDERR, " {$h}\n");
}
}
fwrite(STDERR, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
}
$body = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($verbose) {
fwrite(STDERR, "━━━━━━━━━━━━ CURL RESPONSE ━━━━━━━━━━━━\n");
fwrite(STDERR, "HTTP Code: {$httpCode}\n");
if ($error) {
fwrite(STDERR, "Error: {$error}\n");
}
if ($body) {
$prettyBody = json_decode($body, true);
if ($prettyBody) {
fwrite(STDERR, "Body:\n" . json_encode($prettyBody, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n");
} else {
fwrite(STDERR, "Body: {$body}\n");
}
}
fwrite(STDERR, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
}
return [
'http_code' => $httpCode,
'body' => $body,
'error' => $error ?: null,
];
}
/**
* 바이트 포맷팅
*/
function formatBytes($bytes)
{
if ($bytes >= 1073741824) {
return round($bytes / 1073741824, 2) . ' GB';
} elseif ($bytes >= 1048576) {
return round($bytes / 1048576, 2) . ' MB';
} elseif ($bytes >= 1024) {
return round($bytes / 1024, 2) . ' KB';
}
return $bytes . ' B';
}
/**
* 섹션 결과 출력
*/
function printSectionResult($result, $verbose, $showDaily = false)
{
$provider = $result['provider'];
$model = $result['model'];
$status = $result['status'];
echo "Provider: {$provider}\n";
echo "Model: {$model}\n";
echo "Status: {$status}\n";
if ($result['error']) {
echo "Error: {$result['error']}\n";
}
if (!$result['data']) {
return;
}
$data = $result['data'];
switch ($provider) {
case 'anthropic':
if (isset($data['key_type'])) {
echo "Key Type: {$data['key_type']}\n";
}
if (isset($data['note'])) {
echo "Note: {$data['note']}\n";
}
// 요약 정보 출력
if (isset($data['summary'])) {
$s = $data['summary'];
echo "\n[Usage Summary]\n";
echo " Input Tokens: " . number_format($s['total_input_tokens']) . "\n";
echo " Output Tokens: " . number_format($s['total_output_tokens']) . "\n";
echo " Total Tokens: " . number_format($s['total_tokens']) . "\n";
if ($s['cache_creation_tokens'] > 0 || $s['cache_read_tokens'] > 0) {
echo " Cache Creation: " . number_format($s['cache_creation_tokens']) . "\n";
echo " Cache Read: " . number_format($s['cache_read_tokens']) . "\n";
}
if (!empty($s['by_model'])) {
echo "\n[By Model]\n";
foreach ($s['by_model'] as $model => $usage) {
$in = number_format($usage['input']);
$out = number_format($usage['output']);
echo " {$model}: in={$in}, out={$out}\n";
}
}
} elseif (!isset($data['usage_error'])) {
echo "\n[Usage Summary]\n";
echo " (조회 기간 내 사용 데이터 없음)\n";
}
// 비용 요약
if (isset($data['cost_summary'])) {
$c = $data['cost_summary'];
echo "\n[Costs]\n";
echo " Total: \${$c['total_usd']} {$c['currency']}\n";
} elseif (!isset($data['costs_error'])) {
echo "\n[Costs]\n";
echo " (조회 기간 내 비용 데이터 없음)\n";
}
if (isset($data['usage_error'])) {
echo "Usage Error: {$data['usage_error']}\n";
}
if (isset($data['costs_error'])) {
echo "Costs Error: {$data['costs_error']}\n";
}
break;
case 'openai':
if (isset($data['key_type'])) {
echo "Key Type: {$data['key_type']}\n";
}
if (isset($data['available_models_count'])) {
echo "Available Models: {$data['available_models_count']}\n";
}
if (isset($data['summary'])) {
$s = $data['summary'];
echo "\n[Usage Summary]\n";
echo " Total Requests: " . number_format($s['total_requests']) . "\n";
echo " Input Tokens: " . number_format($s['total_input_tokens']) . "\n";
echo " Output Tokens: " . number_format($s['total_output_tokens']) . "\n";
echo " Total Tokens: " . number_format($s['total_tokens']) . "\n";
}
// 날짜별 사용량
if ($showDaily && isset($data['daily_usage']) && !empty($data['daily_usage'])) {
echo "\n[Daily Usage]\n";
echo " Date Requests Input Tokens Output Tokens\n";
echo " ──────────────────────────────────────────────────────────\n";
foreach ($data['daily_usage'] as $date => $usage) {
$req = str_pad(number_format($usage['requests']), 10, ' ', STR_PAD_LEFT);
$in = str_pad(number_format($usage['input_tokens']), 15, ' ', STR_PAD_LEFT);
$out = str_pad(number_format($usage['output_tokens']), 15, ' ', STR_PAD_LEFT);
echo " {$date} {$req} {$in} {$out}\n";
}
}
if (isset($data['costs'])) {
$c = $data['costs'];
echo "\n[Costs]\n";
echo " Total: \${$c['total_usd']} USD ({$c['period_days']} days)\n";
}
// 날짜별 비용
if ($showDaily && isset($data['daily_costs']) && !empty($data['daily_costs'])) {
echo "\n[Daily Costs]\n";
foreach ($data['daily_costs'] as $date => $cost) {
echo " {$date}: \${$cost} USD\n";
}
}
if (isset($data['usage_error'])) {
echo "Usage Error: {$data['usage_error']}\n";
}
if (isset($data['costs_error'])) {
echo "Costs Error: {$data['costs_error']}\n";
}
if (isset($data['balance_note'])) {
echo "\n[Balance]\n";
echo " {$data['balance_note']}\n";
}
break;
case 'google':
if (isset($data['models_count'])) {
echo "Available Models: {$data['models_count']}\n";
}
if (isset($data['models']) && !empty($data['models'])) {
echo "\n[Models]\n";
foreach ($data['models'] as $m) {
$inputLimit = number_format($m['inputTokenLimit']);
$outputLimit = number_format($m['outputTokenLimit']);
echo " - {$m['name']} (in:{$inputLimit}, out:{$outputLimit})\n";
}
}
if (isset($data['note'])) {
echo "\nNote: {$data['note']}\n";
}
break;
case 'ollama':
if (isset($data['server_url'])) {
echo "Server: {$data['server_url']}\n";
}
if (isset($data['models_count'])) {
echo "Installed Models: {$data['models_count']}\n";
}
if (isset($data['models']) && !empty($data['models'])) {
echo "\n[Installed Models]\n";
foreach ($data['models'] as $m) {
$info = "{$m['name']} ({$m['size']}";
if ($m['parameter_size']) {
$info .= ", {$m['parameter_size']}";
}
if ($m['quantization']) {
$info .= ", {$m['quantization']}";
}
$info .= ")";
echo " - {$info}\n";
}
}
if (isset($data['running_count'])) {
echo "\nRunning Models: {$data['running_count']}\n";
if (!empty($data['running_models'])) {
foreach ($data['running_models'] as $m) {
echo " - {$m['name']} (VRAM: {$m['vram']})\n";
}
}
}
break;
}
}
/**
* 사용량 테이블 출력 (Anthropic)
*/
function printUsageTable($usage)
{
if (!isset($usage['data']) || empty($usage['data'])) {
echo " (데이터 없음)\n";
return;
}
foreach ($usage['data'] as $item) {
$model = $item['model'] ?? 'unknown';
$inputTokens = number_format($item['input_tokens'] ?? 0);
$outputTokens = number_format($item['output_tokens'] ?? 0);
echo " {$model}: in={$inputTokens}, out={$outputTokens}\n";
}
}
/**
* 비용 테이블 출력 (Anthropic)
*/
function printCostTable($costs)
{
if (!isset($costs['data']) || empty($costs['data'])) {
echo " (데이터 없음)\n";
return;
}
$total = 0;
foreach ($costs['data'] as $item) {
$amount = floatval($item['amount'] ?? 0) / 100; // cents to USD
$total += $amount;
}
echo " Total: \$" . number_format($total, 4) . " USD\n";
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment