Wie es funktioniert
streamChat() gibt einen PHP-Generator zurück, der String-Chunks liefert (yield), sobald der Provider sie erzeugt. Da ein Generator lazy ist, lässt er sich nicht durch die Response-Middleware-Pipeline schieben (jede Middleware würde gegen einen noch nicht gestarteten Stream feuern). Stattdessen kapselt ein privater StreamingDispatcher den Provider-Generator und durchläuft einen vierstufigen Lebenszyklus: eine sofortige Budget-Vorprüfung, bevor überhaupt ein Generator zurückgegeben wird; die Provider-Auswahl mit flachem Fallback, die jeden Kandidaten bis zu seinem ersten yield vorbereitet; das lazy erneute Ausgeben (yield) jedes Chunks, während die Completion gepuffert wird; und die Verrechnung (Nutzung + Telemetrie) in einem finally-Block, sodass die Erfassung sowohl bei normalem Abschluss als auch bei einer Exception mitten im Stream und bei abgebrochenen bzw. früh beendeten Streams greift. Die Token-Zahlen eines Streams werden geschätzt (~4 Zeichen/Token), da die Streaming-Adapter nur Text-Deltas liefern. Jeder Stream läuft außerdem live durch den verpflichtenden Secret-Redaction-Guardrail (ADR-088): Ein gleitendes Fenster hält die letzten 128 Bytes zurück, bis weiterer Text eintrifft. Die Chunks beim Aufrufer decken sich deshalb nicht mit den Deltas des Providers. Eine Prüfung der ersten 50.000 Bytes am Stream-Ende kann einen Guardrail-Treffer protokollieren, bereits gesendeten Text aber nicht zurückholen.
Tool-Calling folgt dem OpenAI-kompatiblen Schema: Man übergibt chatWithTools() eine Liste von Tool-Definitionen ({type: function, function: {name, description, parameters}}). Das toolCalls der Antwort ist eine nullbare Liste von ToolCall-Value-Objects, deren arguments bereits ein JSON-dekodiertes assoziatives Array sind. Man gibt den Assistant-Zug mit ChatMessage::assistantToolCalls() zurück, führt jedes angeforderte Tool aus, beantwortet jeden Aufruf per id mit ChatMessage::toolResult() und ruft chat() erneut auf, damit das Modell die Ergebnisse einbeziehen kann. Bei den GPT-6-Modellen schickt der OpenAI-Adapter einen Tool-Request an die Responses API statt an chat/completions, wenn der Endpunkt der Host von OpenAI selbst ist; ein kompatibles Gateway bleibt bei chat/completions, außer die Optionen der Konfiguration setzen "openai_tools_transport": "responses" (ADR-203). Die eigenen Items des Providers für diesen Zug stehen in $response->metadata['nrllm_provider_items'] und gehen als drittes Argument an assistantToolCalls(); so behält das Modell sein Reasoning zwischen den Schritten. Die Thought-Signaturen von Gemini laufen über dasselbe Feld (ADR-222). nr-llm bringt 63 eingebaute Tools in 10 schaltbaren Gruppen mit — 45 nur lesend, dazu achtzehn schreibende, die deaktiviert ausgeliefert werden und auf eine menschliche Freigabe warten; eine begrenzte Agent-Schleife (ToolLoopService::runLoop) steuert die Tool-Ausführung über mehrere Runden und wird durch ToolCallPolicy abgesichert, ein zusammengesetztes fail-closed-Gate – globaler Zustand pro Tool, Zustand pro Tool-Gruppe, Tools nur für Admins, erlaubte Gruppen pro Konfiguration, die Allow-List pro Lauf und die Vertrauenszone des Providers der Konfiguration gegenüber der Datenklasse des Tools (ADR-094) –, wobei jede Schicht nur einschränken, nie erweitern kann.
Die Ausgabe von Reasoning-Modellen wird von der Antwort getrennt: AbstractProvider::extractThinkingBlocks() zieht inline <think>…</think>-Blöcke (DeepSeek, Qwen und andere Modelle, die sie ausgeben) aus dem Content. ClaudeProvider ergänzt Claudes native Thinking-Content-Blöcke, GeminiProvider seine Thought-Parts, und OllamaProvider liest das separate Feld message.thinking von Ollama. Alles landet in CompletionResponse::$thinking und ist über hasThinking() zum Debuggen verfügbar, ohne den eigentlichen Content zu verunreinigen. Wie viel ein Modell nachdenkt, ist eine Request-Option: ChatOptions::withReasoningEffort() nimmt einen ReasoningEffort-Case (ADR-204). Derzeit werten nur die GPT-6-Modelle von OpenAI ihn aus; der tatsächlich angewandte Effort steht in den Metadaten der Antwort.
LlmServiceManagerInterface
Öffentlicher Einstiegspunkt: streamChat() / streamChatWithConfiguration() geben einen Generator aus String-Chunks zurück; chatWithTools() / chatWithToolsForConfiguration() geben eine CompletionResponse zurück.
StreamingCapableInterface
Provider-Vertrag für Streaming – streamChatCompletion(array $messages, array $options = []): Generator und supportsStreaming(): bool.
ToolCapableInterface
Provider-Vertrag für Tools – chatCompletionWithTools(array $messages, array $tools, array $options = []): CompletionResponse und supportsTools(): bool.
StreamingDispatcher
Privater kapselnder Generator, dem der Streaming-Lebenszyklus gehört: sofortige Budget-Vorprüfung, Fallback-Vorbereitung vor dem ersten Chunk, lazy erneutes yield und Nutzungs-/Telemetrie-Verrechnung im finally (ADR-062).
ToolCall
Readonly-Value-Object für einen einzelnen Tool-Call – id, name und arguments als bereits JSON-dekodiertes assoziatives Array; wird in CompletionResponse::$toolCalls transportiert.
ToolLoopService
Begrenzte Agent-Schleife (runLoop), die mehrere Tool-Runden gegen eine Konfiguration ausführt und dabei nur die Tools anbietet, die das zusammengesetzte Gate ToolCallPolicy zulässt – die Allow-List pro Lauf geschnitten mit der global aktivierten Tool-Menge (ToolAvailabilityService) und den übrigen Prüfungen.