Files
laravel-llm-gateway/laravel-app/app/Http/Controllers/Api/UsageController.php
wtrinkl b6d75d51e3 feat: Implementiere umfassende RESTful API für LLM Gateway
Fügt 21 neue API-Endpoints in 4 Phasen hinzu:

Phase 1 - Foundation (Provider & Models):
- GET /api/providers - Liste aller Provider
- GET /api/providers/{provider} - Provider-Details
- GET /api/models - Liste aller Models mit Filtering/Sorting
- GET /api/models/{provider}/{model} - Model-Details

Phase 2 - Core Features (Credentials, Budget, Pricing):
- GET/POST/PUT/DELETE /api/credentials - Credential-Management
- POST /api/credentials/{id}/test - Connection Testing
- GET /api/budget - Budget-Status mit Projektionen
- GET /api/budget/history - Budget-Historie
- GET /api/pricing - Model-Pricing-Listen
- GET /api/pricing/calculator - Kosten-Kalkulator
- GET /api/pricing/compare - Preis-Vergleich

Phase 3 - Analytics (Usage Statistics):
- GET /api/usage/summary - Umfassende Statistiken
- GET /api/usage/requests - Request-History mit Pagination
- GET /api/usage/requests/{id} - Request-Details
- GET /api/usage/charts - Chart-Daten (4 Typen)

Phase 4 - Account (Account Info & Activity):
- GET /api/account - User-Informationen
- GET /api/account/activity - Activity-Log

Features:
- Vollständige Scramble/Swagger-Dokumentation
- Consistent Error-Handling
- API-Key Authentication
- Filtering, Sorting, Pagination
- Budget-Tracking mit Alerts
- Provider-Breakdown
- Performance-Metriken
- Chart-Ready-Data

Controller erstellt:
- ProviderController
- ModelController
- CredentialController
- BudgetController
- PricingController
- UsageController
- AccountController

Dokumentation:
- API_KONZEPT.md - Vollständiges API-Konzept
- API_IMPLEMENTATION_STATUS.txt - Implementation-Tracking
- API_IMPLEMENTATION_SUMMARY.md - Zusammenfassung und Workflows
2025-11-19 12:33:11 +01:00

637 lines
23 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LlmRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
class UsageController extends Controller
{
/**
* Get usage summary statistics
*
* Returns comprehensive usage statistics for the authenticated user,
* including requests, tokens, costs, and breakdowns by provider and model.
*
* ## Query Parameters
*
* - `period` (optional) - Time period: today, week, month, all (default: month)
* - `provider` (optional) - Filter by provider
*
* ## Example Response
*
* ```json
* {
* "data": {
* "period": "month",
* "period_start": "2025-11-01T00:00:00Z",
* "period_end": "2025-11-30T23:59:59Z",
* "summary": {
* "total_requests": 1250,
* "successful_requests": 1235,
* "failed_requests": 15,
* "success_rate": 98.8,
* "total_tokens": 2500000,
* "prompt_tokens": 1800000,
* "completion_tokens": 700000,
* "total_cost": 45.67,
* "avg_cost_per_request": 0.0365,
* "avg_tokens_per_request": 2000,
* "avg_response_time_ms": 1450
* },
* "by_provider": [...],
* "by_model": [...],
* "top_hours": [...]
* }
* }
* ```
*
* @tags Usage
*
* @param Request $request
* @return JsonResponse
*/
public function summary(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'period' => 'sometimes|string|in:today,week,month,all',
'provider' => 'sometimes|string|in:openai,anthropic,gemini,deepseek,mistral',
]);
if ($validator->fails()) {
return response()->json([
'error' => [
'code' => 'validation_error',
'message' => 'Invalid query parameters',
'status' => 422,
'details' => $validator->errors(),
],
], 422);
}
$user = $request->user();
$period = $request->input('period', 'month');
// Calculate date range
$dateRange = $this->getDateRange($period);
// Base query
$query = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $dateRange['start']);
if ($dateRange['end']) {
$query->where('created_at', '<=', $dateRange['end']);
}
// Apply provider filter
if ($request->has('provider')) {
$query->where('provider', $request->input('provider'));
}
// Get summary statistics
$summary = $query->selectRaw('
COUNT(*) as total_requests,
SUM(CASE WHEN status = "success" THEN 1 ELSE 0 END) as successful_requests,
SUM(CASE WHEN status != "success" THEN 1 ELSE 0 END) as failed_requests,
SUM(prompt_tokens) as prompt_tokens,
SUM(completion_tokens) as completion_tokens,
SUM(total_tokens) as total_tokens,
SUM(total_cost) as total_cost,
AVG(total_tokens) as avg_tokens_per_request,
AVG(total_cost) as avg_cost_per_request,
AVG(response_time_ms) as avg_response_time_ms
')->first();
$successRate = $summary->total_requests > 0
? ($summary->successful_requests / $summary->total_requests) * 100
: 0;
// Get breakdown by provider
$byProvider = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $dateRange['start'])
->where('status', 'success')
->select(
'provider',
DB::raw('COUNT(*) as requests'),
DB::raw('SUM(total_tokens) as tokens'),
DB::raw('SUM(total_cost) as cost'),
DB::raw('AVG(response_time_ms) as avg_response_time_ms')
)
->groupBy('provider')
->orderByDesc('requests')
->get()
->map(function ($item) use ($summary) {
$successRate = LlmRequest::where('gateway_user_id', request()->user()->user_id)
->where('provider', $item->provider)
->where('created_at', '>=', $this->getDateRange(request()->input('period', 'month'))['start'])
->selectRaw('
COUNT(*) as total,
SUM(CASE WHEN status = "success" THEN 1 ELSE 0 END) as successful
')
->first();
$rate = $successRate->total > 0 ? ($successRate->successful / $successRate->total) * 100 : 0;
return [
'provider' => $item->provider,
'provider_name' => $this->getProviderName($item->provider),
'requests' => $item->requests,
'tokens' => $item->tokens,
'cost' => round($item->cost, 4),
'avg_response_time_ms' => round($item->avg_response_time_ms),
'success_rate' => round($rate, 1),
];
});
// Get breakdown by model (top 10)
$byModel = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $dateRange['start'])
->where('status', 'success')
->select(
'model',
'provider',
DB::raw('COUNT(*) as requests'),
DB::raw('SUM(total_tokens) as tokens'),
DB::raw('SUM(total_cost) as cost'),
DB::raw('AVG(total_tokens) as avg_tokens_per_request')
)
->groupBy('model', 'provider')
->orderByDesc('requests')
->limit(10)
->get()
->map(function ($item) {
return [
'model' => $item->model,
'provider' => $item->provider,
'requests' => $item->requests,
'tokens' => $item->tokens,
'cost' => round($item->cost, 4),
'avg_tokens_per_request' => round($item->avg_tokens_per_request),
];
});
// Get top hours
$topHours = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $dateRange['start'])
->where('status', 'success')
->select(
DB::raw('HOUR(created_at) as hour'),
DB::raw('COUNT(*) as requests'),
DB::raw('SUM(total_cost) as cost')
)
->groupBy('hour')
->orderByDesc('requests')
->limit(5)
->get()
->map(function ($item) {
return [
'hour' => $item->hour,
'requests' => $item->requests,
'cost' => round($item->cost, 4),
];
});
return response()->json([
'data' => [
'period' => $period,
'period_start' => $dateRange['start']->toIso8601String(),
'period_end' => $dateRange['end']?->toIso8601String() ?? now()->toIso8601String(),
'summary' => [
'total_requests' => $summary->total_requests ?? 0,
'successful_requests' => $summary->successful_requests ?? 0,
'failed_requests' => $summary->failed_requests ?? 0,
'success_rate' => round($successRate, 1),
'total_tokens' => $summary->total_tokens ?? 0,
'prompt_tokens' => $summary->prompt_tokens ?? 0,
'completion_tokens' => $summary->completion_tokens ?? 0,
'total_cost' => round($summary->total_cost ?? 0, 4),
'avg_cost_per_request' => round($summary->avg_cost_per_request ?? 0, 6),
'avg_tokens_per_request' => round($summary->avg_tokens_per_request ?? 0),
'avg_response_time_ms' => round($summary->avg_response_time_ms ?? 0),
],
'by_provider' => $byProvider,
'by_model' => $byModel,
'top_hours' => $topHours,
],
]);
}
/**
* Get list of individual requests
*
* Returns paginated list of requests with filtering and sorting options.
*
* ## Query Parameters
*
* - `page` (optional) - Page number (default: 1)
* - `per_page` (optional) - Items per page (default: 20, max: 100)
* - `provider` (optional) - Filter by provider
* - `model` (optional) - Filter by model
* - `status` (optional) - Filter by status: success, failed, all (default: all)
* - `date_from` (optional) - From date (ISO 8601)
* - `date_to` (optional) - To date (ISO 8601)
* - `sort` (optional) - Sort field: created_at, cost, tokens, response_time (default: -created_at)
*
* @tags Usage
*
* @param Request $request
* @return JsonResponse
*/
public function requests(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'page' => 'sometimes|integer|min:1',
'per_page' => 'sometimes|integer|min:1|max:100',
'provider' => 'sometimes|string|in:openai,anthropic,gemini,deepseek,mistral',
'model' => 'sometimes|string',
'status' => 'sometimes|string|in:success,failed,all',
'date_from' => 'sometimes|date',
'date_to' => 'sometimes|date',
'sort' => 'sometimes|string|in:created_at,-created_at,cost,-cost,tokens,-tokens,response_time,-response_time',
]);
if ($validator->fails()) {
return response()->json([
'error' => [
'code' => 'validation_error',
'message' => 'Invalid query parameters',
'status' => 422,
'details' => $validator->errors(),
],
], 422);
}
$user = $request->user();
$perPage = $request->input('per_page', 20);
// Build query
$query = LlmRequest::where('gateway_user_id', $user->user_id);
// Apply filters
if ($request->has('provider')) {
$query->where('provider', $request->input('provider'));
}
if ($request->has('model')) {
$query->where('model', $request->input('model'));
}
$status = $request->input('status', 'all');
if ($status === 'success') {
$query->where('status', 'success');
} elseif ($status === 'failed') {
$query->where('status', '!=', 'success');
}
if ($request->has('date_from')) {
$query->where('created_at', '>=', $request->input('date_from'));
}
if ($request->has('date_to')) {
$query->where('created_at', '<=', $request->input('date_to'));
}
// Apply sorting
$sort = $request->input('sort', '-created_at');
$sortField = ltrim($sort, '-');
$sortDirection = str_starts_with($sort, '-') ? 'desc' : 'asc';
$query->orderBy($sortField, $sortDirection);
// Get summary for filtered results
$summary = $query->clone()->selectRaw('
SUM(total_cost) as total_cost,
SUM(total_tokens) as total_tokens,
AVG(response_time_ms) as avg_response_time_ms
')->first();
// Paginate
$paginated = $query->paginate($perPage);
$data = $paginated->map(function ($request) {
return [
'id' => $request->request_id,
'provider' => $request->provider,
'model' => $request->model,
'status' => $request->status,
'prompt_tokens' => $request->prompt_tokens,
'completion_tokens' => $request->completion_tokens,
'total_tokens' => $request->total_tokens,
'input_cost' => round($request->prompt_tokens * ($request->input_price_per_token ?? 0), 6),
'output_cost' => round($request->completion_tokens * ($request->output_price_per_token ?? 0), 6),
'total_cost' => round($request->total_cost, 6),
'response_time_ms' => $request->response_time_ms,
'created_at' => $request->created_at->toIso8601String(),
];
});
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $paginated->currentPage(),
'per_page' => $paginated->perPage(),
'total' => $paginated->total(),
'total_pages' => $paginated->lastPage(),
'has_more' => $paginated->hasMorePages(),
],
'links' => [
'first' => $paginated->url(1),
'last' => $paginated->url($paginated->lastPage()),
'prev' => $paginated->previousPageUrl(),
'next' => $paginated->nextPageUrl(),
],
'summary' => [
'total_cost' => round($summary->total_cost ?? 0, 4),
'total_tokens' => $summary->total_tokens ?? 0,
'avg_response_time_ms' => round($summary->avg_response_time_ms ?? 0),
],
]);
}
/**
* Get details of a specific request
*
* Returns complete information about a single request including
* full request and response data.
*
* @tags Usage
*
* @param Request $request
* @param string $id
* @return JsonResponse
*/
public function show(Request $request, string $id): JsonResponse
{
$user = $request->user();
$llmRequest = LlmRequest::where('gateway_user_id', $user->user_id)
->where('request_id', $id)
->first();
if (!$llmRequest) {
return response()->json([
'error' => [
'code' => 'not_found',
'message' => 'Request not found',
'status' => 404,
],
], 404);
}
return response()->json([
'data' => [
'id' => $llmRequest->request_id,
'gateway_user_id' => $llmRequest->gateway_user_id,
'provider' => $llmRequest->provider,
'provider_name' => $this->getProviderName($llmRequest->provider),
'model' => $llmRequest->model,
'status' => $llmRequest->status,
'request' => $llmRequest->request_data,
'response' => $llmRequest->response_data,
'usage' => [
'prompt_tokens' => $llmRequest->prompt_tokens,
'completion_tokens' => $llmRequest->completion_tokens,
'total_tokens' => $llmRequest->total_tokens,
],
'cost' => [
'input_cost' => round($llmRequest->prompt_tokens * ($llmRequest->input_price_per_token ?? 0), 6),
'output_cost' => round($llmRequest->completion_tokens * ($llmRequest->output_price_per_token ?? 0), 6),
'total_cost' => round($llmRequest->total_cost, 6),
'currency' => 'USD',
],
'performance' => [
'response_time_ms' => $llmRequest->response_time_ms,
],
'metadata' => [
'ip_address' => $llmRequest->ip_address,
'user_agent' => $llmRequest->user_agent,
],
'created_at' => $llmRequest->created_at->toIso8601String(),
'completed_at' => $llmRequest->created_at->addMilliseconds($llmRequest->response_time_ms)->toIso8601String(),
],
]);
}
/**
* Get chart data for visualizations
*
* Returns data formatted for chart visualizations.
*
* ## Query Parameters
*
* - `type` (required) - Chart type: daily_cost, provider_distribution, model_usage, hourly_pattern
* - `days` (optional) - Number of days to look back (default: 30)
*
* @tags Usage
*
* @param Request $request
* @return JsonResponse
*/
public function charts(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'type' => 'required|string|in:daily_cost,provider_distribution,model_usage,hourly_pattern',
'days' => 'sometimes|integer|min:1|max:365',
]);
if ($validator->fails()) {
return response()->json([
'error' => [
'code' => 'validation_error',
'message' => 'Invalid query parameters',
'status' => 422,
'details' => $validator->errors(),
],
], 422);
}
$user = $request->user();
$type = $request->input('type');
$days = $request->input('days', 30);
$startDate = now()->subDays($days);
$chartData = match ($type) {
'daily_cost' => $this->getDailyCostChart($user, $startDate),
'provider_distribution' => $this->getProviderDistributionChart($user, $startDate),
'model_usage' => $this->getModelUsageChart($user, $startDate),
'hourly_pattern' => $this->getHourlyPatternChart($user, $startDate),
};
return response()->json([
'data' => $chartData,
]);
}
/**
* Calculate date range for period
*/
private function getDateRange(string $period): array
{
$now = now();
return match ($period) {
'today' => [
'start' => $now->copy()->startOfDay(),
'end' => $now->copy()->endOfDay(),
],
'week' => [
'start' => $now->copy()->startOfWeek(),
'end' => $now->copy()->endOfWeek(),
],
'month' => [
'start' => $now->copy()->startOfMonth(),
'end' => $now->copy()->endOfMonth(),
],
'all' => [
'start' => $now->copy()->subYears(10), // 10 years back
'end' => null,
],
};
}
/**
* Get daily cost chart data
*/
private function getDailyCostChart($user, $startDate): array
{
$dailyData = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $startDate)
->where('status', 'success')
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('SUM(total_cost) as cost'),
DB::raw('COUNT(*) as requests')
)
->groupBy('date')
->orderBy('date')
->get();
return [
'type' => 'daily_cost',
'labels' => $dailyData->pluck('date')->toArray(),
'datasets' => [
[
'label' => 'Daily Cost',
'data' => $dailyData->pluck('cost')->map(fn($v) => round($v, 4))->toArray(),
'backgroundColor' => 'rgba(59, 130, 246, 0.5)',
'borderColor' => 'rgba(59, 130, 246, 1)',
],
],
];
}
/**
* Get provider distribution chart data
*/
private function getProviderDistributionChart($user, $startDate): array
{
$providerData = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $startDate)
->where('status', 'success')
->select('provider', DB::raw('SUM(total_cost) as cost'))
->groupBy('provider')
->orderByDesc('cost')
->get();
return [
'type' => 'provider_distribution',
'labels' => $providerData->pluck('provider')->map(fn($p) => $this->getProviderName($p))->toArray(),
'datasets' => [
[
'label' => 'Cost by Provider',
'data' => $providerData->pluck('cost')->map(fn($v) => round($v, 4))->toArray(),
'backgroundColor' => [
'rgba(59, 130, 246, 0.8)',
'rgba(239, 68, 68, 0.8)',
'rgba(34, 197, 94, 0.8)',
'rgba(251, 146, 60, 0.8)',
'rgba(168, 85, 247, 0.8)',
],
],
],
];
}
/**
* Get model usage chart data
*/
private function getModelUsageChart($user, $startDate): array
{
$modelData = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $startDate)
->where('status', 'success')
->select('model', DB::raw('COUNT(*) as requests'))
->groupBy('model')
->orderByDesc('requests')
->limit(10)
->get();
return [
'type' => 'model_usage',
'labels' => $modelData->pluck('model')->toArray(),
'datasets' => [
[
'label' => 'Requests by Model',
'data' => $modelData->pluck('requests')->toArray(),
'backgroundColor' => 'rgba(59, 130, 246, 0.5)',
'borderColor' => 'rgba(59, 130, 246, 1)',
],
],
];
}
/**
* Get hourly pattern chart data
*/
private function getHourlyPatternChart($user, $startDate): array
{
$hourlyData = LlmRequest::where('gateway_user_id', $user->user_id)
->where('created_at', '>=', $startDate)
->where('status', 'success')
->select(
DB::raw('HOUR(created_at) as hour'),
DB::raw('COUNT(*) as requests')
)
->groupBy('hour')
->orderBy('hour')
->get();
// Fill missing hours with 0
$allHours = collect(range(0, 23))->map(function ($hour) use ($hourlyData) {
$data = $hourlyData->firstWhere('hour', $hour);
return $data?->requests ?? 0;
});
return [
'type' => 'hourly_pattern',
'labels' => range(0, 23),
'datasets' => [
[
'label' => 'Requests by Hour',
'data' => $allHours->toArray(),
'backgroundColor' => 'rgba(59, 130, 246, 0.5)',
'borderColor' => 'rgba(59, 130, 246, 1)',
],
],
];
}
/**
* Get human-readable provider name
*/
private function getProviderName(string $provider): string
{
return match ($provider) {
'openai' => 'OpenAI',
'anthropic' => 'Anthropic',
'gemini' => 'Google Gemini',
'deepseek' => 'DeepSeek',
'mistral' => 'Mistral AI',
default => ucfirst($provider),
};
}
}