Skip to content
Merged

wip #94

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 53 additions & 24 deletions app/Actions/FetchLlmUsageAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,43 @@

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.
*
* @return Collection<int, array{date: string, model: string, prompt_tokens: int, completion_tokens: int, total_tokens: int, requests: int, spend: float}>
*/
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
Expand All @@ -40,27 +61,35 @@ private function client(): PendingRequest
}

/**
* @param array<mixed> $logs
* @return Collection<int, array{date: string, model: string, prompt_tokens: int, completion_tokens: int, total_tokens: int, requests: int, spend: float}>
* @param array<string, array{date: string, model: string, prompt_tokens: int, completion_tokens: int, total_tokens: int, requests: int, spend: float}> $totals
* @param array<mixed> $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
Expand Down
1 change: 1 addition & 0 deletions lang/de_CH/components.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions lang/en_CH/components.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 7 additions & 2 deletions resources/views/components/intro.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,21 @@ class="{{ $cap }} cursor-pointer transition hover:bg-white focus-ring">→</butt
</ul>
@endif

<p class="mt-6">
<p class="mt-6 flex flex-wrap items-center gap-3">
@if($section['next'] !== null)
<label for="intro-tab-{{ $section['next'] }}"
class="{{ $pill }} border border-brand bg-white text-brand hover:bg-brand hover:text-white">
<span aria-hidden="true">→</span>
{{ __('components.intro.next', ['title' => $sections[$section['next']]['command']]) }}
</label>
@else
<a href="{{ localized_route('contact.index') }}"
<a href="{{ localized_route('services.index') }}"
class="{{ $pill }} focus-ring bg-brand text-white hover:bg-brand-strong">
<span aria-hidden="true">→</span>
{{ __('components.intro.expertise') }}
</a>
<a href="{{ localized_route('contact.index') }}"
class="{{ $pill }} focus-ring border border-brand bg-white text-brand hover:bg-brand hover:text-white">
<span aria-hidden="true">→</span>
{{ __('components.intro.cta') }}
</a>
Expand Down
55 changes: 48 additions & 7 deletions tests/Feature/Actions/FetchLlmUsageActionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,26 @@
config()->set('services.litellm.master_key', 'test-master-key');
});

function llmPage(array $data, int $page, int $totalPages): array

Check failure on line 16 in tests/Feature/Actions/FetchLlmUsageActionTest.php

View workflow job for this annotation

GitHub Actions / pull-request | ci larastan

Function llmPage() return type has no value type specified in iterable type array.

Check failure on line 16 in tests/Feature/Actions/FetchLlmUsageActionTest.php

View workflow job for this annotation

GitHub Actions / pull-request | ci larastan

Function llmPage() has parameter $data with no value type specified in iterable type 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'));
Expand All @@ -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');

Check failure on line 64 in tests/Feature/Actions/FetchLlmUsageActionTest.php

View workflow job for this annotation

GitHub Actions / pull-request | ci larastan

Cannot cast mixed to int.

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)

Check failure on line 84 in tests/Feature/Actions/FetchLlmUsageActionTest.php

View workflow job for this annotation

GitHub Actions / pull-request | ci larastan

Offset 'prompt_tokens' might not exist on array{date: string, model: string, prompt_tokens: int, completion_tokens: int, total_tokens: int, requests: int, spend: float}|null.
->and($qwen['requests'])->toBe(2);

Check failure on line 85 in tests/Feature/Actions/FetchLlmUsageActionTest.php

View workflow job for this annotation

GitHub Actions / pull-request | ci larastan

Offset 'requests' might not exist on array{date: string, model: string, prompt_tokens: int, completion_tokens: int, total_tokens: int, requests: int, spend: float}|null.

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'));
Expand Down
16 changes: 11 additions & 5 deletions tests/Feature/Jobs/FetchLlmUsageJobTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,24 @@
});

/**
* @return array<int, array<string, mixed>>
* @return array<string, mixed>
*/
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)),
]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Loading