LlmServiceManagerInterface
Public entry point: streamChat() / streamChatWithConfiguration() return a Generator of string chunks; chatWithTools() / chatWithToolsForConfiguration() return a CompletionResponse.
Features
Incremental SSE-style chat streaming and OpenAI-compatible tool calling behind one provider-agnostic interface, with budget, usage, and telemetry accounting on every path.
Every TYPO3 extension that wanted streamed output or function calling had to implement it per provider — parsing each vendor's SSE frames and each vendor's tool-call payload shape. nr-llm exposes both as single methods (streamChat, chatWithTools) that resolve the configured provider and translate to and from the OpenAI-compatible tool format (ADR-010), so an extension writes the integration once and works across OpenAI, Anthropic, Gemini, Ollama, OpenRouter and the rest.
Streaming used to bypass the middleware pipeline entirely: a streamed chat ran no budget pre-flight, wrote no usage row, and produced no telemetry — a live budget hole where an over-budget user could stream freely and the tokens stayed invisible. ADR-062 closes that with a dedicated streaming lifecycle so streamed calls are accounted for like every other call.
streamChat() returns a PHP Generator that yields string chunks as the provider produces them. Because a generator is lazy, it cannot be pushed through the response middleware pipeline (every middleware would fire against a not-yet-started stream). Instead a private StreamingDispatcher wraps the provider generator and runs a four-stage lifecycle: eager budget pre-flight before any generator is returned, provider selection with shallow fallback that primes each candidate up to its first yield, lazy re-yielding of each chunk while buffering the completion, and settlement (usage + telemetry) in a finally block so accounting lands on normal completion, mid-stream exception, and abandoned/early-break streams alike. Stream token counts are estimated (~4 chars/token) since the streaming adapters yield only text deltas.
Tool calling follows the OpenAI-compatible schema: you pass a list of tool definitions ({type: function, function: {name, description, parameters}}) to chatWithTools(). The response's toolCalls is a nullable list of ToolCall value objects whose arguments are already a JSON-decoded associative array. You echo the assistant turn back with ChatMessage::assistantToolCalls(), execute each requested tool, answer each by id with ChatMessage::toolResult(), and call chat() again so the model can incorporate the results. nr-llm ships 41 built-in read-only tools in 8 toggleable groups; a bounded agent loop (ToolLoopService::runLoop) drives multi-round tool execution and is gated by a fail-closed cascade — global per-tool state, per-tool group state, per-configuration allowed groups, and the per-run allow-list — where each layer can only narrow, never widen.
Reasoning-model output is separated from the answer: AbstractProvider::extractThinkingBlocks() pulls inline <think>…</think> blocks (DeepSeek, Qwen, local models) and Claude's native thinking content blocks into CompletionResponse::$thinking, so thinking is available via hasThinking() for debugging without polluting the main content.
LlmServiceManagerInterfacePublic entry point: streamChat() / streamChatWithConfiguration() return a Generator of string chunks; chatWithTools() / chatWithToolsForConfiguration() return a CompletionResponse.
StreamingCapableInterfaceProvider contract for streaming — streamChatCompletion(array $messages, array $options = []): Generator and supportsStreaming(): bool.
ToolCapableInterfaceProvider contract for tools — chatCompletionWithTools(array $messages, array $tools, array $options = []): CompletionResponse and supportsTools(): bool.
StreamingDispatcherPrivate wrapping generator that owns the streaming lifecycle: eager budget pre-flight, pre-first-chunk fallback priming, lazy re-yield, and usage/telemetry settlement in finally (ADR-062).
ToolCallReadonly value object for one tool call — id, name, and arguments as an already JSON-decoded associative array; carried in CompletionResponse::$toolCalls.
ToolLoopServiceBounded agent loop (runLoop) that runs multiple tool rounds against a configuration, intersecting each per-run allow-list with the globally enabled tool set (ToolAvailabilityService).
Both features are single calls on the injected LlmServiceManagerInterface. Streaming yields strings; tool calling returns a CompletionResponse you inspect and answer.
streamChat() returns a Generator of string chunks. Echo and flush each chunk as it arrives; budget, usage and telemetry are settled automatically when the stream ends or is abandoned.
$stream = $this->llmManager->streamChat($messages);
foreach ($stream as $chunk) {
echo $chunk;
ob_flush();
flush();
}
Each tool is a function entry with a JSON-Schema parameters object. The model decides when to call one based on the conversation.
$tools = [
[
'type' => 'function',
'function' => [
'name' => 'get_weather',
'description' => 'Get current weather for a location',
'parameters' => [
'type' => 'object',
'properties' => [
'location' => [
'type' => 'string',
'description' => 'City name',
],
'unit' => [
'type' => 'string',
'enum' => ['celsius', 'fahrenheit'],
],
],
'required' => ['location'],
],
],
],
];
toolCalls is a list of ToolCall value objects; arguments is already a decoded array. Echo the assistant turn, answer each call by its id, then call chat() again for the final answer.
use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;
$response = $this->llmManager->chatWithTools($messages, $tools);
if ($response->hasToolCalls()) {
$messages[] = ChatMessage::assistantToolCalls($response->toolCalls, $response->content);
foreach ($response->toolCalls as $toolCall) {
$result = match ($toolCall->name) {
'get_weather' => $this->getWeather($toolCall->arguments['location']),
default => throw new \RuntimeException("Unknown function: {$toolCall->name}"),
};
$messages[] = ChatMessage::toolResult($toolCall->id, json_encode($result, JSON_THROW_ON_ERROR));
}
$response = $this->llmManager->chat($messages);
}
Not every provider streams or accepts tools. Check the capability interface before using the feature to fail early rather than at the API call.
$provider = $this->llmManager->getProvider('openai');
if ($provider instanceof StreamingCapableInterface) {
// Provider supports streaming
}
The streaming dispatcher walks the configuration's fallback chain and primes candidates only up to their first yield. Once a chunk has been handed to the caller, a provider swap is impossible — so a mid-stream failure cannot be retried against another provider. This asymmetry with the non-streaming pipeline is intrinsic to streaming (ADR-062).
The streaming adapters yield only text deltas and emit no usage frame at stream end, so the dispatcher estimates tokens with a ~4 chars/token heuristic. An estimate is deliberately better than the previous silent zero for budget enforcement, but it is not the provider's exact figure. Streaming also never caches and always records cache_hit = false.
A per-run allow-list can only narrow what is globally enabled. A globally disabled tool (ToolStateRepository) or a disabled tool group can never be re-enabled by a skill or playground selection — a per-tool override cannot re-enable a tool inside a disabled group (ADR-039, ADR-043). Third-party ToolInterface implementations must implement getGroup().
chatWithTools() returns the model's requested calls; it never runs your functions. You must dispatch on $toolCall->name, execute, and return each result via ChatMessage::toolResult() before the model can produce a final answer. Treat every tool result and model output as untrusted content.