boriskwemo / llm-ledger
Framework-agnostic PHP package to call and track costs of LLMs (OpenAI, Anthropic, Google, Mistral, DeepSeek, Qwen and more) with a unified API, a bundled model registry, and Symfony 8 / Laravel integrations.
Requires
- php: >=8.2
- ext-json: *
- ext-pdo: *
- nyholm/psr7: ^1.8
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- symfony/http-client: ^6.4 || ^7.0 || ^8.0
- symfony/yaml: ^6.4 || ^7.0 || ^8.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11.0
Suggests
- illuminate/database: Required to persist usage via Eloquent in Laravel.
- illuminate/support: Required to use the Laravel integration.
- symfony/framework-bundle: Required to use the Symfony bundle integration (with symfony/twig-bundle and twig/twig for the dashboard).
- twig/twig: Renders the Symfony dashboard templates.
README
Track, analyze and control your LLM spend from one clean PHP API - compatible with OpenAI, Anthropic, Google Gemini, Mistral, DeepSeek, Qwen, Kimi, GLM, Grok and any OpenAI-compatible endpoint. Works natively with Symfony 8 and Laravel, installs from Composer, and ships with a customizable cost dashboard, a deprecation-aware model catalog and batch processing - all under the MIT license.
$llm = Llm::fromYaml('llm.yaml'); $result = $llm->chat('deepseek-chat', 'Explain quantum computing in one sentence.'); echo $result->text(); // the answer echo $result->usage->totalTokens; // 42 echo $result->usage->reasoningTokens; // hidden thinking tokens, exposed echo $result->cost->total; // 0.000123 (USD)
Every call - tokens, reasoning tokens, latency, model, provider and dollar cost - is recorded automatically and visualized in a dashboard. No more guessing what AI actually costs you.
Table of contents
- Why LLM Ledger?
- Features
- Who is it for?
- How it compares
- Requirements
- Installation
- Quickstart
- Configuration
- Model registry & deprecation
- Thinking / reasoning tokens
- Batch processing
- API key handling
- Symfony 8 integration
- Laravel integration
- CLI
- Model coverage
- Architecture
- Testing
- Contributing
- Security
- License
Why LLM Ledger?
Modern apps rarely use one AI provider. They use four or five - a frontier model from OpenAI, Claude for coding, Gemini for long context, DeepSeek or Qwen when cost matters, Mistral for EU data residency. That fragmentation creates three real problems:
| Pain point | What you hit today | What LLM Ledger does |
|---|---|---|
| N vendor SDKs | A different client, auth, and response shape for every provider | One chat() / complete() / batch() API across 19 providers |
| Invisible spend | Reasoning/thinking tokens and cache reads silently change your bill | Normalizes and stores reasoning_tokens, cache reads/writes, finish reason and tool calls |
| No oversight | You find out the bill at the end of the month | Per-call cost, per-model/per-provider/per-day aggregates, dashboard + CLI |
| Surprise deprecations | A model disappears and your code breaks | A deprecation-aware model registry (superseded_by, date, note) |
The result: one dependency instead of five, and a complete, queryable audit trail of your AI usage and cost - for free, open-source, without locking you into a gateway or a hosted dashboard.
Features
- Unified multi-provider API - a single
chat()method for OpenAI, Anthropic, Google, Mistral, DeepSeek, Qwen, xAI, Groq, Cohere and any OpenAI-compatible server (vLLM, LiteLLM, local endpoints). - American, French & Chinese models - OpenAI · Anthropic · Google · xAI · Groq · Cohere · Meta (US), Mistral · Kyutai · LightOn (France), DeepSeek · Qwen · Kimi · GLM · Ernie · MiniMax · Doubao · Hunyuan (China).
- Automatic cost tracking - per-call token usage and USD cost, including cached-input and Anthropic cache-write pricing, aggregated by model/provider/day.
- Thinking / reasoning token visibility - captures reasoning text and token counts across providers so no token is invisible in your bill.
- Batch processing - run hundreds of prompts through OpenAI, Anthropic or Gemini batch APIs with one
batch()call and track every item. - Deprecation-aware model registry - curated, editable YAML with descriptions, context windows, pricing, modalities and a
deprecated/superseded_bylifecycle. - YAML configuration - providers, storage, defaults and UI in one
llm.yaml. - Customizable dashboard - light/dark themable UI with Twig (Symfony), Blade (Laravel) and a standalone PHP version; every template is overridable.
- Secure by default - API keys live in memory only and are never persisted or logged.
- CLI -
models:list,models:show,usage:report,call.
Who is it for?
- SaaS & AI-feature teams that call multiple LLMs and need to bill customers or teams accurately.
- Symfony / Laravel shops that want a first-class, idiomatic integration - autowired service or facade - not a raw HTTP client.
- Agencies & indie developers who want to track AI spend per client or project without building infrastructure.
- Cost-conscious engineers comparing models (GPT vs Claude vs Gemini vs DeepSeek vs Qwen) on real, recorded numbers.
How it compares
| Raw vendor SDKs | Generic HTTP client | LLM Ledger | |
|---|---|---|---|
| Multi-provider, one API | ❌ one SDK per vendor | ⚠️ you wire each one | ✅ 19 providers, one API |
| Automatic cost tracking | ❌ | ❌ manual math | ✅ + dashboard & CLI |
| Reasoning/thinking token visibility | ⚠️ vendor-specific parsing | ❌ | ✅ normalized |
| Batch across providers | ❌ per-SDK | ❌ | ✅ one batch() |
| Model deprecation awareness | ❌ | ❌ | ✅ built-in registry |
| Symfony 8 & Laravel support | DIY | DIY | ✅ bundle + provider + facade |
| Open source / self-hosted | - | - | ✅ MIT, data stays with you |
Requirements
- PHP 8.2+ (Symfony 8.1 requires 8.4+, Laravel 12 requires 8.2+)
ext-json,ext-pdo- Composer
Installation
composer require boriskwemo/llm-ledger
Quickstart (plain PHP)
<?php require 'vendor/autoload.php'; use LlmCostTracker\Client\Llm; $llm = Llm::fromYaml('llm.yaml'); $result = $llm->chat('gpt-5.2', 'Hello!', [ 'temperature' => 0.4, 'tags' => ['demo'], 'user' => 'alice', ]); echo $result->text(); printf("cost: $%.6f\n", $result->cost->total); // Lifetime aggregates $totals = $llm->tracker()->totals(); printf("%d requests, $%.4f total\n", $totals->requests, $totals->cost);
Structured conversation & multimodal content
$result = $llm->chat('anthropic:claude-sonnet-4-6', [ ['role' => 'system', 'content' => 'You are concise.'], ['role' => 'user', 'content' => 'What is 17 × 23?'], ]); // Or multiple content parts (vision) $result = $llm->chat('qwen-vl-max', [[ 'role' => 'user', 'content' => [ ['type' => 'text', 'text' => 'What is in this image?'], ['type' => 'image_url', 'image_url' => ['url' => 'https://example.com/img.png']], ], ]]);
Configuration (llm.yaml)
defaults: model: deepseek-chat provider: null # Each provider needs an api_key (literal string or env(VAR)). providers: openai: { api_key: env(OPENAI_API_KEY) } anthropic: { api_key: env(ANTHROPIC_API_KEY) } google: { api_key: env(GOOGLE_API_KEY) } mistral: { api_key: env(MISTRAL_API_KEY) } deepseek: { api_key: env(DEEPSEEK_API_KEY) } qwen: { api_key: env(DASHSCOPE_API_KEY) } # Custom / self-hosted OpenAI-compatible endpoint: my_server: base_url: http://localhost:8000/v1 api_key: local protocol: openai # openai | anthropic | google | auto # Optional: override or extend the bundled model registry. models: providers: my_server: models: my-model: name: My Model description: A fine-tuned local model. context_window: 32768 pricing: { input: 0.0, output: 0.0 } tracking: enabled: true storage: sqlite://llm.db # memory | sqlite://path.db | sqlite::memory: | mysql:... | pgsql:... | eloquent (Laravel) table: llm_usage store_content: true # persist response text, reasoning and tool calls (disable to save space) # dsn: mysql:host=...;dbname=... # alternative to storage for DB DSNs # username: root # password: secret ui: title: LLM Ledger theme: auto # light | dark | auto
Every provider's base_url and protocol default come from the bundled registry; api_key is the only thing you normally add.
Model registry & deprecation
The catalog lives in config/models.yaml - a readable, editable snapshot you can copy and point at with models_file. Each entry carries a description and lifecycle metadata:
gpt-4o: name: GPT-4o description: Legacy omnimodal model. kind: chat context_window: 128000 max_output_tokens: 16384 modalities: [text, image, audio, tool] released_at: 2024-05-01 pricing: { input: 2.5, output: 10.0, cached_input: 1.25 } deprecated: true deprecation: date: 2026-04-01 superseded_by: gpt-4.1 note: Retired from the OpenAI API.
Inspect it programmatically:
$registry = $llm->models(); $registry->active(); // non-deprecated models $registry->deprecated(); // models marked deprecated $registry->get('gpt-4o'); // full ModelInfo (incl. ->deprecation) $registry->search('deepseek');
Pricing is a bundled snapshot. Providers change prices often - treat it as a starting point and override in your own YAML (or via
models_file).
Tracking thinking / reasoning tokens
Every result exposes the full picture, normalized across providers:
$result = $llm->chat('deepseek-reasoner', 'Prove that sqrt(2) is irrational.'); $result->usage->reasoningTokens; // thinking/reasoning tokens (provider-reported) $result->usage->cachedPromptTokens; // prompt-cache reads $result->usage->cacheCreationTokens;// Anthropic cache writes $result->reasoningText(); // the chain-of-thought text (when exposed) $result->finishReason; // stop | length | tool_calls | ... $result->toolCalls; // tool/function calls
Where each number comes from (verified against vendor specs):
| Provider | Reasoning tokens | Reasoning content | Cached tokens |
|---|---|---|---|
| OpenAI-compatible | usage.completion_tokens_details.reasoning_tokens |
message.reasoning_content (DeepSeek/Qwen/Kimi/GLM) |
usage.prompt_tokens_details.cached_tokens |
| Anthropic | included in usage.output_tokens (no split) |
content[].type == "thinking" blocks |
usage.cache_read_input_tokens / cache_creation_input_tokens |
| Google Gemini | usageMetadata.thoughtsTokenCount |
parts[].thought == true |
usageMetadata.cachedContentTokenCount |
All of this is persisted (tokens, reasoning text, response text, finish reason, tool calls) - set tracking.store_content: false to keep only the token/cost numbers.
Batch processing
// Strings, or [custom_id => messages] pairs. $job = $llm->batch('gpt-5.2', [ 'Summarize article A', ['custom_id' => 'b-1', 'messages' => 'Summarize article B'], ['custom_id' => 'b-2', 'messages' => [['role' => 'user', 'content' => '…']]], ], [ 'completion_window' => '24h', // OpenAI 'temperature' => 0.2, 'timeout' => 900, // max seconds to wait (default 600) 'tags' => ['nightly-job'], ]); $job->status; // completed | failed | in_progress | ... $job->succeeded(); // number of successful items $job->items; // per-item [custom_id, content, usage, cost, reasoning, error]
Supported backends:
- OpenAI (and providers mirroring
/v1/batches): file upload → batch → poll → JSONL results. - Anthropic:
/v1/messages/batches→ poll → results. - Google Gemini:
:batchGenerateContent(synchronous, returns all responses in one call).
Each successful item is recorded to the tracker with its custom_id. Use $llm->pollBatch($provider, $id) to resume polling a job without recreating it.
API key handling
Keys are never persisted, never logged, and never stored in the tracking database:
- Keys are read once from config (
api_key: env(VAR),%env(VAR)%in Symfony,env()in Laravel) and held in memory only for the lifetime of the request. - Only the
Authorization/x-api-key/x-goog-api-keyheader carries the key; error messages and stored records contain provider responses, never your key. - Keep keys out of version control via
.env(gitignored). For Laravel, note thatphp artisan config:cachebakesenv()values intobootstrap/cache/config.php- exclude that cache from your repo and rotate keys if it leaks. For production secrets, wire a secret manager by resolving the value in your own config before passing it to the provider map.
Symfony 8 integration
-
Register the bundle in
config/bundles.php:return [ // ... LlmCostTracker\Symfony\LlmCostTrackerBundle::class => ['all' => true], ];
-
Configure it (
config/packages/llm_cost_tracker.yaml):llm_cost_tracker: providers: openai: { api_key: '%env(OPENAI_API_KEY)%' } deepseek: { api_key: '%env(DEEPSEEK_API_KEY)%' } tracking: enabled: true # defaults to sqlite://<project>/var/llm.db dashboard: enabled: true title: 'LLM Ledger' theme: auto
-
Import the routes (
config/routes/llm_cost_tracker.yaml):llm_cost_tracker: resource: '@LlmCostTrackerBundle/Resources/config/routes.yaml' prefix: /llm
-
Use the autowired service:
public function __construct(private \LlmCostTracker\Client\Llm $llm) {} $answer = $this->llm->complete('mistral-large-latest', 'Hello!');
Open /llm for the dashboard and /llm/models for the model catalog. To customize the UI, copy Resources/views into templates/bundles/LlmCostTrackerBundle/ and edit - Twig resolution gives your templates priority.
Laravel integration
The package auto-registers via Composer (extra.laravel.providers). Publish config, migration and views:
php artisan vendor:publish --provider="LlmCostTracker\Laravel\LlmCostTrackerServiceProvider"
php artisan migrate
Configure in .env:
OPENAI_API_KEY=... DEEPSEEK_API_KEY=... LLM_COST_TRACKER_ENABLED=true LLM_COST_TRACKER_STORAGE=eloquent
Use the facade:
use Llm; $answer = Llm::complete('qwen3-max', 'Hello!'); $result = Llm::chat('openai:gpt-5.2', 'Hi', ['tags' => ['web']]);
The dashboard is served at /llm (configurable via llm-cost-tracker.dashboard.route_prefix). Views are published to resources/views/vendor/llm-cost-tracker for customization.
CLI
vendor/bin/llm-tracker models:list --search=deepseek
vendor/bin/llm-tracker models:show gpt-4o
vendor/bin/llm-tracker usage:report --days=30
vendor/bin/llm-tracker call deepseek-chat "Hello!"
Standalone dashboard
examples/dashboard.php renders a self-contained HTML dashboard with zero framework dependencies - useful as a reference or for a quick internal tool.
Model coverage
The bundled registry (compiled from vendor documentation, June-Aug 2026) covers, among others:
| Region | Providers |
|---|---|
| 🇺🇸 US / Global | OpenAI, Anthropic, Google, xAI, Groq, Cohere, Meta (Llama), OpenRouter |
| 🇫🇷 France | Mistral AI, Kyutai (Moshi), LightOn (Alfred) |
| 🇨🇳 China | DeepSeek, Alibaba Qwen, Moonshot Kimi, Zhipu GLM, Baidu Ernie, MiniMax, ByteDance Doubao, Tencent Hunyuan |
Sources used for the snapshot: LLM API pricing comparison, Mistral API pricing, Chinese LLM price war 2026, OpenAI API pricing, Claude API pricing, Gemini API pricing, xAI Grok pricing, Qwen API pricing, DeepSeek API pricing, and GLM-5.2 vs Kimi K3.
Architecture
src/
├── Client/ Llm facade, provider clients (OpenAI-compatible, Anthropic, Google), batch
├── Config/ YAML loader + env() resolution
├── Cost/ cost calculator (input/output/cached/cache-write pricing)
├── Http/ PSR-18 wrapper (Symfony HttpClient + Nyholm PSR-7 by default)
├── Model/ ModelInfo, ProviderInfo, ModelRegistry
├── Tracking/ UsageRecord, repositories (InMemory, PDO, Eloquent), Tracker
├── Symfony/ bundle, DI, controller, Twig templates
└── Laravel/ service provider, facade, Eloquent model, controller, Blade views
Token counts come from each provider's usage field (authoritative). When a provider omits usage, a ~4 chars/token heuristic fills the gap so tracking never silently drops a call.
Testing
composer install vendor/bin/phpunit
Contributing
Contributions are welcome - code, docs, tests, or model/pricing updates. See CONTRIBUTING.md for setup, coding standards, and the pull-request process.
Security
To report a vulnerability privately (not in a public issue), see SECURITY.md. Your API keys are never persisted or logged; read the file for key-handling best practices.
License
MIT - free to use, modify and ship in commercial projects. See LICENSE.