lunetics / llm-cost-tracking-bundle
Symfony bundle that tracks LLM API costs and displays them in the Web Debug Toolbar and Profiler
Installs: 8
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Type:symfony-bundle
pkg:composer/lunetics/llm-cost-tracking-bundle
Requires
- php: >=8.2
- symfony/ai-bundle: >=0.4.0 <2.0.0
- symfony/ai-platform: >=0.4.0 <2.0.0
- symfony/cache: ^7.0|^8.0
- symfony/config: ^7.0|^8.0
- symfony/dependency-injection: ^7.0|^8.0
- symfony/framework-bundle: ^7.0|^8.0
- symfony/http-client: ^7.0|^8.0
- symfony/http-kernel: ^7.0|^8.0
- symfony/twig-bundle: ^7.0|^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.0
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpstan/phpstan-symfony: ^2.0
- phpunit/phpunit: ^11.5.13
- symfony/web-profiler-bundle: ^7.0|^8.0
This package is auto-updated.
Last update: 2026-02-26 00:09:10 UTC
README
A Symfony bundle that tracks LLM API costs and displays them in the Web Debug Toolbar and Profiler.
Hooks into symfony/ai-bundle's TraceablePlatform to calculate per-request costs based on token usage, with support for input, output, cached, and thinking tokens.
Requirements
- PHP >= 8.2
- Symfony >= 7.0
- symfony/ai-bundle >= 0.4
Installation
composer require lunetics/llm-cost-tracking-bundle
If you are not using Symfony Flex, register the bundle manually:
// config/bundles.php return [ // ... Lunetics\LlmCostTrackingBundle\LuneticsLlmCostTrackingBundle::class => ['all' => true], ];
Features
- Web Debug Toolbar — shows total cost and call count for the current request
- Profiler Panel — per-call breakdown with model, tokens, and cost
- Per-model aggregation — costs grouped by model with totals
- Budget warnings — toolbar alerts when costs exceed a configurable threshold
- Color-coded costs — green/yellow/red based on configurable thresholds
- Dynamic pricing — automatically fetches live model pricing from models.dev, covering hundreds of models without any manual configuration
- Unconfigured model detection — warns when a model has no pricing data
- Injectable cost tracking — use
CostTrackerInterfacein your own services to access cost data outside the profiler - Extensible — implement
CostCalculatorInterfaceto provide custom pricing logic
Model Pricing
The bundle resolves pricing for a model using the following priority order:
- Your YAML config —
models:entries take precedence over everything else - Dynamic pricing from models.dev — the bundle fetches live pricing from models.dev (thousands of models) and caches it for 24 hours. When models.dev is unreachable, a bundled snapshot of ~3000 models is used as a fallback so known models are still priced correctly during outages
- Not found — cost is shown as zero with a warning in the profiler
This means most models work out of the box with no configuration. Your own entries always win.
All prices are in USD. The models.dev feed and any prices you configure are all treated as USD. There is no currency conversion; the
$prefix shown in the profiler is a literal dollar sign.
Configuration
# config/packages/lunetics_llm_cost_tracking.yaml lunetics_llm_cost_tracking: budget_warning: 0.50 # toolbar turns red when exceeded cost_thresholds: low: 0.01 # below = green medium: 0.10 # between low/medium = yellow, above = red logging: enabled: false # default: false — opt in to log per-request cost data via Monolog channel: 'ai' # Monolog channel (default: 'ai'); route it via your handlers dynamic_pricing: enabled: true # default: true — fetch live pricing from models.dev ttl: 86400 # cache duration in seconds (default: 24h, max: 7 days) models: my-custom-model: display_name: 'My Custom Model' provider: 'MyProvider' input_price_per_million: 1.00 output_price_per_million: 5.00 cached_input_price_per_million: 0.10 # optional thinking_price_per_million: 5.00 # optional
Logging
Logging is disabled by default. Enable it explicitly to have per-request LLM cost data written via Monolog:
lunetics_llm_cost_tracking: logging: enabled: true
Each request that makes at least one AI call produces:
- one
infolog per call (model, provider, token breakdown, cost) - one
infosummary log (total calls, cost, tokens) - one
warningif any models lack pricing configuration
Logs are emitted on kernel.terminate — after the response is sent — so there is no latency impact.
To route AI cost logs to a dedicated file, configure a Monolog handler for the ai channel:
# config/packages/monolog.yaml monolog: channels: [ai] handlers: ai_costs: type: stream path: '%kernel.logs_dir%/ai_costs.log' channels: [ai]
If the ai channel is not explicitly configured, logs fall through to your default handler.
To use a different channel name:
lunetics_llm_cost_tracking: logging: channel: 'llm'
Disabling Dynamic Pricing
If you want fully offline/air-gapped operation, or prefer explicit control over every model's price:
lunetics_llm_cost_tracking: dynamic_pricing: enabled: false
When disabled, the bundled snapshot continues to provide model coverage as a read-only baseline — no live HTTP requests are made, and the lunetics:llm:update-pricing command is removed from the container. Your explicit models: config always takes priority over the snapshot. This is the right choice for air-gapped or reproducible-pricing environments where you want stable costs without live API calls.
Upgrade note (v0.3): The old static list of bundled defaults has been replaced with a versioned pricing snapshot (
resources/pricing_snapshot.json). Whendynamic_pricing.enabled: false, the snapshot serves as an always-on baseline — models that previously returnednullpricing may now resolve from the snapshot. If you need strict "only my config" behaviour where unknown models always return nothing, addmodels:entries for every model you use and configure your app to treat unresolved models as an error.
Adjusting the Cache TTL
The dynamic pricing response is cached to avoid unnecessary HTTP requests on every page load. The default TTL is 24 hours. To refresh more or less frequently:
lunetics_llm_cost_tracking: dynamic_pricing: ttl: 3600 # 1 hour
Minimum: 1 second. Maximum: 604800 (7 days).
Console Command
To manually refresh the cached pricing from models.dev:
php bin/console lunetics:llm:update-pricing
This clears the cache and immediately fetches fresh pricing. Add --verbose to see the full model table:
php bin/console lunetics:llm:update-pricing --verbose
The command exits with a non-zero status if the API is unreachable or returns no models, making it safe to use in deployment pipelines.
Model Coverage
The bundle ships a versioned snapshot of the models.dev catalogue (~3000 models across ~100 providers) as resources/pricing_snapshot.json. This snapshot is used as a fallback when the live API is unreachable, so pricing works correctly even in offline or air-gapped environments.
The snapshot is regenerated before each release. To regenerate it locally:
make update-snapshot
# or directly:
php bin/generate_snapshot.php
The model string passed to $platform->invoke() (e.g. 'gpt-5') is the same string the bundle uses to look up pricing.
Example: OpenAI GPT
# config/packages/symfony_ai.yaml symfony_ai: platform: openai: api_key: '%env(OPENAI_API_KEY)%'
// The model string 'gpt-5' is matched against the pricing registry $result = $platform->invoke('gpt-5', 'Explain Symfony in one sentence.');
No lunetics_llm_cost_tracking config is needed — gpt-5 is covered by dynamic pricing (or the bundled snapshot when offline). Costs appear automatically in the profiler toolbar.
Overriding or Adding Model Pricing
If you use a model that isn't on models.dev (e.g. a fine-tuned or self-hosted model), add it to your config:
lunetics_llm_cost_tracking: models: ft:gpt-5:my-finetuned-2025: display_name: 'My Fine-tuned GPT-5' provider: 'OpenAI' input_price_per_million: 3.00 output_price_per_million: 15.00
Your models: entries always take precedence over bundle defaults and dynamic pricing.
Using Cost Data in Your Services
The CostTrackerInterface service is available for dependency injection. Use it to access cost data outside the profiler — for example, in middleware, event listeners, or API responses:
use Lunetics\LlmCostTrackingBundle\Service\CostTrackerInterface; class MyService { public function __construct( private readonly CostTrackerInterface $costTracker, ) {} public function logCosts(): void { $totals = $this->costTracker->getTotals(); // $totals = ['calls' => 3, 'input_tokens' => 5000, ..., 'cost' => 0.042] // Or get everything in one call: $snapshot = $this->costTracker->getSnapshot(); // $snapshot = ['calls' => [...], 'by_model' => [...], 'totals' => [...], 'unconfigured_models' => [...]] } }
Available methods: getCalls(), getTotals(), getByModel(), getUnconfiguredModels(), getSnapshot().
The ModelRegistryInterface and CostCalculatorInterface are also available for injection if you need lower-level access to model definitions or cost calculation.
How It Works
The bundle collects data from all services tagged with ai.traceable_platform (provided by symfony/ai-bundle). The CostTracker service iterates over all recorded LLM calls, extracts token usage metadata, and calculates costs using the configured model pricing. Results are memoized for the lifetime of the request, so repeated calls to any getter return the same data without recomputation.
The LlmCostCollector (Symfony Profiler data collector) delegates to CostTracker via getSnapshot(), which returns all cost data in a single atomic call. This separation keeps business logic in a standalone service that can be injected anywhere, while the data collector focuses on profiler integration.
Cost formula per call:
cost = (regular_input_tokens / 1M × input_price)
+ (output_tokens / 1M × output_price)
+ (cached_tokens / 1M × cached_price)
+ (thinking_tokens / 1M × thinking_price)
Where regular_input_tokens = max(0, input_tokens - cached_tokens).
Development
A Docker-based Makefile is provided for local development:
make install # Install dependencies make test # Run PHPUnit tests make phpstan # Run PHPStan (level 8) make cs-check # Check coding standards make cs-fix # Fix coding standards make ci # Run all checks make update-snapshot # Regenerate resources/pricing_snapshot.json from models.dev
Override the PHP version with PHP_VERSION=8.2 make test.
License
MIT License. See LICENSE for details.