Report usage from your own provider calls
This page is for an extension that sends its requests to an AI provider with its own code instead of through nr-llm. Such an extension can still respect the editor's AI budget and appear with its costs in the Analytics module and in the monthly billing report. It needs two calls: a budget check before the request and a usage report after it.
Before you start: route the call through nr-llm instead
If you can, let nr-llm make the call. A call through
Llm or a feature service (see
Build your extension on nr-llm) is checked against the budget and recorded
automatically; name your extension on it with
Abstract and Analytics lists its cost under
your extension key. One path does not carry that name yet:
Translation, which hands the
options to a specialized translator such as DeepL, records the translation
as unattributed. For third-party extensions you cannot change, the
compatibility layer nr-llm-compat reroutes their
provider calls through nr-llm and names each one the same way. The two
calls on this page are for the remaining case: your extension keeps its
own provider code.
Example
The class below summarizes a text with a direct HTTP request to a provider
and wraps that request with the two calls. Both services are @api
interfaces and public in the container, so constructor injection is
enough. A functional test of nr-llm runs this exact code against the real
services and the database.
<?php
declare(strict_types=1);
namespace MyVendor\MyExtension\Service;
use Netresearch\NrLlm\Exception\BudgetExceededException;
use Netresearch\NrLlm\Service\BudgetServiceInterface;
use Netresearch\NrLlm\Service\UsageTrackerServiceInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
final readonly class SummaryGenerator
{
private const ENDPOINT = 'https://api.example.com/v1/chat/completions';
// USD per one million tokens, from your provider's price list
private const INPUT_PRICE = 2.50;
private const OUTPUT_PRICE = 10.00;
// An estimate of what one summary costs, not a cap: the
// recorded cost of a call can be higher
private const PLANNED_COST = 0.02;
public function __construct(
private BudgetServiceInterface $budget,
private UsageTrackerServiceInterface $usageTracker,
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
) {}
/**
* @param int $beUserUid the logged-in backend user, never a
* request value
*/
public function summarize(int $beUserUid, string $text): string
{
// 1. Check the editor's budget before the call
$check = $this->budget->check($beUserUid, self::PLANNED_COST);
if (!$check->allowed) {
throw new BudgetExceededException($check);
}
// 2. Your own provider call; authentication and error
// handling are left out here
$payload = json_encode([
'model' => 'example-model',
'messages' => [
['role' => 'user', 'content' => 'Summarize: ' . $text],
],
], JSON_THROW_ON_ERROR);
$request = $this->requestFactory
->createRequest('POST', self::ENDPOINT)
->withHeader('Content-Type', 'application/json')
->withBody($this->streamFactory->createStream($payload));
$body = (string) $this->httpClient->sendRequest($request)
->getBody();
$answer = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
$promptTokens = (int) $answer['usage']['prompt_tokens'];
$completionTokens = (int) $answer['usage']['completion_tokens'];
// 3. Report what the call used and what it cost
$this->usageTracker->trackUsage(
serviceType: 'chat',
provider: 'example',
metrics: [
'tokens' => $promptTokens + $completionTokens,
'promptTokens' => $promptTokens,
'completionTokens' => $completionTokens,
'cost' => ($promptTokens * self::INPUT_PRICE
+ $completionTokens * self::OUTPUT_PRICE) / 1_000_000,
],
modelId: (string) $answer['model'],
beUserUid: $beUserUid,
sourceExtension: 'my_extension',
);
return (string) $answer['choices'][0]['message']['content'];
}
}
Pass the uid of the logged-in backend user, for example
$context->get with an injected
TYPO3CMSCore. Never take it from request
parameters: the budget and the attribution would then belong to whoever
the request names.
Check the budget before the call
Budget returns a
Budget. It reads the editor's budget record (see
Per-user AI budgets) and the usage already recorded today
and this month. It does not reserve or record anything.
$beUser Uid - Required. A value of
0or less skips the per-user budget: command line and scheduler calls have none. $plannedCost - Optional, default
0.. What the coming call is expected to cost, in USD: an estimate, not a cap. Nothing stops the call from costing more, and0 trackrecords the actual cost, which can be higher — in the example, a long input text makes a more expensive call. It is added to the cost already spent before the comparison with the daily and monthly cost limit. A negative value counts asUsage () 0.. Request limits count the coming call as one request; token limits compare only the tokens already used, because the tokens of the coming call are not known yet.0 $configuration- Optional, default
null. Only for a persisted nr-llm configuration record whose own daily limits should apply as well; an extension with its own provider code usually has none. Those limits are compared with the usage recorded under the configuration's uid, so pass the same uid as$configurationtoUid track; otherwise your calls do not count against them.Usage ()
When $check->allowed is false, do not send the request.
$check->exceeded names the limit that was reached as a stable
key (a Budget constant such as
daily_), and $check->reason is an English sentence for logs.
The example throws Budget, the exception nr-llm
throws for its own calls, so code that already handles it for nr-llm calls
handles yours too. Returning a message to the editor instead works just as
well.
The check is best-effort: two requests running at the same time can both pass it before either is recorded.
Report the call after it
Usage adds the call to the
usage table tx_. Calls on the same day with the
same values for service type, provider, backend user, configuration,
model, task and source extension are added up in one row.
$serviceType - Required. The kind of call, for example
chat,complete,embed,vision,image,speechortranslation— the values nr-llm itself records. Analytics and the billing report group by it. $provider- Required. A label for the provider, shown in the provider breakdown.
$metrics- Optional, default
[]. The keystokens,prompt,Tokens completion,Tokens characters,audio,Seconds imagesandcost; a missing key counts as0.costis in USD and is stored with six decimals.tokensis not derived from the other two token counts: pass the total, because the budget's token limits and the Analytics token figures read it. $configuration,Uid $model,Uid $taskUid - Optional, default
null,0and0. Uids of nr-llm records; an extension with its own provider code usually has none. If you passed a configuration tocheck, pass its uid as() $configuration: the configuration's daily limits only count usage recorded under that uid.Uid $modelId - Optional, default
''. The model name, shown in the model breakdown. $beUser Uid - Optional, default
null: the backend user of the current request is used, or0when there is none. Pass the same uid as tocheck, so the call counts against the budget that was checked.() $countsAs Request - Optional, default
true. Passfalsefor a sub-call of an operation that reports its own request, so that the tokens and the cost are added but the request is counted once. $sourceExtension - Optional, default
''. Your extension key. Without it, the call is listed as unattributed. Longer values are cut to 64 characters.
What the administrator sees
Every reported call appears in AI > Operation > Analytics (Usage analytics):
- the per-extension table lists cost, requests and tokens under the value
of
$source(Per-extension table);Extension - the provider, model and service breakdowns use
$provider,$modelandId $service;Type - the per-user table counts the call for the backend user passed as
$be, against that user's monthly budget.User Uid
The monthly billing report (Monthly billing report)
counts the editor as active and lists the call under "Results by kind",
one row per service type, calling extension and task. A call reported
with $be 0 is listed in the row for usage without a backend
user.
The next check for the same editor includes the reported cost and
tokens, so the budget covers your extension's calls and the calls made
through nr-llm together.