From cd3bdf86b5aef7b00276800564920795cd2b40b4 Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Sun, 2 Aug 2026 20:34:09 +0200 Subject: [PATCH] wip --- app/Actions/FetchLlmUsageAction.php | 77 +++++++++++++------ lang/de_CH/components.php | 1 + lang/en_CH/components.php | 1 + resources/views/components/intro.blade.php | 9 ++- .../Actions/FetchLlmUsageActionTest.php | 55 +++++++++++-- tests/Feature/Jobs/FetchLlmUsageJobTest.php | 16 ++-- 6 files changed, 121 insertions(+), 38 deletions(-) diff --git a/app/Actions/FetchLlmUsageAction.php b/app/Actions/FetchLlmUsageAction.php index 3091c1b..6c528c1 100644 --- a/app/Actions/FetchLlmUsageAction.php +++ b/app/Actions/FetchLlmUsageAction.php @@ -11,6 +11,12 @@ class FetchLlmUsageAction { + // The unpaginated /spend/logs endpoint is deprecated and can return an + // unbounded response body — a busy day previously exhausted PHP's memory + // limit decoding a single giant JSON payload. /spend/logs/v2 caps each + // page at 100 rows, so we walk it page by page and aggregate as we go. + private const int PAGE_SIZE = 100; + /** * Fetch the per-model usage aggregates for a single day from the LiteLLM proxy. * @@ -18,15 +24,30 @@ class FetchLlmUsageAction */ public function fetchDay(CarbonImmutable $date): Collection { - $response = $this->client()->get('/spend/logs', [ - 'start_date' => $date->toDateString(), - 'end_date' => $date->addDay()->toDateString(), - 'summarize' => 'false', - ])->throw(); + $start = $date->startOfDay(); + $end = $date->endOfDay(); + + $totals = []; + $page = 1; + $totalPages = 1; + + do { + $body = $this->client()->get('/spend/logs/v2', [ + 'start_date' => $start->toDateTimeString(), + 'end_date' => $end->toDateTimeString(), + 'page' => $page, + 'page_size' => self::PAGE_SIZE, + ])->throw()->json(); + + $rows = data_get($body, 'data'); + + $this->accumulate($totals, $date, is_array($rows) ? $rows : []); - $logs = $response->json(); + $totalPages = max(1, $this->intValue($body, 'total_pages')); + $page++; + } while ($page <= $totalPages); - return $this->parseSpendLogs($date, is_array($logs) ? $logs : []); + return collect($totals)->values(); } private function client(): PendingRequest @@ -40,27 +61,35 @@ private function client(): PendingRequest } /** - * @param array $logs - * @return Collection + * @param array $totals + * @param array $rows */ - private function parseSpendLogs(CarbonImmutable $date, array $logs): Collection + private function accumulate(array &$totals, CarbonImmutable $date, array $rows): void { - // The end_date bound is inclusive, so the response can contain rows of - // the following day — keep only rows that started on the requested day. - return collect($logs) - ->filter(fn (mixed $log): bool => filled($this->stringValue($log, 'model_group'))) - ->filter(fn (mixed $log): bool => str_starts_with($this->stringValue($log, 'startTime'), $date->toDateString())) - ->groupBy(fn (mixed $log): string => $this->stringValue($log, 'model_group')) - ->map(fn (Collection $rows, string $model): array => [ + foreach ($rows as $log) { + $model = $this->stringValue($log, 'model_group'); + + // Defensive: only aggregate rows that actually started on the requested day. + if ($model === '' || ! str_starts_with($this->stringValue($log, 'startTime'), $date->toDateString())) { + continue; + } + + $totals[$model] ??= [ 'date' => $date->toDateString(), 'model' => $model, - 'prompt_tokens' => $rows->sum(fn (mixed $log): int => $this->intValue($log, 'prompt_tokens')), - 'completion_tokens' => $rows->sum(fn (mixed $log): int => $this->intValue($log, 'completion_tokens')), - 'total_tokens' => $rows->sum(fn (mixed $log): int => $this->intValue($log, 'total_tokens')), - 'requests' => $rows->count(), - 'spend' => round($rows->sum(fn (mixed $log): float => $this->floatValue($log, 'spend')), 6), - ]) - ->values(); + 'prompt_tokens' => 0, + 'completion_tokens' => 0, + 'total_tokens' => 0, + 'requests' => 0, + 'spend' => 0.0, + ]; + + $totals[$model]['prompt_tokens'] += $this->intValue($log, 'prompt_tokens'); + $totals[$model]['completion_tokens'] += $this->intValue($log, 'completion_tokens'); + $totals[$model]['total_tokens'] += $this->intValue($log, 'total_tokens'); + $totals[$model]['requests']++; + $totals[$model]['spend'] = round($totals[$model]['spend'] + $this->floatValue($log, 'spend'), 6); + } } private function stringValue(mixed $log, string $key): string diff --git a/lang/de_CH/components.php b/lang/de_CH/components.php index 26e11a6..053687f 100644 --- a/lang/de_CH/components.php +++ b/lang/de_CH/components.php @@ -10,6 +10,7 @@ 'prev_section' => 'Vorheriger Abschnitt', 'next_section' => 'Nächster Abschnitt', 'next' => 'weiter: :title', + 'expertise' => 'schau dir unsere Expertise an', 'cta' => 'erzähl uns von deinem Projekt', 'who_we_are' => [ 'title' => 'Wer wir sind', diff --git a/lang/en_CH/components.php b/lang/en_CH/components.php index d4a82b0..2aafab4 100644 --- a/lang/en_CH/components.php +++ b/lang/en_CH/components.php @@ -10,6 +10,7 @@ 'prev_section' => 'Previous section', 'next_section' => 'Next section', 'next' => 'next: :title', + 'expertise' => 'take a look at our expertise', 'cta' => 'tell us about your project', 'who_we_are' => [ 'title' => 'Who we are', diff --git a/resources/views/components/intro.blade.php b/resources/views/components/intro.blade.php index a014563..4ed071c 100644 --- a/resources/views/components/intro.blade.php +++ b/resources/views/components/intro.blade.php @@ -67,7 +67,7 @@ class="{{ $cap }} cursor-pointer transition hover:bg-white focus-ring">→ @endif -

+

@if($section['next'] !== null) @else - + + {{ __('components.intro.expertise') }} + + {{ __('components.intro.cta') }} diff --git a/tests/Feature/Actions/FetchLlmUsageActionTest.php b/tests/Feature/Actions/FetchLlmUsageActionTest.php index d558812..6ba5bbb 100644 --- a/tests/Feature/Actions/FetchLlmUsageActionTest.php +++ b/tests/Feature/Actions/FetchLlmUsageActionTest.php @@ -13,15 +13,26 @@ config()->set('services.litellm.master_key', 'test-master-key'); }); +function llmPage(array $data, int $page, int $totalPages): array +{ + return [ + 'data' => $data, + 'total' => $totalPages * 100, + 'page' => $page, + 'page_size' => 100, + 'total_pages' => $totalPages, + ]; +} + it('fetches and aggregates spend logs per day and model', function () { Http::fake([ - 'llm.codebar.net/spend/logs*' => Http::response([ + 'llm.codebar.net/spend/logs/v2*' => Http::response(llmPage([ ['model_group' => 'qwen3.6:35b', 'prompt_tokens' => 100, 'completion_tokens' => 50, 'total_tokens' => 150, 'spend' => 0.5, 'startTime' => '2026-07-20T10:00:00.000000Z'], ['model_group' => 'qwen3.6:35b', 'prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15, 'spend' => 0.25, 'startTime' => '2026-07-20T11:00:00.000000Z'], ['model_group' => 'qwen3-embedding:4b', 'prompt_tokens' => 870, 'completion_tokens' => 0, 'total_tokens' => 870, 'spend' => 0, 'startTime' => '2026-07-20T12:00:00.000000Z'], ['model_group' => '', 'prompt_tokens' => 5, 'completion_tokens' => 5, 'total_tokens' => 10, 'spend' => 0, 'startTime' => '2026-07-20T13:00:00.000000Z'], ['model_group' => 'qwen3.6:35b', 'prompt_tokens' => 99, 'completion_tokens' => 99, 'total_tokens' => 198, 'spend' => 1, 'startTime' => '2026-07-21T00:10:00.000000Z'], - ]), + ], page: 1, totalPages: 1)), ]); $rows = (new FetchLlmUsageAction)->fetchDay(CarbonImmutable::parse('2026-07-20')); @@ -38,17 +49,47 @@ ->and($qwen['spend'])->toBe(0.75); Http::assertSent(function (Request $request) { - return str_contains($request->url(), '/spend/logs') + return str_contains($request->url(), '/spend/logs/v2') && $request->hasHeader('Authorization', 'Bearer test-master-key') - && data_get($request->data(), 'start_date') === '2026-07-20' - && data_get($request->data(), 'end_date') === '2026-07-21' - && data_get($request->data(), 'summarize') === 'false'; + && data_get($request->data(), 'start_date') === '2026-07-20 00:00:00' + && data_get($request->data(), 'end_date') === '2026-07-20 23:59:59' + && data_get($request->data(), 'page') === 1 + && data_get($request->data(), 'page_size') === 100; }); })->group('llm-analytics'); +it('walks every page and aggregates across them without loading the whole day at once', function () { + Http::fake([ + 'llm.codebar.net/spend/logs/v2*' => function (Request $request) { + $page = (int) data_get($request->data(), 'page'); + + return match ($page) { + 1 => Http::response(llmPage([ + ['model_group' => 'qwen3.6:35b', 'prompt_tokens' => 100, 'completion_tokens' => 50, 'total_tokens' => 150, 'spend' => 0.5, 'startTime' => '2026-07-20T10:00:00.000000Z'], + ], page: 1, totalPages: 2)), + 2 => Http::response(llmPage([ + ['model_group' => 'qwen3.6:35b', 'prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15, 'spend' => 0.25, 'startTime' => '2026-07-20T11:00:00.000000Z'], + ], page: 2, totalPages: 2)), + default => Http::response(llmPage([], page: $page, totalPages: 2)), + }; + }, + ]); + + $rows = (new FetchLlmUsageAction)->fetchDay(CarbonImmutable::parse('2026-07-20')); + + expect($rows)->toHaveCount(1); + + $qwen = $rows->first(); + + expect($qwen['prompt_tokens'])->toBe(110) + ->and($qwen['requests'])->toBe(2); + + Http::assertSentCount(2); +})->group('llm-analytics'); + it('throws when the proxy responds with an error', function () { Http::fake([ - 'llm.codebar.net/spend/logs*' => Http::response([], 500), + 'llm.codebar.net/spend/logs/v2*' => Http::response([], 500), ]); (new FetchLlmUsageAction)->fetchDay(CarbonImmutable::parse('2026-07-20')); diff --git a/tests/Feature/Jobs/FetchLlmUsageJobTest.php b/tests/Feature/Jobs/FetchLlmUsageJobTest.php index dbc73c2..c6d3b93 100644 --- a/tests/Feature/Jobs/FetchLlmUsageJobTest.php +++ b/tests/Feature/Jobs/FetchLlmUsageJobTest.php @@ -20,18 +20,24 @@ }); /** - * @return array> + * @return array */ function spendLogsPayload(int $promptTokens): array { return [ - ['model_group' => 'qwen3.6:35b', 'prompt_tokens' => $promptTokens, 'completion_tokens' => 50, 'total_tokens' => $promptTokens + 50, 'spend' => 0.5, 'startTime' => '2026-07-20T10:00:00.000000Z'], + 'data' => [ + ['model_group' => 'qwen3.6:35b', 'prompt_tokens' => $promptTokens, 'completion_tokens' => 50, 'total_tokens' => $promptTokens + 50, 'spend' => 0.5, 'startTime' => '2026-07-20T10:00:00.000000Z'], + ], + 'total' => 1, + 'page' => 1, + 'page_size' => 100, + 'total_pages' => 1, ]; } it('stores the fetched usage and updates existing rows idempotently', function () { Http::fake([ - 'llm.codebar.net/spend/logs*' => Http::sequence() + 'llm.codebar.net/spend/logs/v2*' => Http::sequence() ->push(spendLogsPayload(promptTokens: 100)) ->push(spendLogsPayload(promptTokens: 200)), ]); @@ -64,7 +70,7 @@ function spendLogsPayload(int $promptTokens): array it('invalidates the stats cache after storing', function () { Http::fake([ - 'llm.codebar.net/spend/logs*' => Http::response(spendLogsPayload(promptTokens: 100)), + 'llm.codebar.net/spend/logs/v2*' => Http::response(spendLogsPayload(promptTokens: 100)), ]); $versionBefore = Cache::get(LlmUsageStatsAction::VERSION_CACHE_KEY, 0); @@ -96,7 +102,7 @@ function spendLogsPayload(int $promptTokens): array })->group('llm-analytics'); it('clears the response cache once after the whole sync batch finishes', function () { - Http::fake(['llm.codebar.net/spend/logs*' => Http::response(spendLogsPayload(promptTokens: 100))]); + Http::fake(['llm.codebar.net/spend/logs/v2*' => Http::response(spendLogsPayload(promptTokens: 100))]); // Four days are synced below; the rendered pages are dropped exactly once. ResponseCache::partialMock()->shouldReceive('clear')->once();