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.
- PHP >= 8.2
- Symfony >= 7.0
- symfony/ai-bundle >= 0.4
composer require lunetics/llm-cost-tracking-bundleIf you are not using Symfony Flex, register the bundle manually:
// config/bundles.php
return [
// ...
Lunetics\LlmCostTrackingBundle\LuneticsLlmCostTrackingBundle::class => ['all' => true],
];- 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
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.
# 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 # optionalLogging is disabled by default. Enable it explicitly to have per-request LLM cost data written via Monolog:
lunetics_llm_cost_tracking:
logging:
enabled: trueEach 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'If you want fully offline/air-gapped operation, or prefer explicit control over every model's price:
lunetics_llm_cost_tracking:
dynamic_pricing:
enabled: falseWhen 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.
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 hourMinimum: 1 second. Maximum: 604800 (7 days).
To manually refresh the cached pricing from models.dev:
php bin/console lunetics:llm:update-pricingThis clears the cache and immediately fetches fresh pricing. Add --verbose to see the full model table:
php bin/console lunetics:llm:update-pricing --verboseThe command exits with a non-zero status if the API is unreachable or returns no models, making it safe to use in deployment pipelines.
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.phpThe model string passed to $platform->invoke() (e.g. 'gpt-5') is the same string the bundle uses to look up pricing.
# 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.
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.00Your models: entries always take precedence over bundle defaults and dynamic pricing.
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.
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).
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.devOverride the PHP version with PHP_VERSION=8.2 make test.
MIT License. See LICENSE for details.