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.

Maintainers

Package info

github.com/boriskwemo/llm-ledger

pkg:composer/boriskwemo/llm-ledger

Transparency log

Statistics

Installs: 30

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.5 2026-08-29 11:18 UTC

This package is auto-updated.

Last update: 2026-08-29 11:18:47 UTC


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

  1. Why LLM Ledger?
  2. Features
  3. Who is it for?
  4. How it compares
  5. Requirements
  6. Installation
  7. Quickstart
  8. Configuration
  9. Model registry & deprecation
  10. Thinking / reasoning tokens
  11. Batch processing
  12. API key handling
  13. Symfony 8 integration
  14. Laravel integration
  15. CLI
  16. Model coverage
  17. Architecture
  18. Testing
  19. Contributing
  20. Security
  21. 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_by lifecycle.
  • 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-key header 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 that php artisan config:cache bakes env() values into bootstrap/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

  1. Register the bundle in config/bundles.php:

    return [
        // ...
        LlmCostTracker\Symfony\LlmCostTrackerBundle::class => ['all' => true],
    ];
  2. 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
  3. Import the routes (config/routes/llm_cost_tracker.yaml):

    llm_cost_tracker:
        resource: '@LlmCostTrackerBundle/Resources/config/routes.yaml'
        prefix: /llm
  4. 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.