From d8141c151e20608343f3e5a6626f246cef6a4632 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Thu, 18 Jun 2026 11:40:54 +0530 Subject: [PATCH 01/71] Add stream to request options --- src/Providers/Http/DTO/RequestOptions.php | 46 ++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/Providers/Http/DTO/RequestOptions.php b/src/Providers/Http/DTO/RequestOptions.php index 9b101bef..aefd8a49 100644 --- a/src/Providers/Http/DTO/RequestOptions.php +++ b/src/Providers/Http/DTO/RequestOptions.php @@ -17,7 +17,8 @@ * @phpstan-type RequestOptionsArrayShape array{ * timeout?: float|null, * connectTimeout?: float|null, - * maxRedirects?: int|null + * maxRedirects?: int|null, + * stream?: bool|null * } * * @extends AbstractDataTransferObject @@ -27,6 +28,7 @@ class RequestOptions extends AbstractDataTransferObject public const KEY_TIMEOUT = 'timeout'; public const KEY_CONNECT_TIMEOUT = 'connectTimeout'; public const KEY_MAX_REDIRECTS = 'maxRedirects'; + public const KEY_STREAM = 'stream'; /** * @var float|null Maximum duration in seconds to wait for the full response. @@ -43,6 +45,11 @@ class RequestOptions extends AbstractDataTransferObject */ protected ?int $maxRedirects = null; + /** + * @var bool|null Whether to request the response body as a stream. Null is unspecified. + */ + protected ?bool $stream = null; + /** * Sets the request timeout in seconds. * @@ -153,6 +160,31 @@ public function getMaxRedirects(): ?int return $this->maxRedirects; } + /** + * Sets whether to request the response body as a stream. + * + * @since n.e.x.t + * + * @param bool $stream Whether to stream the response body. + * @return void + */ + public function setStream(bool $stream): void + { + $this->stream = $stream; + } + + /** + * Gets whether the response body should be requested as a stream. + * + * @since n.e.x.t + * + * @return bool|null True to stream, false to buffer, or null when unspecified. + */ + public function isStream(): ?bool + { + return $this->stream; + } + /** * {@inheritDoc} * @@ -176,6 +208,10 @@ public function toArray(): array $data[self::KEY_MAX_REDIRECTS] = $this->maxRedirects; } + if ($this->stream !== null) { + $data[self::KEY_STREAM] = $this->stream; + } + return $data; } @@ -200,6 +236,10 @@ public static function fromArray(array $array): self $instance->setMaxRedirects((int) $array[self::KEY_MAX_REDIRECTS]); } + if (isset($array[self::KEY_STREAM])) { + $instance->setStream((bool) $array[self::KEY_STREAM]); + } + return $instance; } @@ -228,6 +268,10 @@ public static function getJsonSchema(): array 'minimum' => 0, 'description' => 'Maximum redirects to follow. 0 disables, null is unspecified.', ], + self::KEY_STREAM => [ + 'type' => ['boolean', 'null'], + 'description' => 'Whether to request the response body as a stream.', + ], ], 'additionalProperties' => false, ]; From f00f0c8b350631bb6e36516d8be17da1de11cfa0 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Thu, 18 Jun 2026 12:10:51 +0530 Subject: [PATCH 02/71] Add stream helpers to Response --- src/Providers/Http/DTO/Response.php | 99 +++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/src/Providers/Http/DTO/Response.php b/src/Providers/Http/DTO/Response.php index 623ab770..14795616 100644 --- a/src/Providers/Http/DTO/Response.php +++ b/src/Providers/Http/DTO/Response.php @@ -4,6 +4,8 @@ namespace WordPress\AiClient\Providers\Http\DTO; +use Nyholm\Psr7\Stream; +use Psr\Http\Message\StreamInterface; use WordPress\AiClient\Common\AbstractDataTransferObject; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Providers\Http\Collections\HeadersCollection; @@ -41,9 +43,19 @@ class Response extends AbstractDataTransferObject protected HeadersCollection $headers; /** - * @var string|null The response body. + * @var string|null The response body as a string, once resolved. */ - protected ?string $body; + protected ?string $body = null; + + /** + * @var StreamInterface|null The response body stream, when the response is streamed. + */ + protected ?StreamInterface $stream = null; + + /** + * @var bool Whether the string body has been resolved from the stream. + */ + private bool $bodyResolved; /** * Constructor. @@ -52,11 +64,11 @@ class Response extends AbstractDataTransferObject * * @param int $statusCode The HTTP status code. * @param array> $headers The response headers. - * @param string|null $body The response body. + * @param string|StreamInterface|null $body The response body, as a string or a stream. * * @throws InvalidArgumentException If the status code is invalid. */ - public function __construct(int $statusCode, array $headers, ?string $body = null) + public function __construct(int $statusCode, array $headers, $body = null) { if ($statusCode < 100 || $statusCode >= 600) { throw new InvalidArgumentException('Invalid HTTP status code: ' . $statusCode); @@ -64,14 +76,24 @@ public function __construct(int $statusCode, array $headers, ?string $body = nul $this->statusCode = $statusCode; $this->headers = new HeadersCollection($headers); - $this->body = $body; + + if ($body instanceof StreamInterface) { + $this->stream = $body; + $this->bodyResolved = false; + } else { + $this->body = $body; + $this->bodyResolved = true; + } } /** - * Creates a deep clone of this response. + * Creates a copy of this response. * - * Clones the headers collection to ensure the cloned - * response is independent of the original. + * Headers are cloned so the new response can modify them independently of + * the original. + * + * The body stream is not cloned. Both responses share the same stream + * instance, so consuming it from one response also consumes it from the other. * * @since 0.4.2 */ @@ -132,17 +154,44 @@ public function getHeaderAsString(string $name): ?string } /** - * Gets the response body. + * Gets the response body as a string. + * + * When the response is streamed, this reads the stream to completion, which + * consumes it unless the stream is seekable. * * @since 0.1.0 * - * @return string|null The body. + * @return string|null The body, or null if empty. */ public function getBody(): ?string { + if (!$this->bodyResolved) { + $this->bodyResolved = true; + if ($this->stream !== null) { + $contents = $this->readStream($this->stream); + $this->body = $contents === '' ? null : $contents; + } + } + return $this->body; } + /** + * Gets the response body as a PSR-7 stream. + * + * @since n.e.x.t + * + * @return StreamInterface The body stream. + */ + public function getStream(): StreamInterface + { + if ($this->stream !== null) { + return $this->stream; + } + + return Stream::create($this->body ?? ''); + } + /** * Checks if the response has a header. * @@ -181,11 +230,12 @@ public function isSuccessful(): bool */ public function getData(): ?array { - if ($this->body === null || $this->body === '') { + $body = $this->getBody(); + if ($body === null || $body === '') { return null; } - $data = json_decode($this->body, true); + $data = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { return null; @@ -231,6 +281,9 @@ public static function getJsonSchema(): array /** * {@inheritDoc} * + * When the response is streamed, this reads the stream + * to serialize the body. + * * @since 0.1.0 * * @return ResponseArrayShape @@ -242,8 +295,9 @@ public function toArray(): array self::KEY_HEADERS => $this->headers->getAll(), ]; - if ($this->body !== null) { - $data[self::KEY_BODY] = $this->body; + $body = $this->getBody(); + if ($body !== null) { + $data[self::KEY_BODY] = $body; } return $data; @@ -267,4 +321,21 @@ public static function fromArray(array $array): self $array[self::KEY_BODY] ?? null ); } + + /** + * Reads a stream to a string, rewinding first when possible. + * + * @since n.e.x.t + * + * @param StreamInterface $stream The stream to read. + * @return string The stream contents. + */ + private function readStream(StreamInterface $stream): string + { + if ($stream->isSeekable()) { + $stream->rewind(); + } + + return $stream->getContents(); + } } From 64844fc7cb56b148ed09c6cf6ae0ef4fa302ad55 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Thu, 18 Jun 2026 12:15:10 +0530 Subject: [PATCH 03/71] Add streaming support to http transport --- src/Providers/Http/HttpTransporter.php | 35 +++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/Providers/Http/HttpTransporter.php b/src/Providers/Http/HttpTransporter.php index 00917cec..f8a63188 100644 --- a/src/Providers/Http/HttpTransporter.php +++ b/src/Providers/Http/HttpTransporter.php @@ -101,7 +101,9 @@ public function send(Request $request, ?RequestOptions $options = null): Respons ); } - return $this->convertFromPsr7Response($psr7Response); + $streaming = $mergedOptions !== null && $mergedOptions->isStream() === true; + + return $this->convertFromPsr7Response($psr7Response, $streaming); } /** @@ -145,6 +147,11 @@ private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $ $merged->setMaxRedirects($requestOptions->getMaxRedirects()); } + $requestStream = $requestOptions->isStream(); + if ($requestStream !== null) { + $merged->setStream($requestStream); + } + // Override with parameter options (higher precedence) if ($parameterOptions->getTimeout() !== null) { $merged->setTimeout($parameterOptions->getTimeout()); @@ -158,6 +165,11 @@ private function mergeOptions(?RequestOptions $requestOptions, ?RequestOptions $ $merged->setMaxRedirects($parameterOptions->getMaxRedirects()); } + $parameterStream = $parameterOptions->isStream(); + if ($parameterStream !== null) { + $merged->setStream($parameterStream); + } + return $merged; } @@ -270,6 +282,10 @@ private function buildGuzzleOptions(RequestOptions $options): array } } + if ($options->isStream() === true) { + $guzzleOptions['stream'] = true; + } + return $guzzleOptions; } @@ -313,14 +329,25 @@ private function convertToPsr7Request(Request $request): RequestInterface * @param ResponseInterface $psr7Response The PSR-7 response. * @return Response The custom response. */ - private function convertFromPsr7Response(ResponseInterface $psr7Response): Response + private function convertFromPsr7Response(ResponseInterface $psr7Response, bool $stream): Response { + /** + * PSR-7 always returns headers as arrays, but HeadersCollection handles this. + * + * @var array> $headers + */ + $headers = $psr7Response->getHeaders(); + + // When streaming, pass the body stream through so it is consumed lazily. + if ($stream) { + return new Response($psr7Response->getStatusCode(), $headers, $psr7Response->getBody()); + } + $body = (string) $psr7Response->getBody(); - // PSR-7 always returns headers as arrays, but HeadersCollection handles this return new Response( $psr7Response->getStatusCode(), - $psr7Response->getHeaders(), // @phpstan-ignore-line + $headers, $body === '' ? null : $body ); } From 3f7584344c7e986f2465df94042f2e79538b8fd0 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Thu, 18 Jun 2026 12:19:21 +0530 Subject: [PATCH 04/71] Update phpcs config to lint 20 files in parallel --- phpcs.xml.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 2e49db44..f3f26d3e 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -21,6 +21,9 @@ + + + From 998d6958fb027f2b0414f158ec25174ca7ff3e5e Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 13:21:17 +0530 Subject: [PATCH 05/71] Add interface for sse parser --- .../Contracts/EventStreamParserInterface.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php diff --git a/src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php b/src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php new file mode 100644 index 00000000..e0d08058 --- /dev/null +++ b/src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php @@ -0,0 +1,26 @@ + The decoded events. + */ + public function parse(StreamInterface $stream): iterable; +} From afa0ba0649272daaa760fcaea97738676cc60a22 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 13:21:57 +0530 Subject: [PATCH 06/71] Add DTO for SSE --- .../Http/Streaming/ServerSentEvent.php | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/Providers/Http/Streaming/ServerSentEvent.php diff --git a/src/Providers/Http/Streaming/ServerSentEvent.php b/src/Providers/Http/Streaming/ServerSentEvent.php new file mode 100644 index 00000000..f4697f6e --- /dev/null +++ b/src/Providers/Http/Streaming/ServerSentEvent.php @@ -0,0 +1,101 @@ +. + * + * @since n.e.x.t + */ +final class ServerSentEvent +{ + /** + * @var string The event name. Defaults to "message" when the stream omits it. + */ + private string $event; + + /** + * @var string The event payload. + */ + private string $data; + + /** + * @var string|null The last event ID associated with this event, if any. + */ + private ?string $id; + + /** + * @var int|null The reconnection time in milliseconds, if the stream sent one. + */ + private ?int $retry; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $event The event name. + * @param string $data The event payload. + * @param string|null $id The last event ID, or null. + * @param int|null $retry The reconnection time in milliseconds, or null. + */ + public function __construct(string $event, string $data, ?string $id = null, ?int $retry = null) + { + $this->event = $event; + $this->data = $data; + $this->id = $id; + $this->retry = $retry; + } + + /** + * Gets the event name. + * + * @since n.e.x.t + * + * @return string The event name ("message" when unspecified). + */ + public function getEvent(): string + { + return $this->event; + } + + /** + * Gets the event payload. + * + * @since n.e.x.t + * + * @return string The payload. + */ + public function getData(): string + { + return $this->data; + } + + /** + * Gets the last event ID. + * + * @since n.e.x.t + * + * @return string|null The event ID, or null. + */ + public function getId(): ?string + { + return $this->id; + } + + /** + * Gets the reconnection time in milliseconds. + * + * @since n.e.x.t + * + * @return int|null The reconnection time, or null. + */ + public function getRetry(): ?int + { + return $this->retry; + } +} From 8751105987c66889e99fe29821f621d4e8bda802 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 13:23:11 +0530 Subject: [PATCH 07/71] Add SSE spec compliant parser --- .../Http/Streaming/SseEventStreamParser.php | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 src/Providers/Http/Streaming/SseEventStreamParser.php diff --git a/src/Providers/Http/Streaming/SseEventStreamParser.php b/src/Providers/Http/Streaming/SseEventStreamParser.php new file mode 100644 index 00000000..9e65092e --- /dev/null +++ b/src/Providers/Http/Streaming/SseEventStreamParser.php @@ -0,0 +1,272 @@ + The decoded events. + */ + public function parse(StreamInterface $stream): iterable + { + $event = ''; + $data = ''; + $lastId = null; + $retry = null; + $hasData = false; + + try { + foreach ($this->toLines($stream) as $line) { + // A blank line ends the event. + if ($line === '') { + if ($hasData) { + yield $this->createEvent($event, $data, $lastId, $retry); + } + $event = ''; + $data = ''; + $retry = null; + $hasData = false; + // The last ID persists across events, so it is not reset. + continue; + } + + // Skip comment lines. + if ($line[0] === ':') { + continue; + } + + $colon = strpos($line, ':'); + if ($colon === false) { + $field = $line; + $value = ''; + } else { + $field = (string) substr($line, 0, $colon); + $value = (string) substr($line, $colon + 1); + // Strip one leading space from the value. + if (isset($value[0]) && $value[0] === ' ') { + $value = (string) substr($value, 1); + } + } + + switch ($field) { + case 'event': + $event = $value; + break; + case 'data': + $data .= $value . "\n"; + $hasData = true; + break; + case 'id': + // Ignore IDs that contain a NUL byte. + if (strpos($value, "\0") === false) { + $lastId = $value; + } + break; + case 'retry': + if ($value !== '' && ctype_digit($value)) { + $retry = (int) $value; + } + break; + default: + break; + } + } + + // Emit the last event if the stream ended without a blank line. + if ($hasData) { + yield $this->createEvent($event, $data, $lastId, $retry); + } + } finally { + $stream->close(); + } + } + + /** + * Builds an event from the accumulated field state. + * + * @since n.e.x.t + * + * @param string $event The accumulated event name. + * @param string $data The accumulated data buffer (newline-joined). + * @param string|null $id The current last event ID. + * @param int|null $retry The current reconnection time. + * @return ServerSentEvent The event. + */ + private function createEvent(string $event, string $data, ?string $id, ?int $retry): ServerSentEvent + { + return new ServerSentEvent( + $event !== '' ? $event : 'message', + $this->stripTrailingNewline($data), + $id, + $retry + ); + } + + /** + * Removes a single trailing newline from the data buffer. + * + * Each `data` field appends a newline, so the joined buffer carries a + * trailing one that is removed before the event is dispatched. + * + * @since n.e.x.t + * + * @param string $data The data buffer. + * @return string The data without a trailing newline. + */ + private function stripTrailingNewline(string $data): string + { + if ($data !== '' && $data[strlen($data) - 1] === "\n") { + return substr($data, 0, -1); + } + + return $data; + } + + /** + * Reads the stream and yields complete lines as they become available. + * + * Buffers partial lines across reads, strips a leading BOM, supports the + * `\n`, `\r\n`, and `\r` terminators (including a `\r\n` split across reads), + * and emits any trailing unterminated content once the stream ends. + * + * @since n.e.x.t + * + * @param StreamInterface $stream The response body stream. + * @return \Generator Complete lines, without terminators. + */ + private function toLines(StreamInterface $stream): \Generator + { + $buffer = ''; + $bomChecked = false; + + while (!$stream->eof()) { + $chunk = $stream->read(self::READ_CHUNK_BYTES); + if ($chunk === '') { + continue; + } + + $buffer .= $chunk; + + if (!$bomChecked) { + if (strncmp($buffer, self::BOM, 3) === 0) { + $buffer = substr($buffer, 3); + $bomChecked = true; + } elseif (strlen($buffer) >= 3) { + $bomChecked = true; + } elseif ($buffer === substr(self::BOM, 0, strlen($buffer))) { + // Might be a partial BOM, wait for more bytes. + continue; + } else { + $bomChecked = true; + } + } + + [$lines, $buffer] = $this->extractLines($buffer, false); + foreach ($lines as $line) { + yield $line; + } + } + + // Strip a BOM from a stream shorter than three bytes. + if (!$bomChecked && strncmp($buffer, self::BOM, 3) === 0) { + $buffer = substr($buffer, 3); + } + + [$lines] = $this->extractLines($buffer, true); + foreach ($lines as $line) { + yield $line; + } + } + + /** + * Splits a buffer into complete lines and the unconsumed remainder. + * + * When not at end of stream, a trailing lone `\r` is held back (it may be the + * first half of a `\r\n` arriving next) along with any final unterminated + * line. At end of stream, a trailing `\r` terminates a line and any remaining + * content is emitted as a final line. + * + * @since n.e.x.t + * + * @param string $buffer The byte buffer. + * @param bool $atEof Whether the stream has ended. + * @return array{0: list, 1: string} The complete lines and the remainder. + */ + private function extractLines(string $buffer, bool $atEof): array + { + $lines = []; + $len = strlen($buffer); + $start = 0; + $i = 0; + + while ($i < $len) { + $c = $buffer[$i]; + + if ($c === "\n") { + $lines[] = (string) substr($buffer, $start, $i - $start); + $i++; + $start = $i; + } elseif ($c === "\r") { + if ($i + 1 < $len) { + $lines[] = (string) substr($buffer, $start, $i - $start); + $i += ($buffer[$i + 1] === "\n") ? 2 : 1; + $start = $i; + } elseif ($atEof) { + $lines[] = (string) substr($buffer, $start, $i - $start); + $i++; + $start = $i; + } else { + // A trailing CR might start a split CRLF, so hold it. + break; + } + } else { + $i++; + } + } + + $remaining = (string) substr($buffer, $start); + if ($atEof && $remaining !== '') { + $lines[] = $remaining; + $remaining = ''; + } + + return [$lines, $remaining]; + } +} From 31c21707058d95c885f5977100f501516fef2151 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 14:13:42 +0530 Subject: [PATCH 08/71] Update stream id type to string --- .../Http/Streaming/ServerSentEvent.php | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Providers/Http/Streaming/ServerSentEvent.php b/src/Providers/Http/Streaming/ServerSentEvent.php index f4697f6e..8ac0a8a6 100644 --- a/src/Providers/Http/Streaming/ServerSentEvent.php +++ b/src/Providers/Http/Streaming/ServerSentEvent.php @@ -24,9 +24,9 @@ final class ServerSentEvent private string $data; /** - * @var string|null The last event ID associated with this event, if any. + * @var string The last event ID. Empty when the stream has not set one. */ - private ?string $id; + private string $id; /** * @var int|null The reconnection time in milliseconds, if the stream sent one. @@ -40,10 +40,10 @@ final class ServerSentEvent * * @param string $event The event name. * @param string $data The event payload. - * @param string|null $id The last event ID, or null. + * @param string $id The last event ID, or an empty string when none was set. * @param int|null $retry The reconnection time in milliseconds, or null. */ - public function __construct(string $event, string $data, ?string $id = null, ?int $retry = null) + public function __construct(string $event, string $data, string $id = '', ?int $retry = null) { $this->event = $event; $this->data = $data; @@ -80,19 +80,22 @@ public function getData(): string * * @since n.e.x.t * - * @return string|null The event ID, or null. + * @return string The event ID, or an empty string when none was set. */ - public function getId(): ?string + public function getId(): string { return $this->id; } /** - * Gets the reconnection time in milliseconds. + * Gets the reconnection time in milliseconds, if the event set one. + * + * Parsed for spec completeness only. The SDK does not reconnect: provider + * streams are one-shot and cannot be resumed, so this value is informational. * * @since n.e.x.t * - * @return int|null The reconnection time, or null. + * @return int|null The reconnection time, or null when none was set. */ public function getRetry(): ?int { From 8bb52fcaaa0102bc97667d60996158c9a325e367 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 14:21:20 +0530 Subject: [PATCH 09/71] Remove dispatching event if file end is reached --- .../Http/Streaming/SseEventStreamParser.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Providers/Http/Streaming/SseEventStreamParser.php b/src/Providers/Http/Streaming/SseEventStreamParser.php index 9e65092e..3e82cf70 100644 --- a/src/Providers/Http/Streaming/SseEventStreamParser.php +++ b/src/Providers/Http/Streaming/SseEventStreamParser.php @@ -48,7 +48,7 @@ public function parse(StreamInterface $stream): iterable { $event = ''; $data = ''; - $lastId = null; + $lastId = ''; $retry = null; $hasData = false; @@ -109,10 +109,13 @@ public function parse(StreamInterface $stream): iterable } } - // Emit the last event if the stream ended without a blank line. - if ($hasData) { - yield $this->createEvent($event, $data, $lastId, $retry); - } + /* + * Per the spec: + * Once the end of the file is reached, any pending data must be discarded. (If the file ends + * in the middle of an event, before the final empty line, the incomplete event is not dispatched.) + * + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation + */ } finally { $stream->close(); } @@ -125,11 +128,11 @@ public function parse(StreamInterface $stream): iterable * * @param string $event The accumulated event name. * @param string $data The accumulated data buffer (newline-joined). - * @param string|null $id The current last event ID. + * @param string $id The current last event ID. * @param int|null $retry The current reconnection time. * @return ServerSentEvent The event. */ - private function createEvent(string $event, string $data, ?string $id, ?int $retry): ServerSentEvent + private function createEvent(string $event, string $data, string $id, ?int $retry): ServerSentEvent { return new ServerSentEvent( $event !== '' ? $event : 'message', From 4ff589dffdd042c62f78770d4a10de2775c22689 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 14:22:53 +0530 Subject: [PATCH 10/71] Add mock for chunking stream --- tests/mocks/ChunkStream.php | 146 ++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/mocks/ChunkStream.php diff --git a/tests/mocks/ChunkStream.php b/tests/mocks/ChunkStream.php new file mode 100644 index 00000000..28953a61 --- /dev/null +++ b/tests/mocks/ChunkStream.php @@ -0,0 +1,146 @@ + The chunks to return, in order. + */ + private array $chunks; + + /** + * @var int Index of the next chunk to return. + */ + private int $position = 0; + + /** + * @var int Number of read() calls that returned a chunk. + */ + private int $readCount = 0; + + /** + * @var bool Whether close() has been called. + */ + private bool $closed = false; + + /** + * @param list $chunks The chunks to return, in order. + */ + public function __construct(array $chunks) + { + $this->chunks = array_values($chunks); + } + + /** + * @return int Number of read() calls that returned a chunk. + */ + public function getReadCount(): int + { + return $this->readCount; + } + + /** + * @return bool Whether the stream was closed. + */ + public function isClosed(): bool + { + return $this->closed; + } + + public function eof(): bool + { + return $this->position >= count($this->chunks); + } + + public function read(int $length): string + { + if ($this->eof()) { + return ''; + } + + $this->readCount++; + + return $this->chunks[$this->position++]; + } + + public function close(): void + { + $this->closed = true; + } + + public function __toString(): string + { + return implode('', array_slice($this->chunks, $this->position)); + } + + public function detach() + { + return null; + } + + public function getSize(): ?int + { + return null; + } + + public function tell(): int + { + return $this->position; + } + + public function isSeekable(): bool + { + return false; + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + throw new RuntimeException('Not seekable.'); + } + + public function rewind(): void + { + throw new RuntimeException('Not seekable.'); + } + + public function isWritable(): bool + { + return false; + } + + public function write(string $string): int + { + throw new RuntimeException('Not writable.'); + } + + public function isReadable(): bool + { + return true; + } + + public function getContents(): string + { + $contents = implode('', array_slice($this->chunks, $this->position)); + $this->position = count($this->chunks); + + return $contents; + } + + /** + * @param string|null $key The metadata key. + * @return array|null The metadata. + */ + public function getMetadata(?string $key = null) + { + return $key === null ? [] : null; + } +} From cf15f0b68f0bea668e88b1f5dbe6b05c14cf52a5 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 14:44:42 +0530 Subject: [PATCH 11/71] Add test cases for SseEventStreamParser --- .../Streaming/SseEventStreamParserTest.php | 496 ++++++++++++++++++ 1 file changed, 496 insertions(+) create mode 100644 tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php diff --git a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php new file mode 100644 index 00000000..0867af50 --- /dev/null +++ b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php @@ -0,0 +1,496 @@ + $chunks The byte chunks to feed to the parser. + * @return list The parsed events. + */ + private function parse(array $chunks): array + { + return iterator_to_array((new SseEventStreamParser())->parse(new ChunkStream($chunks)), false); + } + + /** + * Parses a single body string into a list of events. + * + * @param string $body The full body. + * @return list The parsed events. + */ + private function parseBody(string $body): array + { + return $this->parse([$body]); + } + + /** + * Maps events to their data payloads. + * + * @param list $events The events. + * @return list The data payloads. + */ + private function dataOf(array $events): array + { + return array_map(static fn (ServerSentEvent $e): string => $e->getData(), $events); + } + + /** + * Tests field parsing across colons, NUL bytes, casing, and line endings. + * + * @return void + */ + public function testFieldParsing(): void + { + $body = "data:\x00\n" + . "data: 2\r" + . "Data:1\n" + . "data\x00:2\n" + . "data:1\r" + . "\x00data:4\n" + . "da-ta:3\r" + . "data_5\n" + . "data:3\r" + . "data:\r\n" + . " data:32\n" + . "data:4\n" + . "\n"; + + $events = $this->parseBody($body); + + $this->assertCount(1, $events); + $this->assertSame("\x00\n 2\n1\n3\n\n4", $events[0]->getData()); + } + + /** + * Tests the data field: empty values, missing colons, and accumulation. + * + * @return void + */ + public function testDataFieldVariants(): void + { + $events = $this->parseBody("data:\n\ndata\ndata\n\ndata:test\n\n"); + + $this->assertSame(['', "\n", 'test'], $this->dataOf($events)); + } + + /** + * Tests that a custom event name is carried, and the default is "message". + * + * @return void + */ + public function testCustomEventName(): void + { + $events = $this->parseBody("event:test\ndata:x\n\ndata:x\n\n"); + + $this->assertCount(2, $events); + $this->assertSame('test', $events[0]->getEvent()); + $this->assertSame('message', $events[1]->getEvent()); + } + + /** + * Tests that an empty event field falls back to the default "message" type. + * + * @return void + */ + public function testEmptyEventNameDefaultsToMessage(): void + { + $events = $this->parseBody("event: \ndata:data\n\n"); + + $this->assertCount(1, $events); + $this->assertSame('message', $events[0]->getEvent()); + $this->assertSame('data', $events[0]->getData()); + } + + /** + * Tests that comment and unknown lines are ignored among data lines. + * + * @return void + */ + public function testCommentsIgnored(): void + { + $long = str_repeat('x', 16); + $body = "data:1\r" + . ":\x00\n" + . ":\r\n" + . "data:2\n" + . ':' . $long . "\r" + . "data:3\n" + . ":data:fail\r" + . ':' . $long . "\n" + . "data:4\n" + . "\n"; + + $events = $this->parseBody($body); + + $this->assertCount(1, $events); + $this->assertSame("1\n2\n3\n4", $events[0]->getData()); + } + + /** + * Tests that unknown fields, leading-space field names, and comments are skipped. + * + * @return void + */ + public function testUnknownFieldsIgnored(): void + { + $body = "data:test\n" + . " data\n" + . "data\n" + . "foobar:xxx\n" + . "justsometext\n" + . ":thisisacommentyay\n" + . "data:test\n" + . "\n"; + + $events = $this->parseBody($body); + + $this->assertCount(1, $events); + $this->assertSame("test\n\ntest", $events[0]->getData()); + } + + /** + * Tests that only one leading space is stripped, a tab is kept, and CR ends a line. + * + * @return void + */ + public function testLeadingSpaceStrippedOnce(): void + { + $events = $this->parseBody("data:\ttest\rdata: \ndata:test\n\n"); + + $this->assertCount(1, $events); + $this->assertSame("\ttest\n\ntest", $events[0]->getData()); + } + + /** + * Tests CRLF, LF, and a lone CR are all treated as line terminators. + * + * @return void + */ + public function testNewlineVariants(): void + { + $events = $this->parseBody("data:test\r\ndata\ndata:test\r\n\r\n"); + + $this->assertCount(1, $events); + $this->assertSame("test\n\ntest", $events[0]->getData()); + } + + /** + * Tests that a NUL byte is preserved in the data payload. + * + * @return void + */ + public function testNullCharacterInData(): void + { + $events = $this->parseBody("data:\x00\n\n"); + + $this->assertCount(1, $events); + $this->assertSame("\x00", $events[0]->getData()); + } + + /** + * Tests that multi-byte UTF-8 data is passed through unchanged. + * + * @return void + */ + public function testUtf8DataPreserved(): void + { + $events = $this->parseBody("data:ok\xE2\x80\xA6\n\n"); + + $this->assertCount(1, $events); + $this->assertSame('ok…', $events[0]->getData()); + } + + /** + * Tests that the id field sets the event ID. + * + * @return void + */ + public function testIdFieldSetsId(): void + { + $events = $this->parseBody("id:abc\ndata:x\n\n"); + + $this->assertCount(1, $events); + $this->assertSame('abc', $events[0]->getId()); + } + + /** + * Tests that an id containing a NUL byte is ignored. + * + * @dataProvider provideNulIds + * + * @param string $idValue The id field value. + * @return void + */ + public function testIdWithNulIgnored(string $idValue): void + { + $events = $this->parseBody('id:' . $idValue . "\ndata:hello\n\n"); + + $this->assertCount(1, $events); + $this->assertSame('', $events[0]->getId()); + $this->assertSame('hello', $events[0]->getData()); + } + + /** + * @return array + */ + public function provideNulIds(): array + { + return [ + 'two nulls' => ["\x00\x00"], + 'trailing null' => ["x\x00"], + 'leading null' => ["\x00x"], + 'embedded null' => ["x\x00x"], + 'space then null' => [" \x00"], + ]; + } + + /** + * Tests that the last event ID persists across events and resets on an empty id. + * + * @return void + */ + public function testIdPersistsAndResets(): void + { + $body = "id:1\ndata:1\n\n" + . "data:2\n\n" + . "id\ndata:3\n\n" + . "id:2\ndata:4\n\n"; + + $events = $this->parseBody($body); + + $ids = array_map(static fn (ServerSentEvent $e): string => $e->getId(), $events); + $this->assertSame(['1', '1', '', '2'], $ids); + $this->assertSame(['1', '2', '3', '4'], $this->dataOf($events)); + } + + /** + * Tests retry field parsing, including decimal-not-octal and bogus values. + * + * @dataProvider provideRetry + * + * @param string $body The body. + * @param int|null $expected The expected retry value. + * @return void + */ + public function testRetryField(string $body, ?int $expected): void + { + $events = $this->parseBody($body); + + $this->assertCount(1, $events); + $this->assertSame($expected, $events[0]->getRetry()); + } + + /** + * @return array + */ + public function provideRetry(): array + { + return [ + 'plain' => ["retry:3000\ndata:x\n\n", 3000], + 'leading zero is decimal not octal' => ["retry:03000\ndata:x\n\n", 3000], + 'bogus is ignored' => ["retry:1000x\ndata:x\n\n", null], + 'bogus keeps previous value' => ["retry:3000\nretry:1000x\ndata:x\n\n", 3000], + 'empty retry field' => ["retry\ndata:x\n\n", null], + ]; + } + + /** + * Tests that a leading BOM is stripped once while a mid-stream BOM is literal. + * + * @return void + */ + public function testLeadingBomStrippedOnce(): void + { + $bom = "\xEF\xBB\xBF"; + $events = $this->parseBody($bom . "data:1\n\n" . $bom . "data:2\n\ndata:3\n\n"); + + $this->assertSame(['1', '3'], $this->dataOf($events)); + } + + /** + * Tests that only the first of a double BOM is stripped. + * + * @return void + */ + public function testDoubleBomStripsOnlyOne(): void + { + $bom = "\xEF\xBB\xBF"; + $events = $this->parseBody($bom . $bom . "data:1\n\ndata:2\n\ndata:3\n\n"); + + $this->assertSame(['2', '3'], $this->dataOf($events)); + } + + /** + * Tests that an event left pending at EOF (no final blank line) is discarded. + * + * @return void + */ + public function testIncompleteFinalEventDiscarded(): void + { + $events = $this->parseBody("retry:1000\ndata:test1\n\nid:test\ndata:test2\n"); + + $this->assertCount(1, $events); + $this->assertSame('test1', $events[0]->getData()); + $this->assertSame('', $events[0]->getId()); + $this->assertSame(1000, $events[0]->getRetry()); + } + + /** + * Tests line and data parsing across a fuller mixed stream. + * + * @return void + */ + public function testMixedStreamLinesAndData(): void + { + $body = "data:msg\n" + . "data: msg\n\n" + . ":\n" + . "falsefield:msg\n\n" + . "falsefield:msg\n" + . "Data:data\n\n" + . "data\n\n" + . "data:end\n\n"; + + $events = $this->parseBody($body); + + $this->assertSame(["msg\nmsg", '', 'end'], $this->dataOf($events)); + } + + /** + * Tests that an empty stream yields no events. + * + * @return void + */ + public function testEmptyStream(): void + { + $this->assertSame([], $this->parse([])); + $this->assertSame([], $this->parseBody('')); + } + + /** + * Tests that a frame split across reads is reassembled. + * + * @return void + */ + public function testFrameSplitAcrossChunks(): void + { + $events = $this->parse(['data: hel', 'lo wor', "ld\n", "\n"]); + + $this->assertCount(1, $events); + $this->assertSame('hello world', $events[0]->getData()); + } + + /** + * Tests a CRLF terminator split across two reads (CR ends one chunk, LF starts the next). + * + * @return void + */ + public function testCrlfSplitAcrossChunks(): void + { + $events = $this->parse(["data:x\r", "\n\r\n"]); + + $this->assertCount(1, $events); + $this->assertSame('x', $events[0]->getData()); + } + + /** + * Tests a lone CR separating two data lines within one event. + * + * @return void + */ + public function testLoneCrSeparatesDataLines(): void + { + $events = $this->parseBody("data: a\rdata: b\n\n"); + + $this->assertCount(1, $events); + $this->assertSame("a\nb", $events[0]->getData()); + } + + /** + * Tests a multi-byte UTF-8 character split across reads. + * + * @return void + */ + public function testMultibyteUtf8SplitAcrossChunks(): void + { + $events = $this->parse(["data:a\xE2\x80", "\xA6b\n\n"]); + + $this->assertCount(1, $events); + $this->assertSame('a…b', $events[0]->getData()); + } + + /** + * Tests a BOM split across reads is still stripped. + * + * @return void + */ + public function testBomSplitAcrossChunks(): void + { + $events = $this->parse(["\xEF", "\xBB\xBF", "data:x\n\n"]); + + $this->assertCount(1, $events); + $this->assertSame('x', $events[0]->getData()); + } + + /** + * Tests that the stream is closed after it is fully consumed. + * + * @return void + */ + public function testStreamClosedAfterConsumption(): void + { + $stream = new ChunkStream(["data:x\n\n"]); + iterator_to_array((new SseEventStreamParser())->parse($stream), false); + + $this->assertTrue($stream->isClosed()); + } + + /** + * Tests that the stream is closed when iteration stops early. + * + * @return void + */ + public function testStreamClosedOnEarlyAbandon(): void + { + $stream = new ChunkStream(["data:1\n\ndata:2\n\n"]); + + foreach ((new SseEventStreamParser())->parse($stream) as $event) { + break; + } + + $this->assertTrue($stream->isClosed()); + } + + /** + * Tests that events are produced lazily as the stream is read. + * + * @return void + */ + public function testLazyConsumption(): void + { + $stream = new ChunkStream(["data:1\n\n", "data:2\n\n"]); + $generator = (new SseEventStreamParser())->parse($stream); + + $this->assertSame('1', $generator->current()->getData()); + $this->assertSame(1, $stream->getReadCount()); + + $generator->next(); + $this->assertSame('2', $generator->current()->getData()); + $this->assertSame(2, $stream->getReadCount()); + } +} From cbb11c25cfaee6934d76105131d6a2466231a97c Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 15:04:09 +0530 Subject: [PATCH 12/71] Fix aliases order --- tests/mocks/ChunkStream.php | 2 +- .../unit/Providers/Http/Streaming/SseEventStreamParserTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/mocks/ChunkStream.php b/tests/mocks/ChunkStream.php index 28953a61..8e279fdb 100644 --- a/tests/mocks/ChunkStream.php +++ b/tests/mocks/ChunkStream.php @@ -4,8 +4,8 @@ namespace WordPress\AiClient\Tests\mocks; -use RuntimeException; use Psr\Http\Message\StreamInterface; +use RuntimeException; /** * A read-only stream that returns a fixed list of chunks, one per read(). diff --git a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php index 0867af50..6c0f579e 100644 --- a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php +++ b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php @@ -5,9 +5,9 @@ namespace WordPress\AiClient\Tests\unit\Providers\Http\Streaming; use PHPUnit\Framework\TestCase; -use WordPress\AiClient\Tests\mocks\ChunkStream; use WordPress\AiClient\Providers\Http\Streaming\ServerSentEvent; use WordPress\AiClient\Providers\Http\Streaming\SseEventStreamParser; +use WordPress\AiClient\Tests\mocks\ChunkStream; /** * Test for SseEventStreamParser class. From f67deb4387b905df64ae11596c65bd42d67c7049 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 15:28:06 +0530 Subject: [PATCH 13/71] Remove dead code after adding spec driven stream discard if it doesn't ends with newline --- .../Http/Streaming/SseEventStreamParser.php | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/src/Providers/Http/Streaming/SseEventStreamParser.php b/src/Providers/Http/Streaming/SseEventStreamParser.php index 3e82cf70..283114b5 100644 --- a/src/Providers/Http/Streaming/SseEventStreamParser.php +++ b/src/Providers/Http/Streaming/SseEventStreamParser.php @@ -136,32 +136,12 @@ private function createEvent(string $event, string $data, string $id, ?int $retr { return new ServerSentEvent( $event !== '' ? $event : 'message', - $this->stripTrailingNewline($data), + (string) substr($data, 0, -1), // data always ends with a newline, so drop it. $id, $retry ); } - /** - * Removes a single trailing newline from the data buffer. - * - * Each `data` field appends a newline, so the joined buffer carries a - * trailing one that is removed before the event is dispatched. - * - * @since n.e.x.t - * - * @param string $data The data buffer. - * @return string The data without a trailing newline. - */ - private function stripTrailingNewline(string $data): string - { - if ($data !== '' && $data[strlen($data) - 1] === "\n") { - return substr($data, 0, -1); - } - - return $data; - } - /** * Reads the stream and yields complete lines as they become available. * @@ -207,11 +187,6 @@ private function toLines(StreamInterface $stream): \Generator } } - // Strip a BOM from a stream shorter than three bytes. - if (!$bomChecked && strncmp($buffer, self::BOM, 3) === 0) { - $buffer = substr($buffer, 3); - } - [$lines] = $this->extractLines($buffer, true); foreach ($lines as $line) { yield $line; From 127ddadeb2d5b9132765606723bb0d6dd0e5298c Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 15:29:43 +0530 Subject: [PATCH 14/71] Add test cases for unexpected body endings --- .../Streaming/SseEventStreamParserTest.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php index 6c0f579e..8ec52737 100644 --- a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php +++ b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php @@ -447,6 +447,45 @@ public function testBomSplitAcrossChunks(): void $this->assertSame('x', $events[0]->getData()); } + /** + * Tests that a final line without a terminator is parsed then discarded at EOF. + * + * @return void + */ + public function testFinalLineWithoutTerminatorIsDiscarded(): void + { + $events = $this->parseBody("data:x\n\ndata:y"); + + $this->assertCount(1, $events); + $this->assertSame('x', $events[0]->getData()); + } + + /** + * Tests that a final line ending in a lone CR at EOF is parsed then discarded. + * + * @return void + */ + public function testFinalLineEndingWithLoneCrIsDiscarded(): void + { + $events = $this->parseBody("data:x\n\ndata:y\r"); + + $this->assertCount(1, $events); + $this->assertSame('x', $events[0]->getData()); + } + + /** + * Tests a first read shorter than the BOM that is not a BOM prefix. + * + * @return void + */ + public function testFirstReadShorterThanBomPrefix(): void + { + $events = $this->parse(['d', "ata:x\n\n"]); + + $this->assertCount(1, $events); + $this->assertSame('x', $events[0]->getData()); + } + /** * Tests that the stream is closed after it is fully consumed. * From 2fc0ab9b6778b41c9f7dfac0268b69a825c9f85e Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 16:43:10 +0530 Subject: [PATCH 15/71] Add link to PHP_SOCK_CHUNK_SIZE default size --- src/Providers/Http/Streaming/SseEventStreamParser.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Providers/Http/Streaming/SseEventStreamParser.php b/src/Providers/Http/Streaming/SseEventStreamParser.php index 283114b5..6666d3ff 100644 --- a/src/Providers/Http/Streaming/SseEventStreamParser.php +++ b/src/Providers/Http/Streaming/SseEventStreamParser.php @@ -25,6 +25,8 @@ final class SseEventStreamParser implements EventStreamParserInterface /** * Number of bytes to read from the stream per iteration. * + * @see https://github.com/php/php-src/blob/e71b4e592864cfefe15f6861c6b477d89aec2f36/main/php_network.h#L252 + * * @var int */ private const READ_CHUNK_BYTES = 8192; From a42a4c1a27818b5bf7ca238b43b8039901f76a93 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 16:43:21 +0530 Subject: [PATCH 16/71] Update `@see` usage --- src/Providers/Http/Streaming/ServerSentEvent.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/Http/Streaming/ServerSentEvent.php b/src/Providers/Http/Streaming/ServerSentEvent.php index 8ac0a8a6..e727fc41 100644 --- a/src/Providers/Http/Streaming/ServerSentEvent.php +++ b/src/Providers/Http/Streaming/ServerSentEvent.php @@ -7,7 +7,7 @@ /** * Represents a single decoded event from a Server-Sent Events stream. * - * @see . + * @see https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation * * @since n.e.x.t */ From 9781d4edc0834003d68f30d634e42eac2e5111c7 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Fri, 19 Jun 2026 17:09:48 +0530 Subject: [PATCH 17/71] Add test case for streams having no terminating end line --- .../Http/Streaming/SseEventStreamParserTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php index 8ec52737..e1e5a339 100644 --- a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php +++ b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php @@ -473,6 +473,20 @@ public function testFinalLineEndingWithLoneCrIsDiscarded(): void $this->assertSame('x', $events[0]->getData()); } + /** + * Tests that a sole event with no terminating blank line yields nothing. + * + * Some SSE parsers emit the final event even when its terminator is missing. + * The WHATWG spec discards an event left pending at EOF, so a stream whose + * only event is never closed produces no events at all. + * + * @return void + */ + public function testSoleIncompleteEventYieldsNothing(): void + { + $this->assertSame([], $this->parseBody('data:hello')); + } + /** * Tests a first read shorter than the BOM that is not a BOM prefix. * From ecd6235404bbace7af95408331928f0e7bb21fbf Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 00:06:05 +0530 Subject: [PATCH 18/71] Add interface for text generation models with streaming support --- .../StreamingTextGenerationModelInterface.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/Providers/Models/TextGeneration/Contracts/StreamingTextGenerationModelInterface.php diff --git a/src/Providers/Models/TextGeneration/Contracts/StreamingTextGenerationModelInterface.php b/src/Providers/Models/TextGeneration/Contracts/StreamingTextGenerationModelInterface.php new file mode 100644 index 00000000..bfd58589 --- /dev/null +++ b/src/Providers/Models/TextGeneration/Contracts/StreamingTextGenerationModelInterface.php @@ -0,0 +1,26 @@ + $prompt Array of messages containing the text generation prompt. + * @return StreamedGenerativeAiResult The streamed result. + */ + public function streamGenerateTextResult(array $prompt): StreamedGenerativeAiResult; +} From 5e3c0d9bdce0fa85b143db6a5f0715f747f0e975 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 00:08:19 +0530 Subject: [PATCH 19/71] Add immutable class to hold generative ai result chunk --- .../ValueObjects/GenerativeAiResultChunk.php | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 src/Results/ValueObjects/GenerativeAiResultChunk.php diff --git a/src/Results/ValueObjects/GenerativeAiResultChunk.php b/src/Results/ValueObjects/GenerativeAiResultChunk.php new file mode 100644 index 00000000..c7c46a98 --- /dev/null +++ b/src/Results/ValueObjects/GenerativeAiResultChunk.php @@ -0,0 +1,175 @@ +candidateIndex = $candidateIndex; + $this->parts = $parts; + $this->finishReason = $finishReason; + $this->tokenUsage = $tokenUsage; + $this->id = $id; + } + + /** + * Gets the index of the candidate this chunk contributes to. + * + * @since n.e.x.t + * + * @return int|null The candidate index, or null when the chunk carries only result-level metadata. + */ + public function getCandidateIndex(): ?int + { + return $this->candidateIndex; + } + + /** + * Gets the partial content parts. + * + * @since n.e.x.t + * + * @return MessagePart[] The content parts. + */ + public function getParts(): array + { + return $this->parts; + } + + /** + * Gets delta text of this chunk's content channel. + * + * @since n.e.x.t + * + * @return string The content text delta, or an empty string when this chunk carries none. + */ + public function getDeltaText(): string + { + return $this->deltaTextForChannel(MessagePartChannelEnum::content()); + } + + /** + * Gets delta text of this chunk's reasoning (thought) channel. + * + * @since n.e.x.t + * + * @return string The reasoning text delta, or an empty string when this chunk carries none. + */ + public function getReasoningDeltaText(): string + { + return $this->deltaTextForChannel(MessagePartChannelEnum::thought()); + } + + /** + * Concatenates the delta text of this chunk's parts on the given channel. + * + * @since n.e.x.t + * + * @param MessagePartChannelEnum $channel The channel to read. + * @return string The concatenated delta text, or an empty string when there is none. + */ + private function deltaTextForChannel(MessagePartChannelEnum $channel): string + { + $text = ''; + foreach ($this->parts as $part) { + if ($part->getChannel()->is($channel) && $part->getText() !== null) { + $text .= $part->getText(); + } + } + + return $text; + } + + /** + * Gets the finish reason. + * + * @since n.e.x.t + * + * @return FinishReasonEnum|null The finish reason, or null when not reported by this chunk. + */ + public function getFinishReason(): ?FinishReasonEnum + { + return $this->finishReason; + } + + /** + * Gets the token usage. + * + * @since n.e.x.t + * + * @return TokenUsage|null The token usage, or null when not reported by this chunk. + */ + public function getTokenUsage(): ?TokenUsage + { + return $this->tokenUsage; + } + + /** + * Gets the result id. + * + * @since n.e.x.t + * + * @return string|null The id, or null when not reported by this chunk. + */ + public function getId(): ?string + { + return $this->id; + } +} From bf803a703a9f4ef42360498e2902d1c03765c33e Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 00:46:25 +0530 Subject: [PATCH 20/71] Add streamed result aggregator --- src/Results/StreamedGenerativeAiResult.php | 334 +++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 src/Results/StreamedGenerativeAiResult.php diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php new file mode 100644 index 00000000..5448f161 --- /dev/null +++ b/src/Results/StreamedGenerativeAiResult.php @@ -0,0 +1,334 @@ + + */ +final class StreamedGenerativeAiResult implements IteratorAggregate +{ + private ModelMetadata $modelMetadata; + + private ProviderMetadata $providerMetadata; + + /** + * @var iterable The source chunk stream. + */ + private iterable $chunks; + + /** + * @var Iterator|null The lazily created source iterator. + */ + private ?Iterator $iterator = null; + + /** + * @var bool Whether the source stream has been fully read. + */ + private bool $finished = false; + + /** + * @var GenerativeAiResult|null The assembled result, once built. + */ + private ?GenerativeAiResult $result = null; + + /** + * @var string|null The result id, captured from the first chunk that carries one. + */ + private ?string $id = null; + + /** + * @var TokenUsage|null The token usage, captured from the chunk that carries it. + */ + private ?TokenUsage $tokenUsage = null; + + /** + * @var array Candidate indices that carried content or a finish reason. + */ + private array $candidates = []; + + /** + * @var array> Accumulated text, per candidate, per channel. + */ + private array $text = []; + + /** + * @var array> Thought signature, per candidate, per channel. + */ + private array $thoughtSignatures = []; + + /** + * @var array> Non-text parts, per candidate, in arrival order. + */ + private array $otherParts = []; + + /** + * @var array Finish reason, per candidate. + */ + private array $finishReasons = []; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param iterable $chunks The source chunk stream. + * @param ProviderMetadata $providerMetadata Provider metadata for the assembled result. + * @param ModelMetadata $modelMetadata Model metadata for the assembled result. + */ + public function __construct(iterable $chunks, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata) + { + $this->chunks = $chunks; + $this->providerMetadata = $providerMetadata; + $this->modelMetadata = $modelMetadata; + } + + /** + * Yields each chunk as it is read, folding it into the accumulated state. + * + * @since n.e.x.t + * + * @return Generator The chunks, in order. + */ + public function getIterator(): Generator + { + while (true) { + $chunk = $this->pull(); + if ($chunk === null) { + break; + } + yield $chunk; + } + } + + /** + * Returns the complete result, draining any unread chunks first. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The assembled result. + * @throws RuntimeException If the stream produced no candidates. + */ + public function getFinalResult(): GenerativeAiResult + { + if ($this->result === null) { + while ($this->pull() !== null) { + // Drain any remaining chunks so the result is complete. + } + $this->result = $this->buildResult(); + } + + return $this->result; + } + + /** + * Reads the next chunk from the source and folds it into the accumulated state. + * + * @since n.e.x.t + * + * @return GenerativeAiResultChunk|null The next chunk, or null when the stream is exhausted. + */ + private function pull(): ?GenerativeAiResultChunk + { + if ($this->finished) { + return null; + } + + if ($this->iterator === null) { + $this->iterator = $this->toIterator($this->chunks); + $this->iterator->rewind(); + } + + $iterator = $this->iterator; + + if (!$iterator->valid()) { + $this->finished = true; + return null; + } + + $chunk = $iterator->current(); + $this->accumulate($chunk); + $iterator->next(); + + return $chunk; + } + + /** + * Folds a single chunk into the accumulated state. + * + * @since n.e.x.t + * + * @param GenerativeAiResultChunk $chunk The chunk to fold in. + * @return void + */ + private function accumulate(GenerativeAiResultChunk $chunk): void + { + $id = $chunk->getId(); + if ($id !== null && $this->id === null) { + $this->id = $id; + } + + $tokenUsage = $chunk->getTokenUsage(); + if ($tokenUsage !== null) { + $this->tokenUsage = $tokenUsage; + } + + // A chunk with no candidate index carries only result-level metadata, such + // as the final usage event, so it registers no candidate. + $index = $chunk->getCandidateIndex(); + if ($index === null) { + return; + } + + $this->candidates[$index] = true; + + $finishReason = $chunk->getFinishReason(); + if ($finishReason !== null) { + $this->finishReasons[$index] = $finishReason; + } + + foreach ($chunk->getParts() as $part) { + $this->addPart($index, $part); + } + } + + /** + * Folds a content part into the candidate state. + * + * Text parts are concatenated per channel, so the final message has one part + * per channel, matching a non-streamed response. Non-text parts are kept whole. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @param MessagePart $part The part to fold in. + * @return void + */ + private function addPart(int $index, MessagePart $part): void + { + $text = $part->getText(); + if ($text === null) { + $this->otherParts[$index][] = $part; + return; + } + + $channel = $part->getChannel()->value; + if (!isset($this->text[$index][$channel])) { + $this->text[$index][$channel] = ''; + } + $this->text[$index][$channel] .= $text; + + $signature = $part->getThoughtSignature(); + if ($signature !== null) { + $this->thoughtSignatures[$index][$channel] = $signature; + } + } + + /** + * Assembles the accumulated state into a result. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The assembled result. + * @throws RuntimeException If no candidates were produced. + */ + private function buildResult(): GenerativeAiResult + { + if ($this->candidates === []) { + throw new RuntimeException('The stream produced no candidates.'); + } + + $indices = array_keys($this->candidates); + sort($indices); + + $candidates = []; + foreach ($indices as $index) { + $candidates[] = $this->buildCandidate($index); + } + + return new GenerativeAiResult( + $this->id ?? '', + $candidates, + $this->tokenUsage ?? new TokenUsage(0, 0, 0), + $this->providerMetadata, + $this->modelMetadata + ); + } + + /** + * Builds a single candidate from its accumulated state. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @return Candidate The assembled candidate. + */ + private function buildCandidate(int $index): Candidate + { + $parts = []; + + // Text parts, in the order their channels first appeared in the stream. + foreach ($this->text[$index] ?? [] as $channel => $text) { + $parts[] = new MessagePart( + $text, + MessagePartChannelEnum::from($channel), + $this->thoughtSignatures[$index][$channel] ?? null + ); + } + + foreach ($this->otherParts[$index] ?? [] as $part) { + $parts[] = $part; + } + + $message = new Message(MessageRoleEnum::model(), $parts); + $finishReason = $this->finishReasons[$index] ?? FinishReasonEnum::stop(); + + return new Candidate($message, $finishReason); + } + + /** + * Normalizes an iterable into an Iterator the result can pull from. + * + * @since n.e.x.t + * + * @param iterable $chunks The source chunk stream. + * @return Iterator The normalized iterator. + */ + private function toIterator(iterable $chunks): Iterator + { + if ($chunks instanceof Iterator) { + return $chunks; + } + + if ($chunks instanceof IteratorAggregate) { + $inner = $chunks->getIterator(); + return $inner instanceof Iterator ? $inner : new IteratorIterator($inner); + } + + /** @var array $chunks */ + return new ArrayIterator($chunks); + } +} From 90a9cb3da3beab62f0259a88a767bf938c3a2472 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 00:54:22 +0530 Subject: [PATCH 21/71] Add text streaming support in prompt builder --- src/Builders/PromptBuilder.php | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 130fc574..932cd736 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -26,11 +26,13 @@ use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\StreamingTextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\StreamedGenerativeAiResult; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\FunctionResponse; use WordPress\AiClient\Tools\DTO\WebSearch; @@ -1048,6 +1050,53 @@ public function generateTextResult(): GenerativeAiResult return $this->generateResult(CapabilityEnum::textGeneration()); } + /** + * Streams a text result from the prompt. + * + * @since n.e.x.t + * + * @return StreamedGenerativeAiResult The streamed result. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model does not support streaming text generation. + */ + public function streamGenerateTextResult(): StreamedGenerativeAiResult + { + $this->includeOutputModalities(ModalityEnum::text()); + $this->validateMessages(); + + $model = $this->getConfiguredModel(CapabilityEnum::textGeneration()); + + if (!$model instanceof StreamingTextGenerationModelInterface) { + throw new RuntimeException( + sprintf( + 'Model "%s" does not support streaming text generation.', + $model->metadata()->getId() + ) + ); + } + + return $model->streamGenerateTextResult($this->messages); + } + + /** + * Streams generated text from the prompt as it arrives. + * + * @since n.e.x.t + * + * @return iterable The text deltas, in order. + * @throws InvalidArgumentException If the prompt or model validation fails. + * @throws RuntimeException If the model does not support streaming text generation. + */ + public function streamGenerateText(): iterable + { + foreach ($this->streamGenerateTextResult() as $chunk) { + $delta = $chunk->getDeltaText(); + if ($delta !== '') { + yield $delta; + } + } + } + /** * Generates an image result from the prompt. * From 958d30f441268df56ab14d18f04e024fc76424b2 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 01:07:16 +0530 Subject: [PATCH 22/71] Add text stream result API on client --- src/AiClient.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/AiClient.php b/src/AiClient.php index ebfeec75..623b78c0 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -15,6 +15,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\StreamedGenerativeAiResult; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -290,6 +291,33 @@ public static function generateTextResult( return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); } + /** + * Streams a text result using the traditional API approach. + * + * Iterate the returned object to consume chunks as they arrive, then call + * its getFinalResult() for the complete result. + * + * @since n.e.x.t + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. + * @return StreamedGenerativeAiResult The streamed result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found or it does not support streaming. + */ + public static function streamGenerateTextResult( + $prompt, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): StreamedGenerativeAiResult { + self::validateModelOrConfigParameter($modelOrConfig); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->streamGenerateTextResult(); + } + /** * Generates an image using the traditional API approach. * From d4eb3777f91b763675bf20ab23f3071e3f9fe285 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 01:15:52 +0530 Subject: [PATCH 23/71] Add streaming to openai compat model --- ...actOpenAiCompatibleTextGenerationModel.php | 189 +++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index e0e2e71b..da1c490b 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -13,15 +13,21 @@ use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel; use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; use WordPress\AiClient\Providers\Http\Exception\ResponseException; +use WordPress\AiClient\Providers\Http\Streaming\Contracts\EventStreamParserInterface; +use WordPress\AiClient\Providers\Http\Streaming\SseEventStreamParser; use WordPress\AiClient\Providers\Http\Util\ResponseUtil; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\StreamingTextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Results\StreamedGenerativeAiResult; +use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; @@ -62,9 +68,26 @@ * choices?: list, * usage?: UsageData * } + * @phpstan-type StreamDeltaData array{ + * role?: string, + * reasoning_content?: string, + * reasoning?: string, + * content?: string + * } + * @phpstan-type StreamChoiceData array{ + * index?: int, + * delta?: StreamDeltaData, + * finish_reason?: string|null + * } + * @phpstan-type StreamEventData array{ + * id?: string, + * choices?: list, + * usage?: UsageData + * } */ abstract class AbstractOpenAiCompatibleTextGenerationModel extends AbstractApiBasedModel implements - TextGenerationModelInterface + TextGenerationModelInterface, + StreamingTextGenerationModelInterface { /** * {@inheritDoc} @@ -93,6 +116,170 @@ final public function generateTextResult(array $prompt): GenerativeAiResult return $this->parseResponseToGenerativeAiResult($response); } + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + final public function streamGenerateTextResult(array $prompt): StreamedGenerativeAiResult + { + return new StreamedGenerativeAiResult( + $this->streamTextChunks($prompt), + $this->providerMetadata(), + $this->metadata() + ); + } + + /** + * Sends the request and yields result chunks as the event stream arrives. + * + * @since n.e.x.t + * + * @param list $prompt The prompt to generate text for. + * @return iterable The result chunks as they arrive. + */ + private function streamTextChunks(array $prompt): iterable + { + $httpTransporter = $this->getHttpTransporter(); + + $params = $this->prepareGenerateTextParams($prompt); + $params['stream'] = true; + $params['stream_options'] = ['include_usage' => true]; + + $request = $this->createRequest( + HttpMethodEnum::POST(), + 'chat/completions', + ['Content-Type' => 'application/json'], + $params + ); + + $request = $this->getRequestAuthentication()->authenticateRequest($request); + + // Tell the transporter to stream the response body instead of buffering it. + $streamOptions = new RequestOptions(); + $streamOptions->setStream(true); + + $response = $httpTransporter->send($request, $streamOptions); + $this->throwIfNotSuccessful($response); + + foreach ($this->getEventStreamParser()->parse($response->getStream()) as $event) { + $data = $event->getData(); + + // The provider marks the end of the stream with a "[DONE]" sentinel. + if ($data === '' || $data === '[DONE]') { + continue; + } + + $decoded = json_decode($data, true); + if (!is_array($decoded)) { + continue; + } + + /** @var StreamEventData $decoded */ + $chunks = $this->parseStreamEventToChunks($decoded); + foreach ($chunks as $chunk) { + yield $chunk; + } + } + } + + /** + * Maps one decoded stream event into result chunks, one per choice. + * + * @since n.e.x.t + * + * @param StreamEventData $data The decoded event payload. + * @return iterable The chunks for this event. + */ + protected function parseStreamEventToChunks(array $data): iterable + { + $id = isset($data['id']) && is_string($data['id']) ? $data['id'] : null; + $tokenUsage = isset($data['usage']) && is_array($data['usage']) + ? $this->parseUsageData($data['usage']) + : null; + $choices = isset($data['choices']) && is_array($data['choices']) ? $data['choices'] : []; + + if ($choices === []) { + // Events with no choices (such as the final usage event) carry only result metadata. + if ($tokenUsage !== null || $id !== null) { + yield new GenerativeAiResultChunk(null, [], null, $tokenUsage, $id); + } + return; + } + + foreach ($choices as $choice) { + $index = isset($choice['index']) && is_int($choice['index']) ? $choice['index'] : 0; + $delta = isset($choice['delta']) && is_array($choice['delta']) ? $choice['delta'] : []; + $finishReason = isset($choice['finish_reason']) && is_string($choice['finish_reason']) + ? FinishReasonEnum::tryFrom($choice['finish_reason']) + : null; + + yield new GenerativeAiResultChunk( + $index, + $this->parseStreamDeltaParts($delta), + $finishReason, + $tokenUsage, + $id + ); + } + } + + /** + * Maps a streamed delta into message parts. + * + * @since n.e.x.t + * + * @param StreamDeltaData $delta The delta payload from a choice. + * @return MessagePart[] The parsed message parts. + */ + protected function parseStreamDeltaParts(array $delta): array + { + $parts = []; + + if (isset($delta['reasoning_content']) && is_string($delta['reasoning_content'])) { + $parts[] = new MessagePart($delta['reasoning_content'], MessagePartChannelEnum::thought()); + } + + if (isset($delta['reasoning']) && is_string($delta['reasoning'])) { + $parts[] = new MessagePart($delta['reasoning'], MessagePartChannelEnum::thought()); + } + + if (isset($delta['content']) && is_string($delta['content'])) { + $parts[] = new MessagePart($delta['content']); + } + + return $parts; + } + + /** + * Parses usage data into a token usage object. + * + * @since n.e.x.t + * + * @param UsageData $usage The usage payload. + * @return TokenUsage The parsed token usage. + */ + protected function parseUsageData(array $usage): TokenUsage + { + return new TokenUsage( + $usage['prompt_tokens'] ?? 0, + $usage['completion_tokens'] ?? 0, + $usage['total_tokens'] ?? 0 + ); + } + + /** + * Returns the parser used to decode the streamed event stream. + * + * @since n.e.x.t + * + * @return EventStreamParserInterface The event stream parser. + */ + protected function getEventStreamParser(): EventStreamParserInterface + { + return new SseEventStreamParser(); + } + /** * Prepares the given prompt and the model configuration into parameters for the API request. * From 76737ee262f84d0a7657c1a8051341668a18b1c6 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 01:17:12 +0530 Subject: [PATCH 24/71] Add stream-text support to cli --- cli.php | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/cli.php b/cli.php index 1ec21f30..1e56ca72 100755 --- a/cli.php +++ b/cli.php @@ -10,6 +10,9 @@ * OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai * GOOGLE_API_KEY=123456 OPENAI_API_KEY=123456 php cli.php 'Your prompt here' * + * To stream the response as it arrives, use --outputFormat=stream-text: + * OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai --outputFormat=stream-text + * * For large prompts (e.g., with images), use stdin or file input: * cat prompt.json | php cli.php - --providerId=openai --modelId=gpt-4o * php cli.php @prompt.json --providerId=openai --modelId=gpt-4o @@ -190,7 +193,15 @@ static function ($item) { } try { - if ($outputFormat === 'image-json' || $outputFormat === 'image-base64') { + if ($outputFormat === 'stream-text') { + $stream = $promptBuilder->streamGenerateTextResult(); + foreach ($stream as $chunk) { + echo $chunk->getDeltaText(); + flush(); + } + echo PHP_EOL; + $result = $stream->getFinalResult(); + } elseif ($outputFormat === 'image-json' || $outputFormat === 'image-base64') { $result = $promptBuilder->generateImageResult(); } else { $result = $promptBuilder->generateTextResult(); @@ -204,7 +215,11 @@ static function ($item) { logInfo("Using provider ID: \"{$result->getProviderMetadata()->getId()}\""); logInfo("Using model ID: \"{$result->getModelMetadata()->getId()}\""); +$output = null; switch ($outputFormat) { + case 'stream-text': + // The text was already streamed to stdout above. + break; case 'result-json': $output = json_encode($result, JSON_PRETTY_PRINT); break; @@ -222,4 +237,6 @@ static function ($item) { $output = $result->toText(); } -printOutput($output); +if (is_string($output)) { + printOutput($output); +} From d7502ed8683005919ea38326da1a0b8d4db38c93 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 02:00:22 +0530 Subject: [PATCH 25/71] Add completion callback support --- src/Results/StreamedGenerativeAiResult.php | 51 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php index 5448f161..b3c98df1 100644 --- a/src/Results/StreamedGenerativeAiResult.php +++ b/src/Results/StreamedGenerativeAiResult.php @@ -50,6 +50,11 @@ final class StreamedGenerativeAiResult implements IteratorAggregate */ private bool $finished = false; + /** + * @var list Callbacks run once when the result is assembled. + */ + private array $completionCallbacks = []; + /** * @var GenerativeAiResult|null The assembled result, once built. */ @@ -106,6 +111,21 @@ public function __construct(iterable $chunks, ProviderMetadata $providerMetadata $this->modelMetadata = $modelMetadata; } + /** + * Registers a callback to run once, when the final result is first assembled. + * + * @since n.e.x.t + * + * @param callable(GenerativeAiResult): void $callback Receives the assembled result. + * @return self + */ + public function onComplete(callable $callback): self + { + $this->completionCallbacks[] = $callback; + + return $this; + } + /** * Yields each chunk as it is read, folding it into the accumulated state. * @@ -122,6 +142,8 @@ public function getIterator(): Generator } yield $chunk; } + + $this->finalize(); } /** @@ -138,12 +160,39 @@ public function getFinalResult(): GenerativeAiResult while ($this->pull() !== null) { // Drain any remaining chunks so the result is complete. } - $this->result = $this->buildResult(); + $this->finalize(); + } + + if ($this->result === null) { + throw new RuntimeException('The stream produced no candidates.'); } return $this->result; } + /** + * Assembles the result once and runs the completion callbacks. + * + * A no-op if the result is already built or the stream produced no candidates, + * so a fully iterated empty stream does not fire the completion callbacks. + * + * @since n.e.x.t + * + * @return void + */ + private function finalize(): void + { + if ($this->result !== null || $this->candidates === []) { + return; + } + + $this->result = $this->buildResult(); + + foreach ($this->completionCallbacks as $callback) { + $callback($this->result); + } + } + /** * Reads the next chunk from the source and folds it into the accumulated state. * From f169b69e3aea4d574b47846a56e04db1d2fbe8d0 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Sat, 20 Jun 2026 02:02:23 +0530 Subject: [PATCH 26/71] Add event dispatchers after streaming text results --- src/Builders/PromptBuilder.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 932cd736..22a59853 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -1064,7 +1064,8 @@ public function streamGenerateTextResult(): StreamedGenerativeAiResult $this->includeOutputModalities(ModalityEnum::text()); $this->validateMessages(); - $model = $this->getConfiguredModel(CapabilityEnum::textGeneration()); + $capability = CapabilityEnum::textGeneration(); + $model = $this->getConfiguredModel($capability); if (!$model instanceof StreamingTextGenerationModelInterface) { throw new RuntimeException( @@ -1075,7 +1076,15 @@ public function streamGenerateTextResult(): StreamedGenerativeAiResult ); } - return $model->streamGenerateTextResult($this->messages); + $messages = $this->messages; + + $this->dispatchEvent(new BeforeGenerateResultEvent($messages, $model, $capability)); + + return $model->streamGenerateTextResult($messages)->onComplete( + function (GenerativeAiResult $result) use ($messages, $model, $capability): void { + $this->dispatchEvent(new AfterGenerateResultEvent($messages, $model, $capability, $result)); + } + ); } /** From bdda338adad4f2bb6c5523c31c79a7b405cc07b1 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 17:15:44 +0530 Subject: [PATCH 27/71] Add value object to store tool call delta --- src/Results/ValueObjects/ToolCallDelta.php | 105 +++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/Results/ValueObjects/ToolCallDelta.php diff --git a/src/Results/ValueObjects/ToolCallDelta.php b/src/Results/ValueObjects/ToolCallDelta.php new file mode 100644 index 00000000..58a60b39 --- /dev/null +++ b/src/Results/ValueObjects/ToolCallDelta.php @@ -0,0 +1,105 @@ +index = $index; + $this->id = $id; + $this->functionName = $functionName; + $this->argumentsFragment = $argumentsFragment; + } + + /** + * Gets the tool call slot index this fragment contributes to. + * + * @since n.e.x.t + * + * @return int|null The slot index, or null when not reported. + */ + public function getIndex(): ?int + { + return $this->index; + } + + /** + * Gets the tool call id. + * + * @since n.e.x.t + * + * @return string|null The id, or null when not reported by this fragment. + */ + public function getId(): ?string + { + return $this->id; + } + + /** + * Gets the function name. + * + * @since n.e.x.t + * + * @return string|null The function name, or null when not reported by this fragment. + */ + public function getFunctionName(): ?string + { + return $this->functionName; + } + + /** + * Gets the partial function arguments carried by this fragment. + * + * @since n.e.x.t + * + * @return string The arguments fragment (may be empty). + */ + public function getArgumentsFragment(): string + { + return $this->argumentsFragment; + } +} From 94edcd2f5ea3977db4322501c2b9b5df1ee8c7ca Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 17:16:47 +0530 Subject: [PATCH 28/71] Update gen ai result chink value object to store tool call delta --- .../ValueObjects/GenerativeAiResultChunk.php | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Results/ValueObjects/GenerativeAiResultChunk.php b/src/Results/ValueObjects/GenerativeAiResultChunk.php index c7c46a98..872bd18e 100644 --- a/src/Results/ValueObjects/GenerativeAiResultChunk.php +++ b/src/Results/ValueObjects/GenerativeAiResultChunk.php @@ -44,6 +44,11 @@ final class GenerativeAiResultChunk */ private ?string $id; + /** + * @var list Partial tool calls carried by this chunk. + */ + private array $toolCallDeltas; + /** * Constructor. * @@ -54,19 +59,22 @@ final class GenerativeAiResultChunk * @param FinishReasonEnum|null $finishReason The finish reason, when reported. * @param TokenUsage|null $tokenUsage The token usage, when reported. * @param string|null $id The result id, when reported. + * @param list $toolCallDeltas Partial tool calls carried by this chunk. */ public function __construct( ?int $candidateIndex, array $parts = [], ?FinishReasonEnum $finishReason = null, ?TokenUsage $tokenUsage = null, - ?string $id = null + ?string $id = null, + array $toolCallDeltas = [] ) { $this->candidateIndex = $candidateIndex; $this->parts = $parts; $this->finishReason = $finishReason; $this->tokenUsage = $tokenUsage; $this->id = $id; + $this->toolCallDeltas = $toolCallDeltas; } /** @@ -172,4 +180,16 @@ public function getId(): ?string { return $this->id; } + + /** + * Gets the partial tool calls carried by this chunk. + * + * @since n.e.x.t + * + * @return list The tool call fragments, possibly empty. + */ + public function getToolCallDeltas(): array + { + return $this->toolCallDeltas; + } } From 2f31263ff2c7c0669fe403e764cc0a76df279133 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 17:38:06 +0530 Subject: [PATCH 29/71] Add tool calls accumulation --- src/Results/StreamedGenerativeAiResult.php | 99 ++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php index b3c98df1..18d50dc8 100644 --- a/src/Results/StreamedGenerativeAiResult.php +++ b/src/Results/StreamedGenerativeAiResult.php @@ -21,6 +21,8 @@ use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; +use WordPress\AiClient\Results\ValueObjects\ToolCallDelta; +use WordPress\AiClient\Tools\DTO\FunctionCall; /** * Represents a streamed result from a generative AI operation. @@ -95,6 +97,13 @@ final class StreamedGenerativeAiResult implements IteratorAggregate */ private array $finishReasons = []; + /** + * Stores the accumulated tool call slots, per candidate, per slot index. + * + * @var array> + */ + private array $toolCalls = []; + /** * Constructor. * @@ -262,6 +271,40 @@ private function accumulate(GenerativeAiResultChunk $chunk): void foreach ($chunk->getParts() as $part) { $this->addPart($index, $part); } + + foreach ($chunk->getToolCallDeltas() as $toolCallDelta) { + $this->addToolCallDelta($index, $toolCallDelta); + } + } + + /** + * Stores a tool call fragment into the candidate's tool call slots. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @param ToolCallDelta $delta The tool call fragment. + * @return void + */ + private function addToolCallDelta(int $index, ToolCallDelta $delta): void + { + $slot = $delta->getIndex() ?? 0; + + if (!isset($this->toolCalls[$index][$slot])) { + $this->toolCalls[$index][$slot] = ['id' => null, 'name' => null, 'args' => '']; + } + + $id = $delta->getId(); + if ($id !== null && $this->toolCalls[$index][$slot]['id'] === null) { + $this->toolCalls[$index][$slot]['id'] = $id; + } + + $name = $delta->getFunctionName(); + if ($name !== null && $this->toolCalls[$index][$slot]['name'] === null) { + $this->toolCalls[$index][$slot]['name'] = $name; + } + + $this->toolCalls[$index][$slot]['args'] .= $delta->getArgumentsFragment(); } /** @@ -352,12 +395,68 @@ private function buildCandidate(int $index): Candidate $parts[] = $part; } + foreach ($this->buildToolCallParts($index) as $part) { + $parts[] = $part; + } + $message = new Message(MessageRoleEnum::model(), $parts); $finishReason = $this->finishReasons[$index] ?? FinishReasonEnum::stop(); return new Candidate($message, $finishReason); } + /** + * Assembles the stored tool call slots for a candidate into message parts. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @return list The assembled function call parts, in slot order. + */ + private function buildToolCallParts(int $index): array + { + if (!isset($this->toolCalls[$index])) { + return []; + } + + $slots = $this->toolCalls[$index]; + ksort($slots); + + $parts = []; + foreach ($slots as $slot) { + // A function call needs at least an id or a name; skip a slot that received + // neither (a malformed stream) rather than failing the whole result. + if ($slot['id'] === null && $slot['name'] === null) { + continue; + } + + $parts[] = new MessagePart( + new FunctionCall($slot['id'], $slot['name'], $this->decodeToolCallArgs($slot['args'])) + ); + } + + return $parts; + } + + /** + * Decodes accumulated tool call arguments. + * + * @since n.e.x.t + * + * @param string $arguments The accumulated arguments string. + * @return mixed The decoded arguments, the raw string on failure, or null when empty. + */ + private function decodeToolCallArgs(string $arguments) + { + if ($arguments === '') { + return null; + } + + $decoded = json_decode($arguments, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $arguments; + } + /** * Normalizes an iterable into an Iterator the result can pull from. * From befc7067a216a37e919fc3daeab20429d3d4f3d3 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 17:47:53 +0530 Subject: [PATCH 30/71] Add support to parse function call for open ai compat provider --- ...actOpenAiCompatibleTextGenerationModel.php | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index da1c490b..2cbdd01c 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -28,6 +28,7 @@ use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Results\StreamedGenerativeAiResult; use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; +use WordPress\AiClient\Results\ValueObjects\ToolCallDelta; use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; @@ -68,11 +69,21 @@ * choices?: list, * usage?: UsageData * } + * @phpstan-type StreamToolCallData array{ + * index?: int, + * id?: string, + * type?: string, + * function?: array{ + * name?: string, + * arguments?: string + * } + * } * @phpstan-type StreamDeltaData array{ * role?: string, * reasoning_content?: string, * reasoning?: string, - * content?: string + * content?: string, + * tool_calls?: list * } * @phpstan-type StreamChoiceData array{ * index?: int, @@ -219,7 +230,8 @@ protected function parseStreamEventToChunks(array $data): iterable $this->parseStreamDeltaParts($delta), $finishReason, $tokenUsage, - $id + $id, + $this->parseStreamToolCallDeltas($delta) ); } } @@ -251,6 +263,47 @@ protected function parseStreamDeltaParts(array $delta): array return $parts; } + /** + * Maps a streamed delta's tool calls into tool call fragments. + * + * @since n.e.x.t + * + * @param StreamDeltaData $delta The delta payload from a choice. + * @return list The parsed tool call fragments. + */ + protected function parseStreamToolCallDeltas(array $delta): array + { + if (!isset($delta['tool_calls']) || !is_array($delta['tool_calls'])) { + return []; + } + + $deltas = []; + foreach ($delta['tool_calls'] as $position => $toolCall) { + if (!is_array($toolCall)) { + continue; + } + + // Providers key parallel tool calls by "index"; fall back to position. + $slot = isset($toolCall['index']) && is_int($toolCall['index']) + ? $toolCall['index'] + : (int) $position; + + $id = isset($toolCall['id']) && is_string($toolCall['id']) ? $toolCall['id'] : null; + + $function = isset($toolCall['function']) && is_array($toolCall['function']) + ? $toolCall['function'] + : []; + $name = isset($function['name']) && is_string($function['name']) ? $function['name'] : null; + $arguments = isset($function['arguments']) && is_string($function['arguments']) + ? $function['arguments'] + : ''; + + $deltas[] = new ToolCallDelta($slot, $id, $name, $arguments); + } + + return $deltas; + } + /** * Parses usage data into a token usage object. * From 8c12cdceb875d4f4a417233d49beca1025a796a5 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 19:09:15 +0530 Subject: [PATCH 31/71] Update gen ai result chunk to store additional data --- .../ValueObjects/GenerativeAiResultChunk.php | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Results/ValueObjects/GenerativeAiResultChunk.php b/src/Results/ValueObjects/GenerativeAiResultChunk.php index 872bd18e..5dd1a460 100644 --- a/src/Results/ValueObjects/GenerativeAiResultChunk.php +++ b/src/Results/ValueObjects/GenerativeAiResultChunk.php @@ -49,6 +49,11 @@ final class GenerativeAiResultChunk */ private array $toolCallDeltas; + /** + * @var array Result-level provider metadata carried by this chunk. + */ + private array $additionalData; + /** * Constructor. * @@ -60,6 +65,7 @@ final class GenerativeAiResultChunk * @param TokenUsage|null $tokenUsage The token usage, when reported. * @param string|null $id The result id, when reported. * @param list $toolCallDeltas Partial tool calls carried by this chunk. + * @param array $additionalData Result-level provider metadata carried by this chunk. */ public function __construct( ?int $candidateIndex, @@ -67,7 +73,8 @@ public function __construct( ?FinishReasonEnum $finishReason = null, ?TokenUsage $tokenUsage = null, ?string $id = null, - array $toolCallDeltas = [] + array $toolCallDeltas = [], + array $additionalData = [] ) { $this->candidateIndex = $candidateIndex; $this->parts = $parts; @@ -75,6 +82,7 @@ public function __construct( $this->tokenUsage = $tokenUsage; $this->id = $id; $this->toolCallDeltas = $toolCallDeltas; + $this->additionalData = $additionalData; } /** @@ -192,4 +200,16 @@ public function getToolCallDeltas(): array { return $this->toolCallDeltas; } + + /** + * Gets the result-level provider metadata carried by this chunk. + * + * @since n.e.x.t + * + * @return array The provider metadata, possibly empty. + */ + public function getAdditionalData(): array + { + return $this->additionalData; + } } From f2aada53c982ddfc8c35d6311cc58c574ea8c626 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 19:12:34 +0530 Subject: [PATCH 32/71] Add additional data support --- ...actOpenAiCompatibleTextGenerationModel.php | 57 +++++++++++++------ src/Results/StreamedGenerativeAiResult.php | 13 ++++- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 2cbdd01c..bc38e089 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -62,7 +62,10 @@ * @phpstan-type UsageData array{ * prompt_tokens?: int, * completion_tokens?: int, - * total_tokens?: int + * total_tokens?: int, + * completion_tokens_details?: array{ + * reasoning_tokens?: int + * } * } * @phpstan-type ResponseData array{ * id?: string, @@ -210,10 +213,12 @@ protected function parseStreamEventToChunks(array $data): iterable : null; $choices = isset($data['choices']) && is_array($data['choices']) ? $data['choices'] : []; + $additionalData = $this->extractAdditionalData($data); + if ($choices === []) { // Events with no choices (such as the final usage event) carry only result metadata. - if ($tokenUsage !== null || $id !== null) { - yield new GenerativeAiResultChunk(null, [], null, $tokenUsage, $id); + if ($tokenUsage !== null || $id !== null || $additionalData !== []) { + yield new GenerativeAiResultChunk(null, [], null, $tokenUsage, $id, [], $additionalData); } return; } @@ -231,7 +236,8 @@ protected function parseStreamEventToChunks(array $data): iterable $finishReason, $tokenUsage, $id, - $this->parseStreamToolCallDeltas($delta) + $this->parseStreamToolCallDeltas($delta), + $additionalData ); } } @@ -314,13 +320,37 @@ protected function parseStreamToolCallDeltas(array $delta): array */ protected function parseUsageData(array $usage): TokenUsage { + $thoughtTokens = null; + if ( + isset($usage['completion_tokens_details']['reasoning_tokens']) + && is_int($usage['completion_tokens_details']['reasoning_tokens']) + ) { + $thoughtTokens = $usage['completion_tokens_details']['reasoning_tokens']; + } + return new TokenUsage( $usage['prompt_tokens'] ?? 0, $usage['completion_tokens'] ?? 0, - $usage['total_tokens'] ?? 0 + $usage['total_tokens'] ?? 0, + $thoughtTokens ); } + /** + * Extracts provider-specific metadata from a response or stream event. + * + * @since n.e.x.t + * + * @param array $data The decoded response or event payload. + * @return array The remaining provider metadata. + */ + protected function extractAdditionalData(array $data): array + { + unset($data['id'], $data['choices'], $data['usage']); + + return $data; + } + /** * Returns the parser used to decode the streamed event stream. * @@ -841,21 +871,12 @@ protected function parseResponseToGenerativeAiResult(Response $response): Genera $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; - if (isset($responseData['usage']) && is_array($responseData['usage'])) { - $usage = $responseData['usage']; - - $tokenUsage = new TokenUsage( - $usage['prompt_tokens'] ?? 0, - $usage['completion_tokens'] ?? 0, - $usage['total_tokens'] ?? 0 - ); - } else { - $tokenUsage = new TokenUsage(0, 0, 0); - } + $tokenUsage = isset($responseData['usage']) && is_array($responseData['usage']) + ? $this->parseUsageData($responseData['usage']) + : new TokenUsage(0, 0, 0); // Use any other data from the response as provider-specific response metadata. - $additionalData = $responseData; - unset($additionalData['id'], $additionalData['choices'], $additionalData['usage']); + $additionalData = $this->extractAdditionalData($responseData); return new GenerativeAiResult( $id, diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php index 18d50dc8..db54c31f 100644 --- a/src/Results/StreamedGenerativeAiResult.php +++ b/src/Results/StreamedGenerativeAiResult.php @@ -104,6 +104,11 @@ final class StreamedGenerativeAiResult implements IteratorAggregate */ private array $toolCalls = []; + /** + * @var array Merged result-level provider metadata. + */ + private array $additionalData = []; + /** * Constructor. * @@ -254,6 +259,11 @@ private function accumulate(GenerativeAiResultChunk $chunk): void $this->tokenUsage = $tokenUsage; } + $additionalData = $chunk->getAdditionalData(); + if ($additionalData !== []) { + $this->additionalData = array_merge($this->additionalData, $additionalData); + } + // A chunk with no candidate index carries only result-level metadata, such // as the final usage event, so it registers no candidate. $index = $chunk->getCandidateIndex(); @@ -366,7 +376,8 @@ private function buildResult(): GenerativeAiResult $candidates, $this->tokenUsage ?? new TokenUsage(0, 0, 0), $this->providerMetadata, - $this->modelMetadata + $this->modelMetadata, + $this->additionalData ); } From 512ce47d13ce4c2bf3ac74baff7d8bf7b4ac6864 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 19:35:15 +0530 Subject: [PATCH 33/71] Add exception for stream responses --- .../Http/Exception/ResponseException.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index 01e2bd70..c37b3d51 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -47,4 +47,24 @@ public static function fromInvalidData(string $apiName, string $fieldName, strin { return new self(sprintf('Unexpected %s API response: Invalid "%s" key: %s', $apiName, $fieldName, $message)); } + + /** + * Creates a ResponseException for an error encountered while streaming a response. + * + * @since n.e.x.t + * + * @param string $apiName The name of the API/provider. + * @param string $message The error message. + * @return self + * + * @param \Throwable|null $previous The underlying exception, when wrapping one. + */ + public static function fromStreamError(string $apiName, string $message, ?\Throwable $previous = null): self + { + return new self( + sprintf('Error while streaming the %s API response: %s', $apiName, $message), + 0, + $previous + ); + } } From 96a895b4e1bc11208efde8ee383b08b7555ad530 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Tue, 23 Jun 2026 19:42:13 +0530 Subject: [PATCH 34/71] Add stream error exception --- ...actOpenAiCompatibleTextGenerationModel.php | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index bc38e089..0d12b26e 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -176,25 +176,55 @@ private function streamTextChunks(array $prompt): iterable $response = $httpTransporter->send($request, $streamOptions); $this->throwIfNotSuccessful($response); - foreach ($this->getEventStreamParser()->parse($response->getStream()) as $event) { - $data = $event->getData(); + try { + foreach ($this->getEventStreamParser()->parse($response->getStream()) as $event) { + $data = $event->getData(); + if ($data === '' || $data === '[DONE]') { + continue; + } - // The provider marks the end of the stream with a "[DONE]" sentinel. - if ($data === '' || $data === '[DONE]') { - continue; - } + $decoded = json_decode($data, true); + if (!is_array($decoded)) { + continue; + } - $decoded = json_decode($data, true); - if (!is_array($decoded)) { - continue; - } + $this->throwIfStreamError($decoded); - /** @var StreamEventData $decoded */ - $chunks = $this->parseStreamEventToChunks($decoded); - foreach ($chunks as $chunk) { - yield $chunk; + /** @var StreamEventData $decoded */ + $chunks = $this->parseStreamEventToChunks($decoded); + foreach ($chunks as $chunk) { + yield $chunk; + } } + } catch (ResponseException $e) { + throw $e; + } catch (\RuntimeException $e) { + throw ResponseException::fromStreamError($this->providerMetadata()->getName(), $e->getMessage(), $e); + } + } + + /** + * Throws if a decoded stream event reports a provider error. + * + * @since n.e.x.t + * + * @param array $event The decoded stream event. + * @return void + * + * @throws ResponseException If the event carries an error payload. + */ + protected function throwIfStreamError(array $event): void + { + if (!isset($event['error'])) { + return; } + + $error = $event['error']; + $message = is_array($error) && isset($error['message']) && is_string($error['message']) + ? $error['message'] + : 'The provider reported an error.'; + + throw ResponseException::fromStreamError($this->providerMetadata()->getName(), $message); } /** From 0e511db7cf7fb2f451b314571804b5bc41016e70 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 12:11:25 +0530 Subject: [PATCH 35/71] Update gen ai result chunk responsibilities --- .../ValueObjects/GenerativeAiResultChunk.php | 157 ++++++------------ 1 file changed, 52 insertions(+), 105 deletions(-) diff --git a/src/Results/ValueObjects/GenerativeAiResultChunk.php b/src/Results/ValueObjects/GenerativeAiResultChunk.php index 5dd1a460..7d9800a6 100644 --- a/src/Results/ValueObjects/GenerativeAiResultChunk.php +++ b/src/Results/ValueObjects/GenerativeAiResultChunk.php @@ -4,10 +4,7 @@ namespace WordPress\AiClient\Results\ValueObjects; -use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Results\DTO\TokenUsage; -use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** * Represents a single chunk of a streamed generative AI result. @@ -19,20 +16,9 @@ final class GenerativeAiResultChunk { /** - * @var int|null Index of the candidate this chunk contributes to, or null when the chunk - * carries only result-level metadata (e.g. a usage event). - */ - private ?int $candidateIndex; - - /** - * @var MessagePart[] The partial content parts carried by this chunk. - */ - private array $parts; - - /** - * @var FinishReasonEnum|null The finish reason, when this chunk reports it. + * @var string|null The result id, when this chunk reports it. */ - private ?FinishReasonEnum $finishReason; + private ?string $id; /** * @var TokenUsage|null The token usage, when this chunk reports it. @@ -40,157 +26,123 @@ final class GenerativeAiResultChunk private ?TokenUsage $tokenUsage; /** - * @var string|null The result id, when this chunk reports it. - */ - private ?string $id; - - /** - * @var list Partial tool calls carried by this chunk. + * @var array Result-level provider metadata carried by this chunk. */ - private array $toolCallDeltas; + private array $additionalData; /** - * @var array Result-level provider metadata carried by this chunk. + * @var list The per-candidate deltas carried by this chunk. */ - private array $additionalData; + private array $candidateDeltas; /** * Constructor. * * @since n.e.x.t * - * @param int|null $candidateIndex Index of the candidate this chunk contributes to. - * @param MessagePart[] $parts The partial content parts carried by this chunk. - * @param FinishReasonEnum|null $finishReason The finish reason, when reported. - * @param TokenUsage|null $tokenUsage The token usage, when reported. * @param string|null $id The result id, when reported. - * @param list $toolCallDeltas Partial tool calls carried by this chunk. - * @param array $additionalData Result-level provider metadata carried by this chunk. + * @param TokenUsage|null $tokenUsage The token usage, when reported. + * @param array $additionalData Result-level provider metadata. + * @param list $candidateDeltas The per-candidate deltas. */ public function __construct( - ?int $candidateIndex, - array $parts = [], - ?FinishReasonEnum $finishReason = null, - ?TokenUsage $tokenUsage = null, ?string $id = null, - array $toolCallDeltas = [], - array $additionalData = [] + ?TokenUsage $tokenUsage = null, + array $additionalData = [], + array $candidateDeltas = [] ) { - $this->candidateIndex = $candidateIndex; - $this->parts = $parts; - $this->finishReason = $finishReason; - $this->tokenUsage = $tokenUsage; $this->id = $id; - $this->toolCallDeltas = $toolCallDeltas; + $this->tokenUsage = $tokenUsage; $this->additionalData = $additionalData; + $this->candidateDeltas = $candidateDeltas; } /** - * Gets the index of the candidate this chunk contributes to. + * Gets the result id. * * @since n.e.x.t * - * @return int|null The candidate index, or null when the chunk carries only result-level metadata. + * @return string|null The id, or null when not reported by this chunk. */ - public function getCandidateIndex(): ?int + public function getId(): ?string { - return $this->candidateIndex; + return $this->id; } /** - * Gets the partial content parts. + * Gets the token usage. * * @since n.e.x.t * - * @return MessagePart[] The content parts. + * @return TokenUsage|null The token usage, or null when not reported by this chunk. */ - public function getParts(): array + public function getTokenUsage(): ?TokenUsage { - return $this->parts; + return $this->tokenUsage; } /** - * Gets delta text of this chunk's content channel. + * Gets the result-level provider metadata carried by this chunk. * * @since n.e.x.t * - * @return string The content text delta, or an empty string when this chunk carries none. + * @return array The provider metadata, possibly empty. */ - public function getDeltaText(): string + public function getAdditionalData(): array { - return $this->deltaTextForChannel(MessagePartChannelEnum::content()); + return $this->additionalData; } /** - * Gets delta text of this chunk's reasoning (thought) channel. + * Gets the per-candidate deltas carried by this chunk. * * @since n.e.x.t * - * @return string The reasoning text delta, or an empty string when this chunk carries none. + * @return list The candidate deltas, possibly empty (metadata-only event). */ - public function getReasoningDeltaText(): string + public function getCandidateDeltas(): array { - return $this->deltaTextForChannel(MessagePartChannelEnum::thought()); + return $this->candidateDeltas; } /** - * Concatenates the delta text of this chunk's parts on the given channel. + * Gets the content text delta carried by this chunk. * * @since n.e.x.t * - * @param MessagePartChannelEnum $channel The channel to read. - * @return string The concatenated delta text, or an empty string when there is none. + * @return string The content text delta, or an empty string when this chunk carries none. */ - private function deltaTextForChannel(MessagePartChannelEnum $channel): string + public function getDeltaText(): string { $text = ''; - foreach ($this->parts as $part) { - if ($part->getChannel()->is($channel) && $part->getText() !== null) { - $text .= $part->getText(); - } + foreach ($this->candidateDeltas as $delta) { + $text .= $delta->getDeltaText(); } return $text; } /** - * Gets the finish reason. + * Gets the reasoning (thought) text delta carried by this chunk. * - * @since n.e.x.t - * - * @return FinishReasonEnum|null The finish reason, or null when not reported by this chunk. - */ - public function getFinishReason(): ?FinishReasonEnum - { - return $this->finishReason; - } - - /** - * Gets the token usage. + * Convenience for single-candidate streaming; see getDeltaText(). * * @since n.e.x.t * - * @return TokenUsage|null The token usage, or null when not reported by this chunk. + * @return string The reasoning text delta, or an empty string when this chunk carries none. */ - public function getTokenUsage(): ?TokenUsage + public function getReasoningDeltaText(): string { - return $this->tokenUsage; - } + $text = ''; + foreach ($this->candidateDeltas as $delta) { + $text .= $delta->getReasoningDeltaText(); + } - /** - * Gets the result id. - * - * @since n.e.x.t - * - * @return string|null The id, or null when not reported by this chunk. - */ - public function getId(): ?string - { - return $this->id; + return $text; } /** - * Gets the partial tool calls carried by this chunk. + * Gets the tool call fragments carried by this chunk. * * @since n.e.x.t * @@ -198,18 +150,13 @@ public function getId(): ?string */ public function getToolCallDeltas(): array { - return $this->toolCallDeltas; - } + $deltas = []; + foreach ($this->candidateDeltas as $candidateDelta) { + foreach ($candidateDelta->getToolCallDeltas() as $toolCallDelta) { + $deltas[] = $toolCallDelta; + } + } - /** - * Gets the result-level provider metadata carried by this chunk. - * - * @since n.e.x.t - * - * @return array The provider metadata, possibly empty. - */ - public function getAdditionalData(): array - { - return $this->additionalData; + return $deltas; } } From 412a813f18f83517fa1ed750a1f566779fe7f805 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 12:13:06 +0530 Subject: [PATCH 36/71] Add value object to store candidate delta --- src/Results/ValueObjects/CandidateDelta.php | 153 ++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 src/Results/ValueObjects/CandidateDelta.php diff --git a/src/Results/ValueObjects/CandidateDelta.php b/src/Results/ValueObjects/CandidateDelta.php new file mode 100644 index 00000000..cbaa6101 --- /dev/null +++ b/src/Results/ValueObjects/CandidateDelta.php @@ -0,0 +1,153 @@ + The partial content parts for this candidate. + */ + private array $parts; + + /** + * @var FinishReasonEnum|null The finish reason, when this delta reports it. + */ + private ?FinishReasonEnum $finishReason; + + /** + * @var list The partial tool calls for this candidate. + */ + private array $toolCallDeltas; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param int $index The candidate index this delta contributes to. + * @param list $parts The partial content parts. + * @param FinishReasonEnum|null $finishReason The finish reason, when reported. + * @param list $toolCallDeltas The partial tool calls. + */ + public function __construct( + int $index, + array $parts = [], + ?FinishReasonEnum $finishReason = null, + array $toolCallDeltas = [] + ) { + $this->index = $index; + $this->parts = $parts; + $this->finishReason = $finishReason; + $this->toolCallDeltas = $toolCallDeltas; + } + + /** + * Gets the candidate index this delta contributes to. + * + * @since n.e.x.t + * + * @return int The candidate index. + */ + public function getIndex(): int + { + return $this->index; + } + + /** + * Gets the partial content parts. + * + * @since n.e.x.t + * + * @return list The content parts. + */ + public function getParts(): array + { + return $this->parts; + } + + /** + * Gets the finish reason. + * + * @since n.e.x.t + * + * @return FinishReasonEnum|null The finish reason, or null when not reported by this delta. + */ + public function getFinishReason(): ?FinishReasonEnum + { + return $this->finishReason; + } + + /** + * Gets the partial tool calls. + * + * @since n.e.x.t + * + * @return list The tool call fragments, possibly empty. + */ + public function getToolCallDeltas(): array + { + return $this->toolCallDeltas; + } + + /** + * Gets the delta text of this candidate's content channel. + * + * @since n.e.x.t + * + * @return string The content text delta, or an empty string when this delta carries none. + */ + public function getDeltaText(): string + { + return $this->deltaTextForChannel(MessagePartChannelEnum::content()); + } + + /** + * Gets the delta text of this candidate's reasoning (thought) channel. + * + * @since n.e.x.t + * + * @return string The reasoning text delta, or an empty string when this delta carries none. + */ + public function getReasoningDeltaText(): string + { + return $this->deltaTextForChannel(MessagePartChannelEnum::thought()); + } + + /** + * Concatenates the delta text of this candidate's parts on the given channel. + * + * @since n.e.x.t + * + * @param MessagePartChannelEnum $channel The channel to read. + * @return string The concatenated delta text, or an empty string when there is none. + */ + private function deltaTextForChannel(MessagePartChannelEnum $channel): string + { + $text = ''; + foreach ($this->parts as $part) { + if ($part->getChannel()->is($channel) && $part->getText() !== null) { + $text .= $part->getText(); + } + } + + return $text; + } +} From 0450a83109dfbd3e4d05bff532d3f57574c374fb Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 12:17:39 +0530 Subject: [PATCH 37/71] Add streamed chunks accumulator --- src/Results/ChunkAccumulator.php | 342 +++++++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 src/Results/ChunkAccumulator.php diff --git a/src/Results/ChunkAccumulator.php b/src/Results/ChunkAccumulator.php new file mode 100644 index 00000000..ca9f9a5b --- /dev/null +++ b/src/Results/ChunkAccumulator.php @@ -0,0 +1,342 @@ + Merged result-level provider metadata. + */ + private array $additionalData = []; + + /** + * @var array Candidate indices seen while accumulating. + */ + private array $candidates = []; + + /** + * @var array> Accumulated text, per candidate, per channel. + */ + private array $text = []; + + /** + * @var array> Thought signature, per candidate, per channel. + */ + private array $thoughtSignatures = []; + + /** + * @var array> Non-text parts, per candidate, in arrival order. + */ + private array $otherParts = []; + + /** + * @var array Finish reason, per candidate. + */ + private array $finishReasons = []; + + /** + * Tool call slots being stitched together, per candidate, per slot index. + * + * @var array> + */ + private array $toolCalls = []; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param ProviderMetadata $providerMetadata Provider metadata for the assembled result. + * @param ModelMetadata $modelMetadata Model metadata for the assembled result. + */ + public function __construct(ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata) + { + $this->providerMetadata = $providerMetadata; + $this->modelMetadata = $modelMetadata; + } + + /** + * Folds a single chunk into the accumulated state. + * + * @since n.e.x.t + * + * @param GenerativeAiResultChunk $chunk The chunk to fold in. + * @return void + */ + public function add(GenerativeAiResultChunk $chunk): void + { + $id = $chunk->getId(); + if ($id !== null && $this->id === null) { + $this->id = $id; + } + + $tokenUsage = $chunk->getTokenUsage(); + if ($tokenUsage !== null) { + $this->tokenUsage = $tokenUsage; + } + + $additionalData = $chunk->getAdditionalData(); + if ($additionalData !== []) { + $this->additionalData = array_merge($this->additionalData, $additionalData); + } + + foreach ($chunk->getCandidateDeltas() as $candidateDelta) { + $this->addCandidateDelta($candidateDelta); + } + } + + /** + * Reports whether any candidate has been accumulated. + * + * @since n.e.x.t + * + * @return bool True if there is at least one candidate to build. + */ + public function hasCandidates(): bool + { + return $this->candidates !== []; + } + + /** + * Assembles the accumulated state into a result. + * + * @since n.e.x.t + * + * @return GenerativeAiResult The assembled result. + * @throws RuntimeException If no candidates were accumulated. + */ + public function build(): GenerativeAiResult + { + if ($this->candidates === []) { + throw new RuntimeException('The stream produced no candidates.'); + } + + $indices = array_keys($this->candidates); + sort($indices); + + $candidates = []; + foreach ($indices as $index) { + $candidates[] = $this->buildCandidate($index); + } + + return new GenerativeAiResult( + $this->id ?? '', + $candidates, + $this->tokenUsage ?? new TokenUsage(0, 0, 0), + $this->providerMetadata, + $this->modelMetadata, + $this->additionalData + ); + } + + /** + * Folds a candidate delta into the per-candidate state. + * + * @since n.e.x.t + * + * @param CandidateDelta $delta The candidate delta to fold in. + * @return void + */ + private function addCandidateDelta(CandidateDelta $delta): void + { + $index = $delta->getIndex(); + $this->candidates[$index] = true; + + $finishReason = $delta->getFinishReason(); + if ($finishReason !== null) { + $this->finishReasons[$index] = $finishReason; + } + + foreach ($delta->getParts() as $part) { + $this->addPart($index, $part); + } + + foreach ($delta->getToolCallDeltas() as $toolCallDelta) { + $this->addToolCallDelta($index, $toolCallDelta); + } + } + + /** + * Folds a content part into the candidate state. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @param MessagePart $part The part to fold in. + * @return void + */ + private function addPart(int $index, MessagePart $part): void + { + $text = $part->getText(); + if ($text === null) { + $this->otherParts[$index][] = $part; + return; + } + + $channel = $part->getChannel()->value; + if (!isset($this->text[$index][$channel])) { + $this->text[$index][$channel] = ''; + } + $this->text[$index][$channel] .= $text; + + $signature = $part->getThoughtSignature(); + if ($signature !== null) { + $this->thoughtSignatures[$index][$channel] = $signature; + } + } + + /** + * Stores a tool call fragment into the candidate's tool call slots. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @param ToolCallDelta $delta The tool call fragment. + * @return void + */ + private function addToolCallDelta(int $index, ToolCallDelta $delta): void + { + $slot = $delta->getIndex() ?? 0; + + if (!isset($this->toolCalls[$index][$slot])) { + $this->toolCalls[$index][$slot] = ['id' => null, 'name' => null, 'args' => '']; + } + + $id = $delta->getId(); + if ($id !== null && $this->toolCalls[$index][$slot]['id'] === null) { + $this->toolCalls[$index][$slot]['id'] = $id; + } + + $name = $delta->getFunctionName(); + if ($name !== null && $this->toolCalls[$index][$slot]['name'] === null) { + $this->toolCalls[$index][$slot]['name'] = $name; + } + + $this->toolCalls[$index][$slot]['args'] .= $delta->getArgumentsFragment(); + } + + /** + * Builds a single candidate from its accumulated state. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @return Candidate The assembled candidate. + */ + private function buildCandidate(int $index): Candidate + { + $parts = []; + + // Store text parts first. + foreach ($this->text[$index] ?? [] as $channel => $text) { + $parts[] = new MessagePart( + $text, + MessagePartChannelEnum::from($channel), + $this->thoughtSignatures[$index][$channel] ?? null + ); + } + + foreach ($this->otherParts[$index] ?? [] as $part) { + $parts[] = $part; + } + + // Tool calls last, matching the non-streamed response part order. + foreach ($this->buildToolCallParts($index) as $part) { + $parts[] = $part; + } + + $message = new Message(MessageRoleEnum::model(), $parts); + $finishReason = $this->finishReasons[$index] ?? FinishReasonEnum::stop(); + + return new Candidate($message, $finishReason); + } + + /** + * Assembles the stored tool call slots for a candidate into message parts. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @return list The assembled function call parts, in slot order. + */ + private function buildToolCallParts(int $index): array + { + if (!isset($this->toolCalls[$index])) { + return []; + } + + $slots = $this->toolCalls[$index]; + ksort($slots); + + $parts = []; + foreach ($slots as $slot) { + // A function call needs at least an id or a name; skip a slot that received + // neither (a malformed stream) rather than failing the whole result. + if ($slot['id'] === null && $slot['name'] === null) { + continue; + } + + $parts[] = new MessagePart( + new FunctionCall($slot['id'], $slot['name'], $this->decodeToolCallArgs($slot['args'])) + ); + } + + return $parts; + } + + /** + * Decodes accumulated tool call arguments. + * + * @since n.e.x.t + * + * @param string $arguments The accumulated arguments string. + * @return mixed The decoded arguments, the raw string on failure, or null when empty. + */ + private function decodeToolCallArgs(string $arguments) + { + if ($arguments === '') { + return null; + } + + $decoded = json_decode($arguments, true); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : $arguments; + } +} From 5762881daf94a388d645b08f40a1b37771fab5a1 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 12:18:45 +0530 Subject: [PATCH 38/71] Update gen ai result class to use chunk accumulator --- src/Results/StreamedGenerativeAiResult.php | 357 ++------------------- 1 file changed, 21 insertions(+), 336 deletions(-) diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php index db54c31f..ca8e69e5 100644 --- a/src/Results/StreamedGenerativeAiResult.php +++ b/src/Results/StreamedGenerativeAiResult.php @@ -4,25 +4,14 @@ namespace WordPress\AiClient\Results; -use ArrayIterator; use Generator; use Iterator; use IteratorAggregate; -use IteratorIterator; use WordPress\AiClient\Common\Exception\RuntimeException; -use WordPress\AiClient\Messages\DTO\Message; -use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; -use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Results\DTO\TokenUsage; -use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; -use WordPress\AiClient\Results\ValueObjects\ToolCallDelta; -use WordPress\AiClient\Tools\DTO\FunctionCall; /** * Represents a streamed result from a generative AI operation. @@ -33,24 +22,12 @@ */ final class StreamedGenerativeAiResult implements IteratorAggregate { - private ModelMetadata $modelMetadata; - - private ProviderMetadata $providerMetadata; - /** - * @var iterable The source chunk stream. + * @var Iterator The source chunk stream. */ - private iterable $chunks; + private Iterator $chunks; - /** - * @var Iterator|null The lazily created source iterator. - */ - private ?Iterator $iterator = null; - - /** - * @var bool Whether the source stream has been fully read. - */ - private bool $finished = false; + private ChunkAccumulator $accumulator; /** * @var list Callbacks run once when the result is assembled. @@ -58,71 +35,33 @@ final class StreamedGenerativeAiResult implements IteratorAggregate private array $completionCallbacks = []; /** - * @var GenerativeAiResult|null The assembled result, once built. + * @var bool Whether the source stream has been started. */ - private ?GenerativeAiResult $result = null; + private bool $started = false; /** - * @var string|null The result id, captured from the first chunk that carries one. - */ - private ?string $id = null; - - /** - * @var TokenUsage|null The token usage, captured from the chunk that carries it. - */ - private ?TokenUsage $tokenUsage = null; - - /** - * @var array Candidate indices that carried content or a finish reason. - */ - private array $candidates = []; - - /** - * @var array> Accumulated text, per candidate, per channel. - */ - private array $text = []; - - /** - * @var array> Thought signature, per candidate, per channel. - */ - private array $thoughtSignatures = []; - - /** - * @var array> Non-text parts, per candidate, in arrival order. - */ - private array $otherParts = []; - - /** - * @var array Finish reason, per candidate. - */ - private array $finishReasons = []; - - /** - * Stores the accumulated tool call slots, per candidate, per slot index. - * - * @var array> + * @var bool Whether the source stream has been fully read. */ - private array $toolCalls = []; + private bool $finished = false; /** - * @var array Merged result-level provider metadata. + * @var GenerativeAiResult|null The assembled result, once built. */ - private array $additionalData = []; + private ?GenerativeAiResult $result = null; /** * Constructor. * * @since n.e.x.t * - * @param iterable $chunks The source chunk stream. + * @param Iterator $chunks The source chunk stream. * @param ProviderMetadata $providerMetadata Provider metadata for the assembled result. * @param ModelMetadata $modelMetadata Model metadata for the assembled result. */ - public function __construct(iterable $chunks, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata) + public function __construct(Iterator $chunks, ProviderMetadata $providerMetadata, ModelMetadata $modelMetadata) { $this->chunks = $chunks; - $this->providerMetadata = $providerMetadata; - $this->modelMetadata = $modelMetadata; + $this->accumulator = new ChunkAccumulator($providerMetadata, $modelMetadata); } /** @@ -196,11 +135,11 @@ public function getFinalResult(): GenerativeAiResult */ private function finalize(): void { - if ($this->result !== null || $this->candidates === []) { + if ($this->result !== null || !$this->accumulator->hasCandidates()) { return; } - $this->result = $this->buildResult(); + $this->result = $this->accumulator->build(); foreach ($this->completionCallbacks as $callback) { $callback($this->result); @@ -220,274 +159,20 @@ private function pull(): ?GenerativeAiResultChunk return null; } - if ($this->iterator === null) { - $this->iterator = $this->toIterator($this->chunks); - $this->iterator->rewind(); + if (!$this->started) { + $this->chunks->rewind(); + $this->started = true; } - $iterator = $this->iterator; - - if (!$iterator->valid()) { + if (!$this->chunks->valid()) { $this->finished = true; return null; } - $chunk = $iterator->current(); - $this->accumulate($chunk); - $iterator->next(); + $chunk = $this->chunks->current(); + $this->accumulator->add($chunk); + $this->chunks->next(); return $chunk; } - - /** - * Folds a single chunk into the accumulated state. - * - * @since n.e.x.t - * - * @param GenerativeAiResultChunk $chunk The chunk to fold in. - * @return void - */ - private function accumulate(GenerativeAiResultChunk $chunk): void - { - $id = $chunk->getId(); - if ($id !== null && $this->id === null) { - $this->id = $id; - } - - $tokenUsage = $chunk->getTokenUsage(); - if ($tokenUsage !== null) { - $this->tokenUsage = $tokenUsage; - } - - $additionalData = $chunk->getAdditionalData(); - if ($additionalData !== []) { - $this->additionalData = array_merge($this->additionalData, $additionalData); - } - - // A chunk with no candidate index carries only result-level metadata, such - // as the final usage event, so it registers no candidate. - $index = $chunk->getCandidateIndex(); - if ($index === null) { - return; - } - - $this->candidates[$index] = true; - - $finishReason = $chunk->getFinishReason(); - if ($finishReason !== null) { - $this->finishReasons[$index] = $finishReason; - } - - foreach ($chunk->getParts() as $part) { - $this->addPart($index, $part); - } - - foreach ($chunk->getToolCallDeltas() as $toolCallDelta) { - $this->addToolCallDelta($index, $toolCallDelta); - } - } - - /** - * Stores a tool call fragment into the candidate's tool call slots. - * - * @since n.e.x.t - * - * @param int $index The candidate index. - * @param ToolCallDelta $delta The tool call fragment. - * @return void - */ - private function addToolCallDelta(int $index, ToolCallDelta $delta): void - { - $slot = $delta->getIndex() ?? 0; - - if (!isset($this->toolCalls[$index][$slot])) { - $this->toolCalls[$index][$slot] = ['id' => null, 'name' => null, 'args' => '']; - } - - $id = $delta->getId(); - if ($id !== null && $this->toolCalls[$index][$slot]['id'] === null) { - $this->toolCalls[$index][$slot]['id'] = $id; - } - - $name = $delta->getFunctionName(); - if ($name !== null && $this->toolCalls[$index][$slot]['name'] === null) { - $this->toolCalls[$index][$slot]['name'] = $name; - } - - $this->toolCalls[$index][$slot]['args'] .= $delta->getArgumentsFragment(); - } - - /** - * Folds a content part into the candidate state. - * - * Text parts are concatenated per channel, so the final message has one part - * per channel, matching a non-streamed response. Non-text parts are kept whole. - * - * @since n.e.x.t - * - * @param int $index The candidate index. - * @param MessagePart $part The part to fold in. - * @return void - */ - private function addPart(int $index, MessagePart $part): void - { - $text = $part->getText(); - if ($text === null) { - $this->otherParts[$index][] = $part; - return; - } - - $channel = $part->getChannel()->value; - if (!isset($this->text[$index][$channel])) { - $this->text[$index][$channel] = ''; - } - $this->text[$index][$channel] .= $text; - - $signature = $part->getThoughtSignature(); - if ($signature !== null) { - $this->thoughtSignatures[$index][$channel] = $signature; - } - } - - /** - * Assembles the accumulated state into a result. - * - * @since n.e.x.t - * - * @return GenerativeAiResult The assembled result. - * @throws RuntimeException If no candidates were produced. - */ - private function buildResult(): GenerativeAiResult - { - if ($this->candidates === []) { - throw new RuntimeException('The stream produced no candidates.'); - } - - $indices = array_keys($this->candidates); - sort($indices); - - $candidates = []; - foreach ($indices as $index) { - $candidates[] = $this->buildCandidate($index); - } - - return new GenerativeAiResult( - $this->id ?? '', - $candidates, - $this->tokenUsage ?? new TokenUsage(0, 0, 0), - $this->providerMetadata, - $this->modelMetadata, - $this->additionalData - ); - } - - /** - * Builds a single candidate from its accumulated state. - * - * @since n.e.x.t - * - * @param int $index The candidate index. - * @return Candidate The assembled candidate. - */ - private function buildCandidate(int $index): Candidate - { - $parts = []; - - // Text parts, in the order their channels first appeared in the stream. - foreach ($this->text[$index] ?? [] as $channel => $text) { - $parts[] = new MessagePart( - $text, - MessagePartChannelEnum::from($channel), - $this->thoughtSignatures[$index][$channel] ?? null - ); - } - - foreach ($this->otherParts[$index] ?? [] as $part) { - $parts[] = $part; - } - - foreach ($this->buildToolCallParts($index) as $part) { - $parts[] = $part; - } - - $message = new Message(MessageRoleEnum::model(), $parts); - $finishReason = $this->finishReasons[$index] ?? FinishReasonEnum::stop(); - - return new Candidate($message, $finishReason); - } - - /** - * Assembles the stored tool call slots for a candidate into message parts. - * - * @since n.e.x.t - * - * @param int $index The candidate index. - * @return list The assembled function call parts, in slot order. - */ - private function buildToolCallParts(int $index): array - { - if (!isset($this->toolCalls[$index])) { - return []; - } - - $slots = $this->toolCalls[$index]; - ksort($slots); - - $parts = []; - foreach ($slots as $slot) { - // A function call needs at least an id or a name; skip a slot that received - // neither (a malformed stream) rather than failing the whole result. - if ($slot['id'] === null && $slot['name'] === null) { - continue; - } - - $parts[] = new MessagePart( - new FunctionCall($slot['id'], $slot['name'], $this->decodeToolCallArgs($slot['args'])) - ); - } - - return $parts; - } - - /** - * Decodes accumulated tool call arguments. - * - * @since n.e.x.t - * - * @param string $arguments The accumulated arguments string. - * @return mixed The decoded arguments, the raw string on failure, or null when empty. - */ - private function decodeToolCallArgs(string $arguments) - { - if ($arguments === '') { - return null; - } - - $decoded = json_decode($arguments, true); - - return json_last_error() === JSON_ERROR_NONE ? $decoded : $arguments; - } - - /** - * Normalizes an iterable into an Iterator the result can pull from. - * - * @since n.e.x.t - * - * @param iterable $chunks The source chunk stream. - * @return Iterator The normalized iterator. - */ - private function toIterator(iterable $chunks): Iterator - { - if ($chunks instanceof Iterator) { - return $chunks; - } - - if ($chunks instanceof IteratorAggregate) { - $inner = $chunks->getIterator(); - return $inner instanceof Iterator ? $inner : new IteratorIterator($inner); - } - - /** @var array $chunks */ - return new ArrayIterator($chunks); - } } From 442274933cdd7fea3a5f04ff112e463da712b538 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 12:20:51 +0530 Subject: [PATCH 39/71] Update candidate chinks handling --- ...actOpenAiCompatibleTextGenerationModel.php | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 0d12b26e..ae753f4c 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation; +use Generator; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Messages\DTO\Message; @@ -27,6 +28,7 @@ use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Results\StreamedGenerativeAiResult; +use WordPress\AiClient\Results\ValueObjects\CandidateDelta; use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; use WordPress\AiClient\Results\ValueObjects\ToolCallDelta; use WordPress\AiClient\Tools\DTO\FunctionCall; @@ -150,9 +152,9 @@ final public function streamGenerateTextResult(array $prompt): StreamedGenerativ * @since n.e.x.t * * @param list $prompt The prompt to generate text for. - * @return iterable The result chunks as they arrive. + * @return Generator The result chunks as they arrive. */ - private function streamTextChunks(array $prompt): iterable + private function streamTextChunks(array $prompt): Generator { $httpTransporter = $this->getHttpTransporter(); @@ -191,8 +193,8 @@ private function streamTextChunks(array $prompt): iterable $this->throwIfStreamError($decoded); /** @var StreamEventData $decoded */ - $chunks = $this->parseStreamEventToChunks($decoded); - foreach ($chunks as $chunk) { + $chunk = $this->parseStreamEvent($decoded); + if ($chunk !== null) { yield $chunk; } } @@ -228,48 +230,60 @@ protected function throwIfStreamError(array $event): void } /** - * Maps one decoded stream event into result chunks, one per choice. + * Maps one decoded stream event into a result chunk. * * @since n.e.x.t * * @param StreamEventData $data The decoded event payload. - * @return iterable The chunks for this event. + * @return GenerativeAiResultChunk|null The chunk, or null when the event carries nothing usable. */ - protected function parseStreamEventToChunks(array $data): iterable + protected function parseStreamEvent(array $data): ?GenerativeAiResultChunk { $id = isset($data['id']) && is_string($data['id']) ? $data['id'] : null; $tokenUsage = isset($data['usage']) && is_array($data['usage']) ? $this->parseUsageData($data['usage']) : null; - $choices = isset($data['choices']) && is_array($data['choices']) ? $data['choices'] : []; - $additionalData = $this->extractAdditionalData($data); + $choices = isset($data['choices']) && is_array($data['choices']) ? $data['choices'] : []; - if ($choices === []) { - // Events with no choices (such as the final usage event) carry only result metadata. - if ($tokenUsage !== null || $id !== null || $additionalData !== []) { - yield new GenerativeAiResultChunk(null, [], null, $tokenUsage, $id, [], $additionalData); + $candidateDeltas = []; + foreach ($choices as $choice) { + if (!is_array($choice)) { + continue; } - return; + $candidateDeltas[] = $this->parseStreamChoice($choice); } - foreach ($choices as $choice) { - $index = isset($choice['index']) && is_int($choice['index']) ? $choice['index'] : 0; - $delta = isset($choice['delta']) && is_array($choice['delta']) ? $choice['delta'] : []; - $finishReason = isset($choice['finish_reason']) && is_string($choice['finish_reason']) - ? FinishReasonEnum::tryFrom($choice['finish_reason']) - : null; - - yield new GenerativeAiResultChunk( - $index, - $this->parseStreamDeltaParts($delta), - $finishReason, - $tokenUsage, - $id, - $this->parseStreamToolCallDeltas($delta), - $additionalData - ); + // Skip events that carry no metadata and no candidate deltas. + if ($id === null && $tokenUsage === null && $additionalData === [] && $candidateDeltas === []) { + return null; } + + return new GenerativeAiResultChunk($id, $tokenUsage, $additionalData, $candidateDeltas); + } + + /** + * Maps one streamed choice into a candidate delta. + * + * @since n.e.x.t + * + * @param StreamChoiceData $choice The choice payload from the event. + * @return CandidateDelta The parsed candidate delta. + */ + protected function parseStreamChoice(array $choice): CandidateDelta + { + $index = isset($choice['index']) && is_int($choice['index']) ? $choice['index'] : 0; + $delta = isset($choice['delta']) && is_array($choice['delta']) ? $choice['delta'] : []; + $finishReason = isset($choice['finish_reason']) && is_string($choice['finish_reason']) + ? FinishReasonEnum::tryFrom($choice['finish_reason']) + : null; + + return new CandidateDelta( + $index, + $this->parseStreamDeltaParts($delta), + $finishReason, + $this->parseStreamToolCallDeltas($delta) + ); } /** @@ -278,7 +292,7 @@ protected function parseStreamEventToChunks(array $data): iterable * @since n.e.x.t * * @param StreamDeltaData $delta The delta payload from a choice. - * @return MessagePart[] The parsed message parts. + * @return list The parsed message parts. */ protected function parseStreamDeltaParts(array $delta): array { From baab7a3afa1bcd73e69d4741a4f063904900ff61 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 12:55:16 +0530 Subject: [PATCH 40/71] Update ServerSentEvent class path --- .../Http/Streaming/Contracts/EventStreamParserInterface.php | 2 +- src/Providers/Http/Streaming/SseEventStreamParser.php | 1 + .../Http/Streaming/{ => ValueObjects}/ServerSentEvent.php | 2 +- .../unit/Providers/Http/Streaming/SseEventStreamParserTest.php | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) rename src/Providers/Http/Streaming/{ => ValueObjects}/ServerSentEvent.php (97%) diff --git a/src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php b/src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php index e0d08058..b67f38ba 100644 --- a/src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php +++ b/src/Providers/Http/Streaming/Contracts/EventStreamParserInterface.php @@ -5,7 +5,7 @@ namespace WordPress\AiClient\Providers\Http\Streaming\Contracts; use Psr\Http\Message\StreamInterface; -use WordPress\AiClient\Providers\Http\Streaming\ServerSentEvent; +use WordPress\AiClient\Providers\Http\Streaming\ValueObjects\ServerSentEvent; /** * Decodes a response body stream into discrete events. diff --git a/src/Providers/Http/Streaming/SseEventStreamParser.php b/src/Providers/Http/Streaming/SseEventStreamParser.php index 6666d3ff..c9dd0e95 100644 --- a/src/Providers/Http/Streaming/SseEventStreamParser.php +++ b/src/Providers/Http/Streaming/SseEventStreamParser.php @@ -6,6 +6,7 @@ use Psr\Http\Message\StreamInterface; use WordPress\AiClient\Providers\Http\Streaming\Contracts\EventStreamParserInterface; +use WordPress\AiClient\Providers\Http\Streaming\ValueObjects\ServerSentEvent; /** * Parses a Server-Sent Events (`text/event-stream`) response body. diff --git a/src/Providers/Http/Streaming/ServerSentEvent.php b/src/Providers/Http/Streaming/ValueObjects/ServerSentEvent.php similarity index 97% rename from src/Providers/Http/Streaming/ServerSentEvent.php rename to src/Providers/Http/Streaming/ValueObjects/ServerSentEvent.php index e727fc41..ecbdd87f 100644 --- a/src/Providers/Http/Streaming/ServerSentEvent.php +++ b/src/Providers/Http/Streaming/ValueObjects/ServerSentEvent.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers\Http\Streaming; +namespace WordPress\AiClient\Providers\Http\Streaming\ValueObjects; /** * Represents a single decoded event from a Server-Sent Events stream. diff --git a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php index e1e5a339..229b9641 100644 --- a/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php +++ b/tests/unit/Providers/Http/Streaming/SseEventStreamParserTest.php @@ -5,8 +5,8 @@ namespace WordPress\AiClient\Tests\unit\Providers\Http\Streaming; use PHPUnit\Framework\TestCase; -use WordPress\AiClient\Providers\Http\Streaming\ServerSentEvent; use WordPress\AiClient\Providers\Http\Streaming\SseEventStreamParser; +use WordPress\AiClient\Providers\Http\Streaming\ValueObjects\ServerSentEvent; use WordPress\AiClient\Tests\mocks\ChunkStream; /** From a0937e54ae08ef1f428e73a27975a847179a421d Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 12:55:35 +0530 Subject: [PATCH 41/71] Fix docblock --- src/Results/ValueObjects/GenerativeAiResultChunk.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Results/ValueObjects/GenerativeAiResultChunk.php b/src/Results/ValueObjects/GenerativeAiResultChunk.php index 7d9800a6..a57f4c75 100644 --- a/src/Results/ValueObjects/GenerativeAiResultChunk.php +++ b/src/Results/ValueObjects/GenerativeAiResultChunk.php @@ -125,8 +125,6 @@ public function getDeltaText(): string /** * Gets the reasoning (thought) text delta carried by this chunk. * - * Convenience for single-candidate streaming; see getDeltaText(). - * * @since n.e.x.t * * @return string The reasoning text delta, or an empty string when this chunk carries none. From e5ddc7091a4bc5daf2e9da076549531397cb203b Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 15:04:17 +0530 Subject: [PATCH 42/71] Add exception if stream is being consumed again --- src/Results/StreamedGenerativeAiResult.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php index ca8e69e5..5ed6b811 100644 --- a/src/Results/StreamedGenerativeAiResult.php +++ b/src/Results/StreamedGenerativeAiResult.php @@ -85,9 +85,17 @@ public function onComplete(callable $callback): self * @since n.e.x.t * * @return Generator The chunks, in order. + * + * @throws RuntimeException If the source stream has already been consumed. */ public function getIterator(): Generator { + if ($this->started) { + throw new RuntimeException( + 'This streamed result has already been consumed; the stream can be read only once.' + ); + } + while (true) { $chunk = $this->pull(); if ($chunk === null) { @@ -124,10 +132,7 @@ public function getFinalResult(): GenerativeAiResult } /** - * Assembles the result once and runs the completion callbacks. - * - * A no-op if the result is already built or the stream produced no candidates, - * so a fully iterated empty stream does not fire the completion callbacks. + * Assembles the result and runs the completion callbacks. * * @since n.e.x.t * @@ -135,7 +140,7 @@ public function getFinalResult(): GenerativeAiResult */ private function finalize(): void { - if ($this->result !== null || !$this->accumulator->hasCandidates()) { + if (!$this->accumulator->hasCandidates()) { return; } @@ -162,6 +167,8 @@ private function pull(): ?GenerativeAiResultChunk if (!$this->started) { $this->chunks->rewind(); $this->started = true; + } else { + $this->chunks->next(); } if (!$this->chunks->valid()) { @@ -171,7 +178,6 @@ private function pull(): ?GenerativeAiResultChunk $chunk = $this->chunks->current(); $this->accumulator->add($chunk); - $this->chunks->next(); return $chunk; } From d9e18099858de3e2c1120e7aca404fe069ea7a0e Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 15:04:34 +0530 Subject: [PATCH 43/71] Add mock to simulate stream failure --- tests/mocks/FailingChunkStream.php | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/mocks/FailingChunkStream.php diff --git a/tests/mocks/FailingChunkStream.php b/tests/mocks/FailingChunkStream.php new file mode 100644 index 00000000..a931791b --- /dev/null +++ b/tests/mocks/FailingChunkStream.php @@ -0,0 +1,48 @@ + $chunks The chunks to deliver before failing. + * @param string $errorMessage The message of the error thrown afterwards. + */ + public function __construct(array $chunks, string $errorMessage = 'Connection reset by peer') + { + parent::__construct($chunks); + $this->errorMessage = $errorMessage; + } + + /** + * Never reports end-of-stream, so the reader keeps reading until the failure. + */ + public function eof(): bool + { + return false; + } + + /** + * Returns the next chunk, or throws once the chunks are exhausted. + */ + public function read(int $length): string + { + if (parent::eof()) { + throw new RuntimeException($this->errorMessage); + } + + return parent::read($length); + } +} From d197a79ff8bfa20679ba1e443681582824c72b4f Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 15:05:36 +0530 Subject: [PATCH 44/71] Add stream helpers in openai compat model mock --- ...ockOpenAiCompatibleTextGenerationModel.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php index 4ddd3d7f..9d8edbad 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/MockOpenAiCompatibleTextGenerationModel.php @@ -17,6 +17,10 @@ use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Results\ValueObjects\CandidateDelta; +use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; +use WordPress\AiClient\Results\ValueObjects\ToolCallDelta; /** * Mock class for testing AbstractOpenAiCompatibleTextGenerationModel. @@ -177,4 +181,54 @@ public function exposeParseResponseChoiceMessageToolCallPart(array $toolCallData { return $this->parseResponseChoiceMessageToolCallPart($toolCallData); } + + public function exposeThrowIfStreamError(array $event): void + { + $this->throwIfStreamError($event); + } + + public function exposeParseStreamEvent(array $data): ?GenerativeAiResultChunk + { + return $this->parseStreamEvent($data); + } + + public function exposeParseStreamChoice(array $choice): CandidateDelta + { + return $this->parseStreamChoice($choice); + } + + /** + * @param array $delta + * @return list + */ + public function exposeParseStreamDeltaParts(array $delta): array + { + return $this->parseStreamDeltaParts($delta); + } + + /** + * @param array $delta + * @return list + */ + public function exposeParseStreamToolCallDeltas(array $delta): array + { + return $this->parseStreamToolCallDeltas($delta); + } + + /** + * @param array $usage + */ + public function exposeParseUsageData(array $usage): TokenUsage + { + return $this->parseUsageData($usage); + } + + /** + * @param array $data + * @return array + */ + public function exposeExtractAdditionalData(array $data): array + { + return $this->extractAdditionalData($data); + } } From a216e622137e1e6d294dc47e9f39c8b690f8fd06 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 15:15:12 +0530 Subject: [PATCH 45/71] Add test cases for ChunkAccumulator --- tests/unit/Results/ChunkAccumulatorTest.php | 521 ++++++++++++++++++++ 1 file changed, 521 insertions(+) create mode 100644 tests/unit/Results/ChunkAccumulatorTest.php diff --git a/tests/unit/Results/ChunkAccumulatorTest.php b/tests/unit/Results/ChunkAccumulatorTest.php new file mode 100644 index 00000000..2607bb01 --- /dev/null +++ b/tests/unit/Results/ChunkAccumulatorTest.php @@ -0,0 +1,521 @@ +createTestProviderMetadata(), $this->createTestModelMetadata()); + } + + /** + * Creates a content-channel message part. + * + * @param string $text The text. + * @param string|null $signature The thought signature. + * @return MessagePart + */ + private function createContentPart(string $text, ?string $signature = null): MessagePart + { + return new MessagePart($text, MessagePartChannelEnum::content(), $signature); + } + + /** + * Creates a thought-channel message part. + * + * @param string $text The text. + * @param string|null $signature The thought signature. + * @return MessagePart + */ + private function createReasoningPart(string $text, ?string $signature = null): MessagePart + { + return new MessagePart($text, MessagePartChannelEnum::thought(), $signature); + } + + /** + * Creates a chunk. + * + * @param string|null $id The result id. + * @param TokenUsage|null $usage The token usage. + * @param array $additionalData The provider metadata. + * @param list $candidateDeltas The candidate deltas. + * @return GenerativeAiResultChunk + */ + private function createChunk( + ?string $id = null, + ?TokenUsage $usage = null, + array $additionalData = [], + array $candidateDeltas = [] + ): GenerativeAiResultChunk { + return new GenerativeAiResultChunk($id, $usage, $additionalData, $candidateDeltas); + } + + /** + * The id is captured from the first chunk that reports one; later ids are ignored. + */ + public function testCapturesIdFromFirstChunkThatReportsOne(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('hi')])])); + $acc->add($this->createChunk('first', null, [], [])); + $acc->add($this->createChunk('second', null, [], [])); + + $this->assertSame('first', $acc->build()->getId()); + } + + /** + * Token usage is last-wins (the final usage event is authoritative). + */ + public function testTokenUsageIsLastWins(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('hi')])])); + $acc->add($this->createChunk(null, new TokenUsage(1, 1, 2), [], [])); + $acc->add($this->createChunk(null, new TokenUsage(10, 20, 30), [], [])); + + $usage = $acc->build()->getTokenUsage(); + $this->assertSame(10, $usage->getPromptTokens()); + $this->assertSame(30, $usage->getTotalTokens()); + } + + /** + * Additional data is merged across chunks, with later chunks winning on conflicts. + */ + public function testAdditionalDataMergedWithLaterChunksWinning(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, ['model' => 'x', 'a' => 1], [ + new CandidateDelta(0, [$this->createContentPart('hi')]), + ])); + $acc->add($this->createChunk(null, null, [], [])); + $acc->add($this->createChunk(null, null, ['model' => 'y', 'b' => 2], [])); + + $this->assertSame(['model' => 'y', 'a' => 1, 'b' => 2], $acc->build()->getAdditionalData()); + } + + /** + * Text on the same channel is concatenated across deltas into one part. + */ + public function testTextConcatenatedPerChannel(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('Hel')])])); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('lo')])])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(1, $parts); + $this->assertSame('Hello', $parts[0]->getText()); + $this->assertTrue($parts[0]->getChannel()->is(MessagePartChannelEnum::content())); + } + + /** + * Reasoning and content become separate parts, in arrival (channel-first-seen) order. + */ + public function testReasoningAndContentAreSeparatePartsInArrivalOrder(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [$this->createReasoningPart('because'), $this->createContentPart('Hi')]), + ])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(2, $parts); + $this->assertTrue($parts[0]->getChannel()->is(MessagePartChannelEnum::thought())); + $this->assertSame('because', $parts[0]->getText()); + $this->assertTrue($parts[1]->getChannel()->is(MessagePartChannelEnum::content())); + $this->assertSame('Hi', $parts[1]->getText()); + } + + /** + * The thought signature is captured last-wins per channel; a null signature does not clear it. + */ + public function testThoughtSignatureCapturedLastWinsPerChannel(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [ + $this->createReasoningPart('a', 's1'), + $this->createReasoningPart('b', 's2'), + $this->createReasoningPart('c', null), + ]), + ])); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('hi')])])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertSame('abc', $parts[0]->getText()); + $this->assertSame('s2', $parts[0]->getThoughtSignature()); + $this->assertNull($parts[1]->getThoughtSignature()); + } + + /** + * The finish reason defaults to stop when no delta reports one. + */ + public function testFinishReasonDefaultsToStopWhenNotReported(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('hi')])])); + + $this->assertTrue($acc->build()->getCandidates()[0]->getFinishReason()->is(FinishReasonEnum::stop())); + } + + /** + * The reported finish reason is used when present. + */ + public function testFinishReasonUsedWhenReported(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [$this->createContentPart('hi')], FinishReasonEnum::length()), + ])); + + $this->assertTrue($acc->build()->getCandidates()[0]->getFinishReason()->is(FinishReasonEnum::length())); + } + + /** + * A non-text part is kept whole and placed after the text parts. + */ + public function testNonTextPartIsKeptAndPlacedAfterText(): void + { + $acc = $this->createAccumulator(); + $functionCallPart = new MessagePart(new FunctionCall('id', 'fn', ['k' => 'v'])); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [$this->createContentPart('Hi'), $functionCallPart]), + ])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(2, $parts); + $this->assertSame('Hi', $parts[0]->getText()); + $this->assertNotNull($parts[1]->getFunctionCall()); + $this->assertSame('fn', $parts[1]->getFunctionCall()->getName()); + } + + /** + * Tool-call fragments are stitched by slot into a complete function call. + */ + public function testToolCallReassembledFromFragments(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(0, 'call_1', 'get_weather', '{"loc')]), + ])); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(0, null, null, 'ation":"SF"}')]), + ])); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [], FinishReasonEnum::toolCalls())])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(1, $parts); + $fc = $parts[0]->getFunctionCall(); + $this->assertNotNull($fc); + $this->assertSame('call_1', $fc->getId()); + $this->assertSame('get_weather', $fc->getName()); + $this->assertSame(['location' => 'SF'], $fc->getArgs()); + } + + /** + * Tool-call id and name are first-wins across fragments. + */ + public function testToolCallIdAndNameAreFirstWins(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(0, 'first-id', null, '')]), + ])); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(0, 'ignored-id', 'the_fn', '{}')]), + ])); + + $fc = $acc->build()->getCandidates()[0]->getMessage()->getParts()[0]->getFunctionCall(); + $this->assertSame('first-id', $fc->getId()); + $this->assertSame('the_fn', $fc->getName()); + $this->assertSame([], $fc->getArgs()); + } + + /** + * Tool-call arguments: valid JSON decodes, broken JSON is kept raw, empty becomes null. + * + * @dataProvider toolCallArgumentsProvider + * + * @param string $argumentsFragment The accumulated arguments string. + * @param mixed $expected The expected decoded arguments. + */ + public function testDecodesToolCallArguments(string $argumentsFragment, $expected): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(0, 'id', 'fn', $argumentsFragment)]), + ])); + + $fc = $acc->build()->getCandidates()[0]->getMessage()->getParts()[0]->getFunctionCall(); + $this->assertSame($expected, $fc->getArgs()); + } + + /** + * @return array + */ + public function toolCallArgumentsProvider(): array + { + return [ + 'valid JSON decodes to an array' => ['{"city":"SF"}', ['city' => 'SF']], + 'nested JSON decodes recursively' => ['{"a":{"b":1}}', ['a' => ['b' => 1]]], + 'broken JSON is kept as the raw string' => ['{"city":"SF', '{"city":"SF'], + 'empty arguments become null' => ['', null], + ]; + } + + /** + * Parallel tool calls are emitted in slot-index order regardless of arrival order. + */ + public function testParallelToolCallsAreOrderedBySlot(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [ + new ToolCallDelta(1, 'call_b', 'fn_b', '{"y":2}'), + new ToolCallDelta(0, 'call_a', 'fn_a', '{"x":1}'), + ]), + ])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(2, $parts); + $this->assertSame('fn_a', $parts[0]->getFunctionCall()->getName()); + $this->assertSame('fn_b', $parts[1]->getFunctionCall()->getName()); + } + + /** + * A tool-call slot that never received an id or name is skipped (no crash, no part). + */ + public function testToolCallSlotWithoutIdOrNameIsSkipped(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [$this->createContentPart('hi')], null, [new ToolCallDelta(0, null, null, '{"x":1}')]), + ])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(1, $parts); + $this->assertSame('hi', $parts[0]->getText()); + } + + /** + * Tool-call fragments without an index stitch into slot 0. + */ + public function testToolCallDeltaWithoutIndexUsesSlotZero(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(null, 'id', 'fn', '{"a":')]), + ])); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(null, null, null, '1}')]), + ])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(1, $parts); + $this->assertSame(['a' => 1], $parts[0]->getFunctionCall()->getArgs()); + } + + /** + * Candidates are separated by index and emitted in sorted index order. + */ + public function testCandidatesAreSeparatedAndSortedByIndex(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(1, [$this->createContentPart('B')]), + new CandidateDelta(0, [$this->createContentPart('A')]), + ])); + + $candidates = $acc->build()->getCandidates(); + $this->assertCount(2, $candidates); + $this->assertSame('A', $candidates[0]->getMessage()->getParts()[0]->getText()); + $this->assertSame('B', $candidates[1]->getMessage()->getParts()[0]->getText()); + } + + /** + * hasCandidates() reflects whether any candidate delta has been folded in. + */ + public function testHasCandidatesReflectsAccumulatedState(): void + { + $acc = $this->createAccumulator(); + $this->assertFalse($acc->hasCandidates()); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('hi')])])); + $this->assertTrue($acc->hasCandidates()); + } + + /** + * A metadata-only chunk (no candidate deltas) registers no candidate. + */ + public function testMetadataOnlyChunkRegistersNoCandidate(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk('id', new TokenUsage(1, 1, 2), ['model' => 'x'], [])); + + $this->assertFalse($acc->hasCandidates()); + } + + /** + * build() throws when no candidate was accumulated. + */ + public function testBuildThrowsWhenNoCandidates(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk('id', new TokenUsage(1, 1, 2), ['model' => 'x'], [])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The stream produced no candidates.'); + $acc->build(); + } + + /** + * build() applies defaults when id, usage, and additional data are absent. + */ + public function testBuildAppliesDefaultsWhenMetadataAbsent(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('hi')])])); + + $result = $acc->build(); + $this->assertSame('', $result->getId()); + $this->assertSame(0, $result->getTokenUsage()->getTotalTokens()); + $this->assertSame([], $result->getAdditionalData()); + } + + /** + * build() carries the provider and model metadata into the result. + */ + public function testBuildUsesProviderAndModelMetadata(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('hi')])])); + + $result = $acc->build(); + $this->assertSame('test-provider', $result->getProviderMetadata()->getId()); + $this->assertSame('test-model', $result->getModelMetadata()->getId()); + } + + /** + * A candidate that received no content builds a valid, empty message. + */ + public function testCandidateWithNoPartsBuildsEmptyMessage(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [], FinishReasonEnum::contentFilter())])); + + $candidate = $acc->build()->getCandidates()[0]; + $this->assertCount(0, $candidate->getMessage()->getParts()); + $this->assertTrue($candidate->getFinishReason()->is(FinishReasonEnum::contentFilter())); + } + + /** + * Tool-call arguments reassemble correctly no matter where the fragment boundaries fall. + */ + public function testToolCallArgumentsReassembleFromManySmallFragments(): void + { + $acc = $this->createAccumulator(); + $pieces = ['{', '"ci', 'ty":', ' "San ', 'Francisco"', '}']; + foreach ($pieces as $i => $piece) { + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [ + new ToolCallDelta(0, $i === 0 ? 'call_1' : null, $i === 0 ? 'get_weather' : null, $piece), + ]), + ])); + } + + $fc = $acc->build()->getCandidates()[0]->getMessage()->getParts()[0]->getFunctionCall(); + $this->assertSame('get_weather', $fc->getName()); + $this->assertSame(['city' => 'San Francisco'], $fc->getArgs()); + } + + /** + * Parallel tool-call fragments interleaved across separate deltas stitch per slot. + */ + public function testParallelToolCallFragmentsInterleaveAcrossDeltas(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [ + new ToolCallDelta(0, 'a', 'fn_a', '{"x":'), + new ToolCallDelta(1, 'b', 'fn_b', '{"y":'), + ]), + ])); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(0, null, null, '1}')]), + ])); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(1, null, null, '2}')]), + ])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(2, $parts); + $this->assertSame('fn_a', $parts[0]->getFunctionCall()->getName()); + $this->assertSame(['x' => 1], $parts[0]->getFunctionCall()->getArgs()); + $this->assertSame('fn_b', $parts[1]->getFunctionCall()->getName()); + $this->assertSame(['y' => 2], $parts[1]->getFunctionCall()->getArgs()); + } + + /** + * Reasoning and content interleaved across deltas concatenate per channel, not merge. + */ + public function testReasoningAndContentInterleaveAcrossDeltas(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createReasoningPart('think ')])])); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('Hi ')])])); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createReasoningPart('more')])])); + $acc->add($this->createChunk(null, null, [], [new CandidateDelta(0, [$this->createContentPart('there')])])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(2, $parts); + $this->assertTrue($parts[0]->getChannel()->is(MessagePartChannelEnum::thought())); + $this->assertSame('think more', $parts[0]->getText()); + $this->assertTrue($parts[1]->getChannel()->is(MessagePartChannelEnum::content())); + $this->assertSame('Hi there', $parts[1]->getText()); + } +} From 7822fd95040a26eec2b73683bc7ba60fb0c62dc8 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 15:19:40 +0530 Subject: [PATCH 46/71] Add test cases for StreamedGenerativeAiResult --- .../StreamedGenerativeAiResultTest.php | 504 ++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 tests/unit/Results/StreamedGenerativeAiResultTest.php diff --git a/tests/unit/Results/StreamedGenerativeAiResultTest.php b/tests/unit/Results/StreamedGenerativeAiResultTest.php new file mode 100644 index 00000000..8f7d78a1 --- /dev/null +++ b/tests/unit/Results/StreamedGenerativeAiResultTest.php @@ -0,0 +1,504 @@ + $chunks The chunk stream. + * @return StreamedGenerativeAiResult + */ + private function createHandle(Iterator $chunks): StreamedGenerativeAiResult + { + return new StreamedGenerativeAiResult( + $chunks, + $this->createTestProviderMetadata(), + $this->createTestModelMetadata() + ); + } + + /** + * Creates a handle over a fixed list of chunks. + * + * @param list $chunks The chunks. + * @return StreamedGenerativeAiResult + */ + private function createHandleFromChunks(array $chunks): StreamedGenerativeAiResult + { + return $this->createHandle(new ArrayIterator($chunks)); + } + + /** + * Creates a content chunk for candidate 0. + * + * @param string $text The content text delta. + * @param FinishReasonEnum|null $finishReason The finish reason, if any. + * @return GenerativeAiResultChunk + */ + private function createContentChunk(string $text, ?FinishReasonEnum $finishReason = null): GenerativeAiResultChunk + { + return new GenerativeAiResultChunk(null, null, [], [ + new CandidateDelta(0, [new MessagePart($text)], $finishReason), + ]); + } + + /** + * Creates a metadata-only chunk carrying token usage. + * + * @param TokenUsage $usage The usage. + * @return GenerativeAiResultChunk + */ + private function createUsageChunk(TokenUsage $usage): GenerativeAiResultChunk + { + return new GenerativeAiResultChunk(null, $usage, [], []); + } + + /** + * Yields the given chunks, then throws. + * + * @param list $chunks The chunks to yield before failing. + * @param \Throwable $error The error to throw after the chunks. + * @return Generator + */ + private function createFailingIterator(array $chunks, \Throwable $error): Generator + { + foreach ($chunks as $chunk) { + yield $chunk; + } + throw $error; + } + + /** + * Creates a strict single-use source that fails if it is read again after exhaustion. + * + * This mirrors a real consumed stream (an HTTP/SSE body cannot be re-read), so a handle that + * re-touches an exhausted source surfaces a `LogicException` instead of finishing cleanly. + * + * @param list $chunks The chunks to yield once. + * @return Iterator + */ + private function createSingleUseSource(array $chunks): Iterator + { + return new class ($chunks) implements Iterator { + /** @var list */ + private array $chunks; + private int $pos = 0; + private bool $exhausted = false; + + /** @param list $chunks */ + public function __construct(array $chunks) + { + $this->chunks = array_values($chunks); + } + + public function current(): GenerativeAiResultChunk + { + $this->guardNotExhausted(); + return $this->chunks[$this->pos]; + } + + public function next(): void + { + $this->guardNotExhausted(); + $this->pos++; + if ($this->pos >= count($this->chunks)) { + $this->exhausted = true; + } + } + + public function key(): int + { + return $this->pos; + } + + public function valid(): bool + { + return $this->pos < count($this->chunks); + } + + public function rewind(): void + { + if ($this->pos !== 0 || $this->exhausted) { + throw new \LogicException('A single-use source cannot be rewound.'); + } + } + + private function guardNotExhausted(): void + { + if ($this->exhausted) { + throw new \LogicException('A single-use source cannot be read after exhaustion.'); + } + } + }; + } + + /** + * Iterating yields every chunk, in order. + */ + public function testIteratingYieldsAllChunksInOrder(): void + { + $a = $this->createContentChunk('a'); + $b = $this->createContentChunk('b'); + $c = $this->createContentChunk('c'); + + $collected = []; + foreach ($this->createHandleFromChunks([$a, $b, $c]) as $chunk) { + $collected[] = $chunk; + } + + $this->assertSame([$a, $b, $c], $collected); + } + + /** + * getFinalResult() assembles the result without the caller iterating. + */ + public function testGetFinalResultAssemblesWithoutIterating(): void + { + $result = $this->createHandleFromChunks([ + $this->createContentChunk('Hel'), + $this->createContentChunk('lo', FinishReasonEnum::stop()), + $this->createUsageChunk(new TokenUsage(3, 5, 8)), + ])->getFinalResult(); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertSame('Hello', $result->toText()); + $this->assertSame(8, $result->getTokenUsage()->getTotalTokens()); + $this->assertTrue($result->getCandidates()[0]->getFinishReason()->is(FinishReasonEnum::stop())); + } + + /** + * Iterating and then calling getFinalResult() yields the same assembled result. + */ + public function testIterateThenGetFinalResultIsConsistent(): void + { + $handle = $this->createHandleFromChunks([ + $this->createContentChunk('Hel'), + $this->createContentChunk('lo', FinishReasonEnum::stop()), + ]); + + foreach ($handle as $chunk) { + // drain + } + + $this->assertSame('Hello', $handle->getFinalResult()->toText()); + } + + /** + * getFinalResult() returns the same instance on repeated calls. + */ + public function testGetFinalResultIsIdempotent(): void + { + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + + $first = $handle->getFinalResult(); + $second = $handle->getFinalResult(); + + $this->assertSame($first, $second); + } + + /** + * getFinalResult() after an early break drains the remainder and returns the full result. + */ + public function testGetFinalResultAfterEarlyBreakDrainsRemainder(): void + { + $handle = $this->createHandleFromChunks([ + $this->createContentChunk('a'), + $this->createContentChunk('b'), + $this->createContentChunk('c', FinishReasonEnum::stop()), + ]); + + foreach ($handle as $chunk) { + break; + } + + $this->assertSame('abc', $handle->getFinalResult()->toText()); + } + + /** + * The completion callback fires once, with the result, after a full iteration. + */ + public function testOnCompleteFiresOnceWithResultOnFullIteration(): void + { + $received = []; + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->onComplete(function (GenerativeAiResult $result) use (&$received) { + $received[] = $result; + }); + + foreach ($handle as $chunk) { + // drain + } + + $this->assertCount(1, $received); + $this->assertSame($handle->getFinalResult(), $received[0]); + } + + /** + * The completion callback fires on getFinalResult() without iterating. + */ + public function testOnCompleteFiresOnGetFinalResult(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->onComplete(function () use (&$count) { + $count++; + }); + + $handle->getFinalResult(); + + $this->assertSame(1, $count); + } + + /** + * The completion callback fires only once across iteration and getFinalResult(). + */ + public function testOnCompleteFiresOnlyOnceAcrossIterateAndGetFinalResult(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->onComplete(function () use (&$count) { + $count++; + }); + + foreach ($handle as $chunk) { + // drain + } + $handle->getFinalResult(); + + $this->assertSame(1, $count); + } + + /** + * Multiple completion callbacks all fire, in registration order. + */ + public function testMultipleOnCompleteCallbacksFireInRegistrationOrder(): void + { + $order = []; + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->onComplete(function () use (&$order) { + $order[] = 'first'; + }); + $handle->onComplete(function () use (&$order) { + $order[] = 'second'; + }); + + $handle->getFinalResult(); + + $this->assertSame(['first', 'second'], $order); + } + + /** + * The completion callback does not fire when the caller breaks before the stream ends. + */ + public function testOnCompleteNotFiredOnEarlyBreak(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([ + $this->createContentChunk('a'), + $this->createContentChunk('b', FinishReasonEnum::stop()), + ]); + $handle->onComplete(function () use (&$count) { + $count++; + }); + + foreach ($handle as $chunk) { + break; + } + + $this->assertSame(0, $count); + } + + /** + * An empty stream throws on getFinalResult() and never fires the completion callback. + */ + public function testEmptyStreamThrowsAndDoesNotFireOnComplete(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([$this->createUsageChunk(new TokenUsage(1, 1, 2))]); + $handle->onComplete(function () use (&$count) { + $count++; + }); + + try { + $handle->getFinalResult(); + $this->fail('Expected RuntimeException for a stream with no candidates.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('no candidates', $e->getMessage()); + } + + $this->assertSame(0, $count); + } + + /** + * After a candidate-less stream is drained by iteration, getFinalResult() still throws the + * no-candidates error and does not re-read the already-exhausted source. + */ + public function testGetFinalResultAfterIteratingEmptyStreamThrowsWithoutReReadingSource(): void + { + $count = 0; + $handle = $this->createHandle( + $this->createSingleUseSource([$this->createUsageChunk(new TokenUsage(1, 1, 2))]) + ); + $handle->onComplete(function () use (&$count) { + $count++; + }); + + foreach ($handle as $chunk) { + // drain the metadata-only stream to completion + } + + try { + $handle->getFinalResult(); + $this->fail('Expected RuntimeException for a stream with no candidates.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('no candidates', $e->getMessage()); + } + + $this->assertSame(0, $count); + } + + /** + * A stream that ends without a finish reason resolves with the default (stop). + */ + public function testPartialStreamWithoutFinishReasonDefaultsToStop(): void + { + $result = $this->createHandleFromChunks([$this->createContentChunk('partial')])->getFinalResult(); + + $this->assertSame('partial', $result->toText()); + $this->assertTrue($result->getCandidates()[0]->getFinishReason()->is(FinishReasonEnum::stop())); + } + + /** + * Iterating yields every chunk produced before an error, then propagates the error. + */ + public function testIterationYieldsChunksBeforeAnErrorThenPropagates(): void + { + $a = $this->createContentChunk('a'); + $b = $this->createContentChunk('b'); + $handle = $this->createHandle( + $this->createFailingIterator([$a, $b], new RuntimeException('stream failed')) + ); + + $collected = []; + $thrown = null; + try { + foreach ($handle as $chunk) { + $collected[] = $chunk; + } + } catch (RuntimeException $e) { + $thrown = $e; + } + + $this->assertSame([$a, $b], $collected); + $this->assertNotNull($thrown); + $this->assertSame('stream failed', $thrown->getMessage()); + } + + /** + * getFinalResult() propagates an error raised while draining the stream. + */ + public function testGetFinalResultPropagatesStreamError(): void + { + $handle = $this->createHandle( + $this->createFailingIterator([$this->createContentChunk('a')], new RuntimeException('stream failed')) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('stream failed'); + $handle->getFinalResult(); + } + + /** + * The completion callback does not fire when the stream errors. + */ + public function testOnCompleteNotFiredWhenStreamErrors(): void + { + $count = 0; + $handle = $this->createHandle( + $this->createFailingIterator([$this->createContentChunk('a')], new RuntimeException('stream failed')) + ); + $handle->onComplete(function () use (&$count) { + $count++; + }); + + try { + foreach ($handle as $chunk) { + // drain + } + } catch (RuntimeException $e) { + // expected + } + + $this->assertSame(0, $count); + } + + /** + * The stream is single-use: a second iteration throws rather than silently yielding nothing. + */ + public function testReiteratingAConsumedStreamThrows(): void + { + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + + foreach ($handle as $chunk) { + // drain + } + + $this->expectException(RuntimeException::class); + foreach ($handle as $chunk) { + // second iteration must not silently yield nothing + } + } + + /** + * Iterating after getFinalResult() throws (the stream has already been consumed). + */ + public function testIteratingAfterGetFinalResultThrows(): void + { + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->getFinalResult(); + + $this->expectException(RuntimeException::class); + foreach ($handle as $chunk) { + // already consumed + } + } +} From 4a6182b36012eee28fc331a2ece086f3b8962ded Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 15:31:56 +0530 Subject: [PATCH 47/71] Add test case for streaming in open ai compat text gen modal --- ...penAiCompatibleTextGenerationModelTest.php | 820 ++++++++++++++++++ 1 file changed, 820 insertions(+) diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6c99c75b..c3eeec11 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -15,6 +16,8 @@ use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\Contracts\HttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\RequestAuthenticationInterface; +use WordPress\AiClient\Providers\Http\DTO\Request; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Exception\ClientException; use WordPress\AiClient\Providers\Http\Exception\ResponseException; @@ -22,7 +25,13 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Results\StreamedGenerativeAiResult; +use WordPress\AiClient\Results\ValueObjects\CandidateDelta; +use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; +use WordPress\AiClient\Tests\mocks\ChunkStream; +use WordPress\AiClient\Tests\mocks\FailingChunkStream; use WordPress\AiClient\Tools\DTO\FunctionCall; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\FunctionResponse; @@ -1328,4 +1337,815 @@ public function testGetMessagePartContentDataThoughtPart(): void // Should be skipped because OpenAI API doesn't support receiving thoughts. $this->assertNull($data); } + + /** + * @return list + */ + private function createStreamPrompt(): array + { + return [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + } + + /** + * Builds one SSE "data:" frame for the given decoded payload. + * + * @param array $payload + */ + private function createSseDataLine(array $payload): string + { + return 'data: ' . json_encode($payload) . "\n\n"; + } + + /** + * Wraps SSE frames in a streamed Response, one frame per read. + * + * @param list $sseFrames + */ + private function createStreamResponse(array $sseFrames, int $statusCode = 200): Response + { + return new Response($statusCode, [], new ChunkStream($sseFrames)); + } + + /** + * Configures auth passthrough and the transporter to return the given response. + */ + private function givenStreamResponse(Response $response): void + { + $this->mockRequestAuthentication->method('authenticateRequest')->willReturnArgument(0); + $this->mockHttpTransporter->method('send')->willReturn($response); + } + + /** + * Drains a handle into a list of chunks. + * + * @return list + */ + private function consumeChunks(StreamedGenerativeAiResult $handle): array + { + return array_values(iterator_to_array($handle, false)); + } + + /** + * Creating the handle is lazy: no HTTP request is sent until it is consumed. + */ + public function testStreamGenerateTextResultReturnsHandleWithoutPerformingRequest(): void + { + $this->mockHttpTransporter->expects($this->never())->method('send'); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + + $this->assertInstanceOf(StreamedGenerativeAiResult::class, $handle); + } + + /** + * The streamed request opts into streaming and asks the provider to include usage. + */ + public function testStreamGenerateTextResultEnablesStreamingOnTheRequest(): void + { + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturnCallback(function (Request $request, ?RequestOptions $options = null) { + $params = $request->getData() ?? []; + $this->assertTrue($params['stream'] ?? null); + $this->assertSame(['include_usage' => true], $params['stream_options'] ?? null); + $this->assertInstanceOf(RequestOptions::class, $options); + $this->assertTrue($options->isStream()); + + return $this->createStreamResponse(["data: [DONE]\n\n"]); + }); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + + // Consuming triggers the request; a DONE-only stream yields no chunks. + $this->assertSame([], $this->consumeChunks($handle)); + } + + /** + * Content deltas are concatenated and the trailing usage/finish frames are folded in. + */ + public function testStreamAssemblesContentFinishReasonAndUsage(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine([ + 'id' => 'chatcmpl-1', + 'choices' => [['index' => 0, 'delta' => ['role' => 'assistant', 'content' => 'Hel']]], + ]), + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['content' => 'lo']]]]), + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => [], 'finish_reason' => 'stop']]]), + $this->createSseDataLine([ + 'choices' => [], + 'usage' => ['prompt_tokens' => 10, 'completion_tokens' => 5, 'total_tokens' => 15], + ]), + "data: [DONE]\n\n", + ])); + + $result = $this->createModel() + ->streamGenerateTextResult($this->createStreamPrompt()) + ->getFinalResult(); + + $this->assertSame('chatcmpl-1', $result->getId()); + $this->assertSame('Hello', $result->toText()); + $this->assertTrue($result->getCandidates()[0]->getFinishReason()->is(FinishReasonEnum::stop())); + $this->assertSame(10, $result->getTokenUsage()->getPromptTokens()); + $this->assertSame(5, $result->getTokenUsage()->getCompletionTokens()); + $this->assertSame(15, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Each usable SSE event surfaces as its own chunk; empty and [DONE] frames do not. + */ + public function testStreamYieldsOneChunkPerEventAndSkipsTheDoneSentinel(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['content' => 'Hel']]]]), + "data: \n\n", + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['content' => 'lo']]]]), + $this->createSseDataLine([ + 'choices' => [], + 'usage' => ['prompt_tokens' => 1, 'completion_tokens' => 2, 'total_tokens' => 3], + ]), + "data: [DONE]\n\n", + ])); + + $chunks = $this->consumeChunks( + $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()) + ); + + $this->assertCount(3, $chunks); + $this->assertSame('Hel', $chunks[0]->getDeltaText()); + $this->assertSame('lo', $chunks[1]->getDeltaText()); + $this->assertSame('', $chunks[2]->getDeltaText()); + $this->assertInstanceOf(TokenUsage::class, $chunks[2]->getTokenUsage()); + $this->assertSame(3, $chunks[2]->getTokenUsage()->getTotalTokens()); + } + + /** + * Provider-specific top-level fields are preserved as additional data on chunks and the result. + */ + public function testStreamExtractsAdditionalDataIntoChunksAndResult(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine([ + 'id' => 'chatcmpl-1', + 'object' => 'chat.completion.chunk', + 'system_fingerprint' => 'fp_abc', + 'choices' => [['index' => 0, 'delta' => ['content' => 'Hi'], 'finish_reason' => 'stop']], + 'usage' => ['prompt_tokens' => 1, 'completion_tokens' => 1, 'total_tokens' => 2], + ]), + "data: [DONE]\n\n", + ])); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + $chunks = $this->consumeChunks($handle); + + $expected = ['object' => 'chat.completion.chunk', 'system_fingerprint' => 'fp_abc']; + $this->assertSame($expected, $chunks[0]->getAdditionalData()); + $this->assertSame($expected, $handle->getFinalResult()->getAdditionalData()); + } + + /** + * `reasoning_content` and `reasoning` deltas land on the thought channel and reasoning + * tokens are captured from the usage details. + */ + public function testStreamRoutesReasoningToThoughtChannelWithThoughtTokens(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['reasoning_content' => 'Think']]]]), + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['reasoning' => 'ing']]]]), + $this->createSseDataLine([ + 'choices' => [['index' => 0, 'delta' => ['content' => 'Answer'], 'finish_reason' => 'stop']], + ]), + $this->createSseDataLine([ + 'choices' => [], + 'usage' => [ + 'prompt_tokens' => 15, + 'completion_tokens' => 20, + 'total_tokens' => 35, + 'completion_tokens_details' => ['reasoning_tokens' => 10], + ], + ]), + "data: [DONE]\n\n", + ])); + + $result = $this->createModel() + ->streamGenerateTextResult($this->createStreamPrompt()) + ->getFinalResult(); + + $thought = ''; + $content = ''; + foreach ($result->getCandidates()[0]->getMessage()->getParts() as $part) { + if ($part->getText() === null) { + continue; + } + if ($part->getChannel()->is(MessagePartChannelEnum::thought())) { + $thought .= $part->getText(); + } else { + $content .= $part->getText(); + } + } + + $this->assertSame('Thinking', $thought); + $this->assertSame('Answer', $content); + $this->assertSame(10, $result->getTokenUsage()->getThoughtTokens()); + } + + /** + * Builds one streamed-choice SSE frame carrying tool-call deltas. + * + * @param list $toolCalls + */ + private function toolCallFrame(array $toolCalls, ?string $finishReason = null): string + { + $deltas = []; + foreach ($toolCalls as [$index, $id, $name, $arguments]) { + $function = []; + if ($name !== null) { + $function['name'] = $name; + } + if ($arguments !== null) { + $function['arguments'] = $arguments; + } + + $toolCall = ['index' => $index]; + if ($id !== null) { + $toolCall['id'] = $id; + } + $toolCall['function'] = $function; + $deltas[] = $toolCall; + } + + $choice = ['index' => 0, 'delta' => ['tool_calls' => $deltas]]; + if ($finishReason !== null) { + $choice['finish_reason'] = $finishReason; + } + + return $this->createSseDataLine(['choices' => [$choice]]); + } + + /** + * @return array, 1: list}> + */ + public function assembledToolCallProvider(): array + { + return [ + 'arguments split across frames' => [ + [ + $this->toolCallFrame([[0, 'call_1', 'get_weather', '']]), + $this->toolCallFrame([[0, null, null, '{"ci']]), + $this->toolCallFrame([[0, null, null, 'ty": "San']]), + $this->toolCallFrame([[0, null, null, ' Francisco"}']], 'tool_calls'), + ], + [['id' => 'call_1', 'name' => 'get_weather', 'args' => ['city' => 'San Francisco']]], + ], + 'whole tool call in one frame' => [ + [ + $this->toolCallFrame([[0, 'call_1', 'get_weather', '{"city": "London"}']], 'tool_calls'), + ], + [['id' => 'call_1', 'name' => 'get_weather', 'args' => ['city' => 'London']]], + ], + 'missing type field (Azure AI Foundry / Mistral)' => [ + [ + $this->toolCallFrame([[0, 'call_abc', 'test-tool', '{"value"']]), + $this->toolCallFrame([[0, null, null, ':"hello"}']], 'tool_calls'), + ], + [['id' => 'call_abc', 'name' => 'test-tool', 'args' => ['value' => 'hello']]], + ], + 'trailing empty argument frame does not duplicate' => [ + [ + $this->toolCallFrame([[0, 'call_1', 'searchGoogle', null]]), + $this->toolCallFrame([[0, null, null, '{"query": "ai"}']]), + $this->toolCallFrame([[0, null, null, '']], 'tool_calls'), + ], + [['id' => 'call_1', 'name' => 'searchGoogle', 'args' => ['query' => 'ai']]], + ], + 'parallel tool calls reassembled independently' => [ + [ + $this->toolCallFrame([ + [0, 'call_a', 'get_weather', '{"city":'], + [1, 'call_b', 'get_time', '{"tz":'], + ]), + $this->toolCallFrame([[1, null, null, '"UTC"}'], [0, null, null, '"Paris"}']], 'tool_calls'), + ], + [ + ['id' => 'call_a', 'name' => 'get_weather', 'args' => ['city' => 'Paris']], + ['id' => 'call_b', 'name' => 'get_time', 'args' => ['tz' => 'UTC']], + ], + ], + ]; + } + + /** + * Tool-call deltas are reassembled into function calls, however the provider fragments them. + * + * @dataProvider assembledToolCallProvider + * + * @param list $sseFrames + * @param list $expectedCalls + */ + public function testStreamReassemblesToolCalls(array $sseFrames, array $expectedCalls): void + { + $this->givenStreamResponse($this->createStreamResponse(array_merge($sseFrames, ["data: [DONE]\n\n"]))); + + $candidate = $this->createModel() + ->streamGenerateTextResult($this->createStreamPrompt()) + ->getFinalResult() + ->getCandidates()[0]; + + $actualCalls = []; + foreach ($candidate->getMessage()->getParts() as $part) { + $call = $part->getFunctionCall(); + if ($call !== null) { + $actualCalls[] = ['id' => $call->getId(), 'name' => $call->getName(), 'args' => $call->getArgs()]; + } + } + + $this->assertEquals($expectedCalls, $actualCalls); + } + + /** + * Choices at different indices become separate candidates, sorted by index. + */ + public function testStreamSeparatesMultipleCandidatesByIndex(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine(['choices' => [ + ['index' => 0, 'delta' => ['content' => 'First']], + ['index' => 1, 'delta' => ['content' => 'Second']], + ]]), + $this->createSseDataLine(['choices' => [ + ['index' => 0, 'delta' => [], 'finish_reason' => 'stop'], + ['index' => 1, 'delta' => [], 'finish_reason' => 'stop'], + ]]), + "data: [DONE]\n\n", + ])); + + $candidates = $this->createModel() + ->streamGenerateTextResult($this->createStreamPrompt()) + ->getFinalResult() + ->getCandidates(); + + $this->assertCount(2, $candidates); + $this->assertSame('First', $candidates[0]->getMessage()->getParts()[0]->getText()); + $this->assertSame('Second', $candidates[1]->getMessage()->getParts()[0]->getText()); + } + + /** + * An unknown finish reason is dropped (null) and the candidate defaults to stop. + */ + public function testStreamUnknownFinishReasonDefaultsToStop(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine([ + 'choices' => [['index' => 0, 'delta' => ['content' => 'Hi'], 'finish_reason' => 'something_new']], + ]), + "data: [DONE]\n\n", + ])); + + $candidate = $this->createModel() + ->streamGenerateTextResult($this->createStreamPrompt()) + ->getFinalResult() + ->getCandidates()[0]; + + $this->assertTrue($candidate->getFinishReason()->is(FinishReasonEnum::stop())); + } + + /** + * A malformed JSON frame is skipped without aborting the stream. + */ + public function testStreamSkipsUnparsableJsonLineButKeepsValidChunks(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['content' => 'Hel']]]]), + "data: {unparsable}\n\n", + $this->createSseDataLine([ + 'choices' => [['index' => 0, 'delta' => ['content' => 'lo'], 'finish_reason' => 'stop']], + ]), + "data: [DONE]\n\n", + ])); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + $chunks = $this->consumeChunks($handle); + + $this->assertCount(2, $chunks); + $this->assertSame('Hello', $handle->getFinalResult()->toText()); + } + + /** + * A stream that only contains the [DONE] sentinel produces no result. + */ + public function testStreamWithOnlyDoneSentinelProducesNoResult(): void + { + $this->givenStreamResponse($this->createStreamResponse(["data: [DONE]\n\n"])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('no candidates'); + $this->createModel()->streamGenerateTextResult($this->createStreamPrompt())->getFinalResult(); + } + + /** + * An error frame raises a ResponseException carrying the provider message, wrapped exactly once. + */ + public function testStreamThrowsResponseExceptionOnErrorEvent(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine(['error' => ['message' => 'bad request', 'type' => 'provider_error']]), + "data: [DONE]\n\n", + ])); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + + $thrown = null; + try { + $this->consumeChunks($handle); + } catch (ResponseException $e) { + $thrown = $e; + } + + $this->assertInstanceOf(ResponseException::class, $thrown); + $this->assertSame('Error while streaming the TestProvider API response: bad request', $thrown->getMessage()); + } + + /** + * Chunks produced before a mid-stream error frame are delivered, then the error propagates. + */ + public function testStreamYieldsContentBeforeMidStreamErrorThenThrows(): void + { + $this->givenStreamResponse($this->createStreamResponse([ + $this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['content' => 'Hello']]]]), + $this->createSseDataLine(['error' => ['message' => 'stream failed after output']]), + "data: [DONE]\n\n", + ])); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + + $collected = []; + $thrown = null; + try { + foreach ($handle as $chunk) { + $collected[] = $chunk; + } + } catch (ResponseException $e) { + $thrown = $e; + } + + $this->assertCount(1, $collected); + $this->assertSame('Hello', $collected[0]->getDeltaText()); + $this->assertInstanceOf(ResponseException::class, $thrown); + $this->assertStringContainsString('stream failed after output', $thrown->getMessage()); + } + + /** + * A non-successful response is surfaced before any streaming begins. + */ + public function testStreamThrowsClientExceptionWhenResponseIsNotSuccessful(): void + { + $this->givenStreamResponse(new Response(400, [], '{"error": "Invalid parameter."}')); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + + $this->expectException(ClientException::class); + $this->consumeChunks($handle); + } + + /** + * A read failure part-way through the stream is wrapped as a ResponseException with its cause, + * after the chunks received before the failure have been delivered. + */ + public function testStreamWrapsMidReadFailureAsResponseException(): void + { + $response = new Response(200, [], new FailingChunkStream( + [$this->createSseDataLine(['choices' => [['index' => 0, 'delta' => ['content' => 'Hello']]]])], + 'Connection reset by peer' + )); + $this->givenStreamResponse($response); + + $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); + + $collected = []; + $thrown = null; + try { + foreach ($handle as $chunk) { + $collected[] = $chunk; + } + } catch (ResponseException $e) { + $thrown = $e; + } + + $this->assertCount(1, $collected); + $this->assertSame('Hello', $collected[0]->getDeltaText()); + $this->assertInstanceOf(ResponseException::class, $thrown); + $this->assertStringContainsString('Connection reset by peer', $thrown->getMessage()); + $this->assertInstanceOf(\RuntimeException::class, $thrown->getPrevious()); + } + + /** + * throwIfStreamError() is a no-op for events without an error payload. + */ + public function testThrowIfStreamErrorIgnoresEventsWithoutError(): void + { + $model = $this->createModel(); + $model->exposeThrowIfStreamError(['choices' => []]); + + $this->assertTrue(true); // No exception thrown. + } + + /** + * @return array, 1: string}> + */ + public function streamErrorMessageProvider(): array + { + return [ + 'object error with message' => [ + ['error' => ['message' => 'boom', 'type' => 'server_error']], + 'Error while streaming the TestProvider API response: boom', + ], + 'object error without message' => [ + ['error' => ['type' => 'server_error']], + 'Error while streaming the TestProvider API response: The provider reported an error.', + ], + 'non-array error' => [ + ['error' => 'oops'], + 'Error while streaming the TestProvider API response: The provider reported an error.', + ], + 'non-string message' => [ + ['error' => ['message' => 123]], + 'Error while streaming the TestProvider API response: The provider reported an error.', + ], + ]; + } + + /** + * @dataProvider streamErrorMessageProvider + * + * @param array $event + */ + public function testThrowIfStreamErrorMessage(array $event, string $expectedMessage): void + { + $model = $this->createModel(); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage($expectedMessage); + $model->exposeThrowIfStreamError($event); + } + + /** + * parseStreamEvent() returns null when an event carries nothing usable. + */ + public function testParseStreamEventReturnsNullWhenEventCarriesNothingUsable(): void + { + $model = $this->createModel(); + + $this->assertNull($model->exposeParseStreamEvent([])); + $this->assertNull($model->exposeParseStreamEvent(['choices' => []])); + } + + /** + * parseStreamEvent() ignores a non-string id and non-array usage/choices. + */ + public function testParseStreamEventIgnoresNonStringIdAndNonArrayUsageAndChoices(): void + { + $model = $this->createModel(); + + $chunk = $model->exposeParseStreamEvent([ + 'id' => 123, + 'usage' => 'nope', + 'choices' => 'nope', + 'system_fingerprint' => 'fp_1', + ]); + + $this->assertInstanceOf(GenerativeAiResultChunk::class, $chunk); + $this->assertNull($chunk->getId()); + $this->assertNull($chunk->getTokenUsage()); + $this->assertSame([], $chunk->getCandidateDeltas()); + $this->assertSame(['system_fingerprint' => 'fp_1'], $chunk->getAdditionalData()); + } + + /** + * parseStreamEvent() skips choice entries that are not arrays. + */ + public function testParseStreamEventSkipsNonArrayChoiceEntries(): void + { + $model = $this->createModel(); + + $chunk = $model->exposeParseStreamEvent([ + 'choices' => [ + 'not-an-array', + ['index' => 0, 'delta' => ['content' => 'Hi']], + ], + ]); + + $this->assertInstanceOf(GenerativeAiResultChunk::class, $chunk); + $this->assertCount(1, $chunk->getCandidateDeltas()); + $this->assertSame('Hi', $chunk->getDeltaText()); + } + + /** + * @return array, 1: int, 2: bool}> + */ + public function streamChoiceGuardProvider(): array + { + return [ + 'missing index defaults to 0' => [['delta' => ['content' => 'x']], 0, false], + 'non-int index defaults to 0' => [['index' => '5', 'delta' => ['content' => 'x']], 0, false], + 'non-array delta yields no parts' => [['index' => 2, 'delta' => 'nope'], 2, false], + 'non-string finish reason is dropped' => [['index' => 0, 'delta' => [], 'finish_reason' => 7], 0, false], + 'unknown finish reason is dropped' => [ + ['index' => 0, 'delta' => [], 'finish_reason' => 'mystery'], + 0, + false, + ], + 'known finish reason is kept' => [['index' => 0, 'delta' => [], 'finish_reason' => 'stop'], 0, true], + ]; + } + + /** + * @dataProvider streamChoiceGuardProvider + * + * @param array $choice + */ + public function testParseStreamChoiceGuards(array $choice, int $expectedIndex, bool $hasFinishReason): void + { + $delta = $this->createModel()->exposeParseStreamChoice($choice); + + $this->assertInstanceOf(CandidateDelta::class, $delta); + $this->assertSame($expectedIndex, $delta->getIndex()); + if ($hasFinishReason) { + $this->assertInstanceOf(FinishReasonEnum::class, $delta->getFinishReason()); + } else { + $this->assertNull($delta->getFinishReason()); + } + } + + /** + * parseStreamDeltaParts() maps reasoning and content channels and ignores non-string values. + */ + public function testParseStreamDeltaPartsMapsChannels(): void + { + $parts = $this->createModel()->exposeParseStreamDeltaParts([ + 'reasoning_content' => 'A', + 'reasoning' => 'B', + 'content' => 'C', + ]); + + $this->assertCount(3, $parts); + $this->assertSame('A', $parts[0]->getText()); + $this->assertTrue($parts[0]->getChannel()->is(MessagePartChannelEnum::thought())); + $this->assertSame('B', $parts[1]->getText()); + $this->assertTrue($parts[1]->getChannel()->is(MessagePartChannelEnum::thought())); + $this->assertSame('C', $parts[2]->getText()); + $this->assertTrue($parts[2]->getChannel()->is(MessagePartChannelEnum::content())); + + $this->assertSame([], $this->createModel()->exposeParseStreamDeltaParts([ + 'reasoning_content' => 1, + 'reasoning' => 2, + 'content' => 3, + ])); + } + + /** + * @return array, 1: array}> + */ + public function streamToolCallDeltaGuardProvider(): array + { + return [ + 'tool_calls not an array' => [ + ['tool_calls' => 'nope'], + ['count' => 0], + ], + 'tool call entry not an array' => [ + ['tool_calls' => ['nope']], + ['count' => 0], + ], + 'missing index falls back to position' => [ + ['tool_calls' => [['id' => 'a', 'function' => ['name' => 'fn', 'arguments' => '{}']]]], + ['count' => 1, 'index' => 0, 'id' => 'a', 'name' => 'fn', 'arguments' => '{}'], + ], + 'non-int index falls back to position' => [ + ['tool_calls' => [['index' => 'x', 'id' => 'a', 'function' => ['name' => 'fn']]]], + ['count' => 1, 'index' => 0, 'id' => 'a', 'name' => 'fn', 'arguments' => ''], + ], + 'non-string id becomes null' => [ + ['tool_calls' => [['index' => 0, 'id' => 9, 'function' => ['name' => 'fn']]]], + ['count' => 1, 'index' => 0, 'id' => null, 'name' => 'fn', 'arguments' => ''], + ], + 'non-array function yields null name and empty arguments' => [ + ['tool_calls' => [['index' => 0, 'id' => 'a', 'function' => 'nope']]], + ['count' => 1, 'index' => 0, 'id' => 'a', 'name' => null, 'arguments' => ''], + ], + 'non-string name and arguments are dropped' => [ + ['tool_calls' => [['index' => 0, 'id' => 'a', 'function' => ['name' => 1, 'arguments' => 2]]]], + ['count' => 1, 'index' => 0, 'id' => 'a', 'name' => null, 'arguments' => ''], + ], + ]; + } + + /** + * @dataProvider streamToolCallDeltaGuardProvider + * + * @param array $delta + * @param array $expected + */ + public function testParseStreamToolCallDeltasGuards(array $delta, array $expected): void + { + $deltas = $this->createModel()->exposeParseStreamToolCallDeltas($delta); + + $this->assertCount($expected['count'], $deltas); + if ($expected['count'] === 0) { + return; + } + + $this->assertSame($expected['index'], $deltas[0]->getIndex()); + $this->assertSame($expected['id'], $deltas[0]->getId()); + $this->assertSame($expected['name'], $deltas[0]->getFunctionName()); + $this->assertSame($expected['arguments'], $deltas[0]->getArgumentsFragment()); + } + + /** + * @return array, 1: int, 2: int, 3: int, 4: int|null}> + */ + public function usageDataProvider(): array + { + return [ + 'full usage with reasoning tokens' => [ + [ + 'prompt_tokens' => 15, + 'completion_tokens' => 20, + 'total_tokens' => 35, + 'completion_tokens_details' => ['reasoning_tokens' => 10], + ], + 15, + 20, + 35, + 10, + ], + 'usage without reasoning tokens' => [ + ['prompt_tokens' => 8, 'completion_tokens' => 4, 'total_tokens' => 12], + 8, + 4, + 12, + null, + ], + 'partial usage defaults missing counts to zero' => [ + ['prompt_tokens' => 20], + 20, + 0, + 0, + null, + ], + 'non-int reasoning tokens are ignored' => [ + [ + 'prompt_tokens' => 1, + 'completion_tokens' => 1, + 'total_tokens' => 2, + 'completion_tokens_details' => ['reasoning_tokens' => 'x'], + ], + 1, + 1, + 2, + null, + ], + ]; + } + + /** + * @dataProvider usageDataProvider + * + * @param array $usage + */ + public function testParseUsageData( + array $usage, + int $prompt, + int $completion, + int $total, + ?int $thought + ): void { + $tokenUsage = $this->createModel()->exposeParseUsageData($usage); + + $this->assertSame($prompt, $tokenUsage->getPromptTokens()); + $this->assertSame($completion, $tokenUsage->getCompletionTokens()); + $this->assertSame($total, $tokenUsage->getTotalTokens()); + $this->assertSame($thought, $tokenUsage->getThoughtTokens()); + } + + /** + * extractAdditionalData() strips id, choices and usage and keeps the rest. + */ + public function testExtractAdditionalDataStripsKnownKeys(): void + { + $data = $this->createModel()->exposeExtractAdditionalData([ + 'id' => 'x', + 'choices' => [], + 'usage' => [], + 'object' => 'chat.completion.chunk', + 'system_fingerprint' => 'fp_1', + ]); + + $this->assertSame(['object' => 'chat.completion.chunk', 'system_fingerprint' => 'fp_1'], $data); + } } From d32da7564ff9269cdc8efd9498c2796d8adb2b8f Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 16:10:44 +0530 Subject: [PATCH 48/71] Add streaming support in modal creation mock --- tests/traits/MockModelCreationTrait.php | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index d330c518..750a480f 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Tests\traits; +use ArrayIterator; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Providers\DTO\ProviderMetadata; @@ -13,6 +14,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\StreamingTextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; @@ -20,6 +22,9 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Results\StreamedGenerativeAiResult; +use WordPress\AiClient\Results\ValueObjects\CandidateDelta; +use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; use WordPress\AiClient\Tests\mocks\MockProvider; /** @@ -368,4 +373,100 @@ protected function createMockUnsupportedModel(string $modelId = 'unsupported-mod return $mockModel; } + + /** + * Creates a chunk carrying a content text delta on candidate 0. + * + * @param string $text The content delta. + * @param FinishReasonEnum|null $finishReason Optional finish reason for the candidate. + * @return GenerativeAiResultChunk + */ + protected function createStreamingTextChunk( + string $text, + ?FinishReasonEnum $finishReason = null + ): GenerativeAiResultChunk { + return new GenerativeAiResultChunk(null, null, [], [ + new CandidateDelta(0, [new MessagePart($text)], $finishReason), + ]); + } + + /** + * Creates a mock model that streams the given chunks. + * + * @param list $chunks The chunks to stream. + * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). + * @return ModelInterface&StreamingTextGenerationModelInterface The mock model. + */ + protected function createMockStreamingTextGenerationModel( + array $chunks, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->createTestTextModelMetadata(); + + $providerMetadata = new ProviderMetadata( + 'mock', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + + return new class ( + $metadata, + $providerMetadata, + $chunks + ) implements ModelInterface, TextGenerationModelInterface, StreamingTextGenerationModelInterface { + private ModelMetadata $metadata; + private ProviderMetadata $providerMetadata; + /** @var list */ + private array $chunks; + private ModelConfig $config; + + /** + * @param list $chunks + */ + public function __construct( + ModelMetadata $metadata, + ProviderMetadata $providerMetadata, + array $chunks + ) { + $this->metadata = $metadata; + $this->providerMetadata = $providerMetadata; + $this->chunks = $chunks; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function providerMetadata(): ProviderMetadata + { + return $this->providerMetadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateTextResult(array $prompt): GenerativeAiResult + { + throw new \RuntimeException('Non-streaming generation is not exercised by streaming tests.'); + } + + public function streamGenerateTextResult(array $prompt): StreamedGenerativeAiResult + { + return new StreamedGenerativeAiResult( + new ArrayIterator($this->chunks), + $this->providerMetadata, + $this->metadata + ); + } + }; + } } From 760e95a4bc3a006718dbc777c42cbc94b2595418 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 16:12:34 +0530 Subject: [PATCH 49/71] Add test cases for event dispatch in streamed results --- .../PromptBuilderEventDispatchingTest.php | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php index 092a9330..b1cfe373 100644 --- a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -10,6 +10,9 @@ use WordPress\AiClient\Events\BeforeGenerateResultEvent; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; use WordPress\AiClient\Tests\mocks\MockEventDispatcher; use WordPress\AiClient\Tests\mocks\MockProvider; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; @@ -156,4 +159,126 @@ public function testEventsDispatchedInCorrectOrder(): void $this->assertInstanceOf(BeforeGenerateResultEvent::class, $events[0]); $this->assertInstanceOf(AfterGenerateResultEvent::class, $events[1]); } + + /** + * Creates a builder wired with the injected dispatcher and a model that streams the given chunks. + * + * @return PromptBuilder + */ + private function createStreamingBuilderWithDispatcher(): PromptBuilder + { + $model = $this->createMockStreamingTextGenerationModel([ + $this->createStreamingTextChunk('Hel'), + $this->createStreamingTextChunk('lo', FinishReasonEnum::stop()), + new GenerativeAiResultChunk(null, new TokenUsage(3, 5, 8), [], []), + ]); + + $builder = new PromptBuilder($this->registry, 'Hello', $this->dispatcher); + $builder->usingModel($model); + + return $builder; + } + + /** + * The Before event is dispatched up front, before the stream is consumed. + * + * @return void + */ + public function testStreamingDispatchesBeforeEventBeforeConsumption(): void + { + // Create the handle but do not consume it. + $this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult(); + + $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); + $this->assertCount(0, $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class)); + } + + /** + * The After event fires once on completion, carrying the assembled result. + * + * @return void + */ + public function testStreamingDispatchesAfterEventOnceWithAssembledResult(): void + { + $result = $this->createStreamingBuilderWithDispatcher() + ->streamGenerateTextResult() + ->getFinalResult(); + + $afterEvents = $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class); + $this->assertCount(1, $afterEvents); + + $event = $afterEvents[0]; + $this->assertSame($result, $event->getResult()); + $this->assertSame('Hello', $event->getResult()->toText()); + $this->assertSame(8, $event->getResult()->getTokenUsage()->getTotalTokens()); + $this->assertEquals(CapabilityEnum::textGeneration(), $event->getCapability()); + $this->assertCount(1, $event->getMessages()); + } + + /** + * The After event is not dispatched when the consumer breaks out early. + * + * @return void + */ + public function testStreamingDoesNotDispatchAfterEventOnEarlyBreak(): void + { + foreach ($this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult() as $chunk) { + break; + } + + $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); + $this->assertCount(0, $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class)); + } + + /** + * The After event fires exactly once across a full iteration followed by getFinalResult(). + * + * @return void + */ + public function testStreamingDispatchesAfterEventOnlyOnce(): void + { + $handle = $this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult(); + + foreach ($handle as $chunk) { + // drain + } + $handle->getFinalResult(); + + $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class)); + } + + /** + * Streaming completes without error when no dispatcher is set, and dispatches nothing. + * + * @return void + */ + public function testStreamingDispatchesNoEventsWithoutDispatcher(): void + { + $model = $this->createMockStreamingTextGenerationModel([ + $this->createStreamingTextChunk('Hello', FinishReasonEnum::stop()), + ]); + + $builder = new PromptBuilder($this->registry, 'Hello'); + $builder->usingModel($model); + + $result = $builder->streamGenerateTextResult()->getFinalResult(); + + $this->assertSame('Hello', $result->toText()); + $this->assertCount(0, $this->dispatcher->getDispatchedEvents()); + } + + /** + * Streaming dispatches Before then After, in that order. + * + * @return void + */ + public function testStreamingDispatchesEventsInOrder(): void + { + $this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult()->getFinalResult(); + + $events = $this->dispatcher->getDispatchedEvents(); + $this->assertCount(2, $events); + $this->assertInstanceOf(BeforeGenerateResultEvent::class, $events[0]); + $this->assertInstanceOf(AfterGenerateResultEvent::class, $events[1]); + } } From 4fd90a2cd71184d40f321275b2ffb291d5543b91 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 16:14:26 +0530 Subject: [PATCH 50/71] Add text stream in prompt builder --- tests/unit/Builders/PromptBuilderTest.php | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index ce68a223..0f35cdb5 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -36,6 +36,8 @@ use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Results\StreamedGenerativeAiResult; +use WordPress\AiClient\Results\ValueObjects\GenerativeAiResultChunk; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; use WordPress\AiClient\Tools\DTO\FunctionResponse; @@ -4143,4 +4145,68 @@ public function testUsingStopSequencesSetsProperty(): void $this->assertEquals(['STOP', 'END'], $config->getStopSequences()); } + + /** + * Tests streamGenerateTextResult returns a StreamedGenerativeAiResult handle and yields the final result. + * + * @return void + */ + public function testStreamGenerateTextResultReturnsStreamedHandle(): void + { + $model = $this->createMockStreamingTextGenerationModel([ + $this->createStreamingTextChunk('Hel'), + $this->createStreamingTextChunk('lo', FinishReasonEnum::stop()), + new GenerativeAiResultChunk(null, new TokenUsage(3, 5, 8), [], []), + ]); + + $builder = new PromptBuilder($this->registry, 'Hello'); + $builder->usingModel($model); + + $handle = $builder->streamGenerateTextResult(); + + $this->assertInstanceOf(StreamedGenerativeAiResult::class, $handle); + $result = $handle->getFinalResult(); + $this->assertSame('Hello', $result->toText()); + $this->assertSame(8, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests streamGenerateTextResult throws an exception when the model does not support streaming. + * + * @return void + */ + public function testStreamGenerateTextResultThrowsWhenModelDoesNotSupportStreaming(): void + { + // A plain text-generation model does not implement StreamingTextGenerationModelInterface. + $model = $this->createMockTextGenerationModel($this->createTestResult()); + + $builder = new PromptBuilder($this->registry, 'Hello'); + $builder->usingModel($model); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('does not support streaming text generation'); + $builder->streamGenerateTextResult(); + } + + /** + * Tests streamGenerateText yields the text deltas in order and skips chunks without text. + * + * @return void + */ + public function testStreamGenerateTextYieldsTextDeltasAndSkipsEmpty(): void + { + $model = $this->createMockStreamingTextGenerationModel([ + $this->createStreamingTextChunk('Hel'), + // A metadata-only chunk carries no text and must not be yielded. + new GenerativeAiResultChunk(null, new TokenUsage(1, 1, 2), [], []), + $this->createStreamingTextChunk('lo', FinishReasonEnum::stop()), + ]); + + $builder = new PromptBuilder($this->registry, 'Hello'); + $builder->usingModel($model); + + $deltas = iterator_to_array($builder->streamGenerateText(), false); + + $this->assertSame(['Hel', 'lo'], $deltas); + } } From ef2de1194a6c61463db07929a79aeb9ad53bf89e Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 16:44:07 +0530 Subject: [PATCH 51/71] Add streaming support test cases for http transporter --- .../Providers/Http/HttpTransporterTest.php | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/tests/unit/Providers/Http/HttpTransporterTest.php b/tests/unit/Providers/Http/HttpTransporterTest.php index e94073e5..1d975286 100644 --- a/tests/unit/Providers/Http/HttpTransporterTest.php +++ b/tests/unit/Providers/Http/HttpTransporterTest.php @@ -347,4 +347,188 @@ public function testSendMergesOptionsWithParameterPrecedence(): void $this->assertSame(5.0, $lastOptions['connect_timeout']); // From request (not overridden) $this->assertFalse($lastOptions['allow_redirects']); // From parameter (0 = disabled) } + + /** + * Creates a transporter backed by the given Guzzle-like client. + * + * @param GuzzleLikeClient $client The Guzzle-like client. + * @return HttpTransporter + */ + private function createGuzzleTransporter(GuzzleLikeClient $client): HttpTransporter + { + return new HttpTransporter($client, $this->httpFactory, $this->httpFactory); + } + + /** + * Tests that streaming forwards stream:true to Guzzle and passes the body through. + * + * @return void + */ + public function testStreamForwardsStreamOptionToGuzzleAndPassesBodyThrough(): void + { + $psr7Response = new Psr7Response(200, [], 'streamed body'); + $body = $psr7Response->getBody(); + $guzzleClient = new GuzzleLikeClient($psr7Response); + $transporter = $this->createGuzzleTransporter($guzzleClient); + + $options = new RequestOptions(); + $options->setStream(true); + + $request = new Request(HttpMethodEnum::GET(), 'https://api.example.com/stream'); + $result = $transporter->send($request, $options); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + $this->assertTrue($lastOptions['stream']); + + $this->assertSame($body, $result->getStream()); + } + + /** + * Tests that a non-streaming Guzzle request omits the stream option and buffers the body. + * + * @return void + */ + public function testNonStreamGuzzleRequestOmitsStreamOptionAndBuffersBody(): void + { + $psr7Response = new Psr7Response(200, [], 'buffered body'); + $guzzleClient = new GuzzleLikeClient($psr7Response); + $transporter = $this->createGuzzleTransporter($guzzleClient); + + $options = new RequestOptions(); + $options->setStream(false); + + $request = new Request(HttpMethodEnum::GET(), 'https://api.example.com/buffered'); + $result = $transporter->send($request, $options); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + $this->assertArrayNotHasKey('stream', $lastOptions); + + $this->assertSame('buffered body', $result->getBody()); + } + + /** + * Tests that a request-level stream option streams the response. + * + * @return void + */ + public function testRequestLevelStreamOptionStreamsResponse(): void + { + $psr7Response = new Psr7Response(200, [], 'streamed body'); + $body = $psr7Response->getBody(); + $guzzleClient = new GuzzleLikeClient($psr7Response); + $transporter = $this->createGuzzleTransporter($guzzleClient); + + $requestOptions = new RequestOptions(); + $requestOptions->setStream(true); + + $request = new Request(HttpMethodEnum::GET(), 'https://api.example.com/stream', [], null, $requestOptions); + $result = $transporter->send($request); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + $this->assertTrue($lastOptions['stream']); + $this->assertSame($body, $result->getStream()); + } + + /** + * Tests that parameter stream:false overrides request stream:true. + * + * @return void + */ + public function testParameterStreamFalseOverridesRequestStreamTrue(): void + { + $psr7Response = new Psr7Response(200, [], 'buffered body'); + $guzzleClient = new GuzzleLikeClient($psr7Response); + $transporter = $this->createGuzzleTransporter($guzzleClient); + + $requestOptions = new RequestOptions(); + $requestOptions->setStream(true); + $request = new Request(HttpMethodEnum::GET(), 'https://api.example.com/x', [], null, $requestOptions); + + $parameterOptions = new RequestOptions(); + $parameterOptions->setStream(false); + + $result = $transporter->send($request, $parameterOptions); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + $this->assertArrayNotHasKey('stream', $lastOptions); + $this->assertSame('buffered body', $result->getBody()); + } + + /** + * Tests that parameter stream:true overrides request stream:false. + * + * @return void + */ + public function testParameterStreamTrueOverridesRequestStreamFalse(): void + { + $psr7Response = new Psr7Response(200, [], 'streamed body'); + $body = $psr7Response->getBody(); + $guzzleClient = new GuzzleLikeClient($psr7Response); + $transporter = $this->createGuzzleTransporter($guzzleClient); + + $requestOptions = new RequestOptions(); + $requestOptions->setStream(false); + $request = new Request(HttpMethodEnum::GET(), 'https://api.example.com/x', [], null, $requestOptions); + + $parameterOptions = new RequestOptions(); + $parameterOptions->setStream(true); + + $result = $transporter->send($request, $parameterOptions); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + $this->assertTrue($lastOptions['stream']); + $this->assertSame($body, $result->getStream()); + } + + /** + * Tests that a request-level stream flag is kept when parameter options omit it. + * + * @return void + */ + public function testRequestStreamIsMergedWhenParameterDoesNotSpecifyStream(): void + { + $psr7Response = new Psr7Response(200, [], 'streamed body'); + $body = $psr7Response->getBody(); + $guzzleClient = new GuzzleLikeClient($psr7Response); + $transporter = $this->createGuzzleTransporter($guzzleClient); + + $requestOptions = new RequestOptions(); + $requestOptions->setStream(true); + $request = new Request(HttpMethodEnum::GET(), 'https://api.example.com/x', [], null, $requestOptions); + + $parameterOptions = new RequestOptions(); + $parameterOptions->setTimeout(5.0); + + $result = $transporter->send($request, $parameterOptions); + + $lastOptions = $guzzleClient->getLastOptions(); + $this->assertIsArray($lastOptions); + $this->assertTrue($lastOptions['stream']); + $this->assertSame($body, $result->getStream()); + } + + /** + * Tests that the streamed response is passed through with a non-Guzzle client. + * + * @return void + */ + public function testStreamingResponseIsPassedThroughWithNonGuzzleClient(): void + { + $psr7Response = new Psr7Response(200, [], 'streamed body'); + $body = $psr7Response->getBody(); + $this->mockClient->addResponse($psr7Response); + + $options = new RequestOptions(); + $options->setStream(true); + + $request = new Request(HttpMethodEnum::GET(), 'https://api.example.com/stream'); + $result = $this->transporter->send($request, $options); + + $this->assertSame($body, $result->getStream()); + } } From f3f0dc90c0d70bae14e22f981ad74077ff3d7057 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 16:47:24 +0530 Subject: [PATCH 52/71] Update code comments --- .../PromptBuilderEventDispatchingTest.php | 14 ++--- ...penAiCompatibleTextGenerationModelTest.php | 47 +++++++-------- tests/unit/Results/ChunkAccumulatorTest.php | 54 ++++++++--------- .../StreamedGenerativeAiResultTest.php | 58 +++++++++---------- 4 files changed, 82 insertions(+), 91 deletions(-) diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php index b1cfe373..724455a4 100644 --- a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -180,13 +180,12 @@ private function createStreamingBuilderWithDispatcher(): PromptBuilder } /** - * The Before event is dispatched up front, before the stream is consumed. + * Tests that the Before event is dispatched before the stream is consumed. * * @return void */ public function testStreamingDispatchesBeforeEventBeforeConsumption(): void { - // Create the handle but do not consume it. $this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult(); $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); @@ -194,7 +193,7 @@ public function testStreamingDispatchesBeforeEventBeforeConsumption(): void } /** - * The After event fires once on completion, carrying the assembled result. + * Tests that the After event fires once with the assembled result. * * @return void */ @@ -216,7 +215,7 @@ public function testStreamingDispatchesAfterEventOnceWithAssembledResult(): void } /** - * The After event is not dispatched when the consumer breaks out early. + * Tests that the After event is not dispatched on an early break. * * @return void */ @@ -231,7 +230,7 @@ public function testStreamingDoesNotDispatchAfterEventOnEarlyBreak(): void } /** - * The After event fires exactly once across a full iteration followed by getFinalResult(). + * Tests that the After event fires only once across iteration and getFinalResult(). * * @return void */ @@ -240,7 +239,6 @@ public function testStreamingDispatchesAfterEventOnlyOnce(): void $handle = $this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult(); foreach ($handle as $chunk) { - // drain } $handle->getFinalResult(); @@ -248,7 +246,7 @@ public function testStreamingDispatchesAfterEventOnlyOnce(): void } /** - * Streaming completes without error when no dispatcher is set, and dispatches nothing. + * Tests that streaming dispatches nothing when no dispatcher is set. * * @return void */ @@ -268,7 +266,7 @@ public function testStreamingDispatchesNoEventsWithoutDispatcher(): void } /** - * Streaming dispatches Before then After, in that order. + * Tests that streaming dispatches Before then After. * * @return void */ diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index c3eeec11..7b0d98f4 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -1386,7 +1386,7 @@ private function consumeChunks(StreamedGenerativeAiResult $handle): array } /** - * Creating the handle is lazy: no HTTP request is sent until it is consumed. + * Tests that creating the handle does not perform the request. */ public function testStreamGenerateTextResultReturnsHandleWithoutPerformingRequest(): void { @@ -1398,7 +1398,7 @@ public function testStreamGenerateTextResultReturnsHandleWithoutPerformingReques } /** - * The streamed request opts into streaming and asks the provider to include usage. + * Tests that the streamed request enables streaming and usage reporting. */ public function testStreamGenerateTextResultEnablesStreamingOnTheRequest(): void { @@ -1422,12 +1422,11 @@ public function testStreamGenerateTextResultEnablesStreamingOnTheRequest(): void $handle = $this->createModel()->streamGenerateTextResult($this->createStreamPrompt()); - // Consuming triggers the request; a DONE-only stream yields no chunks. $this->assertSame([], $this->consumeChunks($handle)); } /** - * Content deltas are concatenated and the trailing usage/finish frames are folded in. + * Tests that content deltas, the finish reason, and usage are assembled. */ public function testStreamAssemblesContentFinishReasonAndUsage(): void { @@ -1458,7 +1457,7 @@ public function testStreamAssemblesContentFinishReasonAndUsage(): void } /** - * Each usable SSE event surfaces as its own chunk; empty and [DONE] frames do not. + * Tests that each event yields a chunk and the [DONE] sentinel is skipped. */ public function testStreamYieldsOneChunkPerEventAndSkipsTheDoneSentinel(): void { @@ -1486,7 +1485,7 @@ public function testStreamYieldsOneChunkPerEventAndSkipsTheDoneSentinel(): void } /** - * Provider-specific top-level fields are preserved as additional data on chunks and the result. + * Tests that additional data is extracted onto chunks and the result. */ public function testStreamExtractsAdditionalDataIntoChunksAndResult(): void { @@ -1510,8 +1509,7 @@ public function testStreamExtractsAdditionalDataIntoChunksAndResult(): void } /** - * `reasoning_content` and `reasoning` deltas land on the thought channel and reasoning - * tokens are captured from the usage details. + * Tests that reasoning deltas route to the thought channel with thought tokens. */ public function testStreamRoutesReasoningToThoughtChannelWithThoughtTokens(): void { @@ -1641,7 +1639,7 @@ public function assembledToolCallProvider(): array } /** - * Tool-call deltas are reassembled into function calls, however the provider fragments them. + * Tests that tool-call deltas are reassembled into function calls. * * @dataProvider assembledToolCallProvider * @@ -1669,7 +1667,7 @@ public function testStreamReassemblesToolCalls(array $sseFrames, array $expected } /** - * Choices at different indices become separate candidates, sorted by index. + * Tests that choices at different indices become separate candidates. */ public function testStreamSeparatesMultipleCandidatesByIndex(): void { @@ -1696,7 +1694,7 @@ public function testStreamSeparatesMultipleCandidatesByIndex(): void } /** - * An unknown finish reason is dropped (null) and the candidate defaults to stop. + * Tests that an unknown finish reason defaults to stop. */ public function testStreamUnknownFinishReasonDefaultsToStop(): void { @@ -1716,7 +1714,7 @@ public function testStreamUnknownFinishReasonDefaultsToStop(): void } /** - * A malformed JSON frame is skipped without aborting the stream. + * Tests that a malformed JSON frame is skipped without aborting the stream. */ public function testStreamSkipsUnparsableJsonLineButKeepsValidChunks(): void { @@ -1737,7 +1735,7 @@ public function testStreamSkipsUnparsableJsonLineButKeepsValidChunks(): void } /** - * A stream that only contains the [DONE] sentinel produces no result. + * Tests that a [DONE]-only stream produces no result. */ public function testStreamWithOnlyDoneSentinelProducesNoResult(): void { @@ -1749,7 +1747,7 @@ public function testStreamWithOnlyDoneSentinelProducesNoResult(): void } /** - * An error frame raises a ResponseException carrying the provider message, wrapped exactly once. + * Tests that an error frame raises a ResponseException with the provider message. */ public function testStreamThrowsResponseExceptionOnErrorEvent(): void { @@ -1772,7 +1770,7 @@ public function testStreamThrowsResponseExceptionOnErrorEvent(): void } /** - * Chunks produced before a mid-stream error frame are delivered, then the error propagates. + * Tests that chunks before a mid-stream error are delivered, then it propagates. */ public function testStreamYieldsContentBeforeMidStreamErrorThenThrows(): void { @@ -1801,7 +1799,7 @@ public function testStreamYieldsContentBeforeMidStreamErrorThenThrows(): void } /** - * A non-successful response is surfaced before any streaming begins. + * Tests that a non-successful response is surfaced before streaming begins. */ public function testStreamThrowsClientExceptionWhenResponseIsNotSuccessful(): void { @@ -1814,8 +1812,7 @@ public function testStreamThrowsClientExceptionWhenResponseIsNotSuccessful(): vo } /** - * A read failure part-way through the stream is wrapped as a ResponseException with its cause, - * after the chunks received before the failure have been delivered. + * Tests that a mid-read failure is wrapped as a ResponseException with its cause. */ public function testStreamWrapsMidReadFailureAsResponseException(): void { @@ -1845,14 +1842,14 @@ public function testStreamWrapsMidReadFailureAsResponseException(): void } /** - * throwIfStreamError() is a no-op for events without an error payload. + * Tests that throwIfStreamError() is a no-op without an error payload. */ public function testThrowIfStreamErrorIgnoresEventsWithoutError(): void { $model = $this->createModel(); $model->exposeThrowIfStreamError(['choices' => []]); - $this->assertTrue(true); // No exception thrown. + $this->assertTrue(true); } /** @@ -1895,7 +1892,7 @@ public function testThrowIfStreamErrorMessage(array $event, string $expectedMess } /** - * parseStreamEvent() returns null when an event carries nothing usable. + * Tests that parseStreamEvent() returns null for an unusable event. */ public function testParseStreamEventReturnsNullWhenEventCarriesNothingUsable(): void { @@ -1906,7 +1903,7 @@ public function testParseStreamEventReturnsNullWhenEventCarriesNothingUsable(): } /** - * parseStreamEvent() ignores a non-string id and non-array usage/choices. + * Tests that parseStreamEvent() ignores a non-string id and non-array usage and choices. */ public function testParseStreamEventIgnoresNonStringIdAndNonArrayUsageAndChoices(): void { @@ -1927,7 +1924,7 @@ public function testParseStreamEventIgnoresNonStringIdAndNonArrayUsageAndChoices } /** - * parseStreamEvent() skips choice entries that are not arrays. + * Tests that parseStreamEvent() skips non-array choice entries. */ public function testParseStreamEventSkipsNonArrayChoiceEntries(): void { @@ -1983,7 +1980,7 @@ public function testParseStreamChoiceGuards(array $choice, int $expectedIndex, b } /** - * parseStreamDeltaParts() maps reasoning and content channels and ignores non-string values. + * Tests that parseStreamDeltaParts() maps channels and ignores non-string values. */ public function testParseStreamDeltaPartsMapsChannels(): void { @@ -2134,7 +2131,7 @@ public function testParseUsageData( } /** - * extractAdditionalData() strips id, choices and usage and keeps the rest. + * Tests that extractAdditionalData() strips id, choices, and usage. */ public function testExtractAdditionalDataStripsKnownKeys(): void { diff --git a/tests/unit/Results/ChunkAccumulatorTest.php b/tests/unit/Results/ChunkAccumulatorTest.php index 2607bb01..d4afd92b 100644 --- a/tests/unit/Results/ChunkAccumulatorTest.php +++ b/tests/unit/Results/ChunkAccumulatorTest.php @@ -97,7 +97,7 @@ private function createChunk( } /** - * The id is captured from the first chunk that reports one; later ids are ignored. + * Tests that the id is captured from the first chunk that reports one. */ public function testCapturesIdFromFirstChunkThatReportsOne(): void { @@ -110,7 +110,7 @@ public function testCapturesIdFromFirstChunkThatReportsOne(): void } /** - * Token usage is last-wins (the final usage event is authoritative). + * Tests that token usage is last-wins. */ public function testTokenUsageIsLastWins(): void { @@ -125,7 +125,7 @@ public function testTokenUsageIsLastWins(): void } /** - * Additional data is merged across chunks, with later chunks winning on conflicts. + * Tests that additional data is merged with later chunks winning. */ public function testAdditionalDataMergedWithLaterChunksWinning(): void { @@ -140,7 +140,7 @@ public function testAdditionalDataMergedWithLaterChunksWinning(): void } /** - * Text on the same channel is concatenated across deltas into one part. + * Tests that text on the same channel is concatenated into one part. */ public function testTextConcatenatedPerChannel(): void { @@ -155,7 +155,7 @@ public function testTextConcatenatedPerChannel(): void } /** - * Reasoning and content become separate parts, in arrival (channel-first-seen) order. + * Tests that reasoning and content become separate parts in arrival order. */ public function testReasoningAndContentAreSeparatePartsInArrivalOrder(): void { @@ -173,7 +173,7 @@ public function testReasoningAndContentAreSeparatePartsInArrivalOrder(): void } /** - * The thought signature is captured last-wins per channel; a null signature does not clear it. + * Tests that the thought signature is captured last-wins and a null signature does not clear it. */ public function testThoughtSignatureCapturedLastWinsPerChannel(): void { @@ -194,7 +194,7 @@ public function testThoughtSignatureCapturedLastWinsPerChannel(): void } /** - * The finish reason defaults to stop when no delta reports one. + * Tests that the finish reason defaults to stop when not reported. */ public function testFinishReasonDefaultsToStopWhenNotReported(): void { @@ -205,7 +205,7 @@ public function testFinishReasonDefaultsToStopWhenNotReported(): void } /** - * The reported finish reason is used when present. + * Tests that the reported finish reason is used when present. */ public function testFinishReasonUsedWhenReported(): void { @@ -218,7 +218,7 @@ public function testFinishReasonUsedWhenReported(): void } /** - * A non-text part is kept whole and placed after the text parts. + * Tests that a non-text part is kept and placed after the text parts. */ public function testNonTextPartIsKeptAndPlacedAfterText(): void { @@ -236,7 +236,7 @@ public function testNonTextPartIsKeptAndPlacedAfterText(): void } /** - * Tool-call fragments are stitched by slot into a complete function call. + * Tests that tool-call fragments are stitched by slot into a function call. */ public function testToolCallReassembledFromFragments(): void { @@ -259,7 +259,7 @@ public function testToolCallReassembledFromFragments(): void } /** - * Tool-call id and name are first-wins across fragments. + * Tests that tool-call id and name are first-wins across fragments. */ public function testToolCallIdAndNameAreFirstWins(): void { @@ -278,7 +278,7 @@ public function testToolCallIdAndNameAreFirstWins(): void } /** - * Tool-call arguments: valid JSON decodes, broken JSON is kept raw, empty becomes null. + * Tests decoding of tool-call arguments. * * @dataProvider toolCallArgumentsProvider * @@ -310,7 +310,7 @@ public function toolCallArgumentsProvider(): array } /** - * Parallel tool calls are emitted in slot-index order regardless of arrival order. + * Tests that parallel tool calls are emitted in slot-index order. */ public function testParallelToolCallsAreOrderedBySlot(): void { @@ -329,13 +329,15 @@ public function testParallelToolCallsAreOrderedBySlot(): void } /** - * A tool-call slot that never received an id or name is skipped (no crash, no part). + * Tests that a tool-call slot without an id or name is skipped. */ public function testToolCallSlotWithoutIdOrNameIsSkipped(): void { $acc = $this->createAccumulator(); $acc->add($this->createChunk(null, null, [], [ - new CandidateDelta(0, [$this->createContentPart('hi')], null, [new ToolCallDelta(0, null, null, '{"x":1}')]), + new CandidateDelta(0, [$this->createContentPart('hi')], null, [ + new ToolCallDelta(0, null, null, '{"x":1}'), + ]), ])); $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); @@ -344,7 +346,7 @@ public function testToolCallSlotWithoutIdOrNameIsSkipped(): void } /** - * Tool-call fragments without an index stitch into slot 0. + * Tests that tool-call fragments without an index stitch into slot 0. */ public function testToolCallDeltaWithoutIndexUsesSlotZero(): void { @@ -362,7 +364,7 @@ public function testToolCallDeltaWithoutIndexUsesSlotZero(): void } /** - * Candidates are separated by index and emitted in sorted index order. + * Tests that candidates are separated and sorted by index. */ public function testCandidatesAreSeparatedAndSortedByIndex(): void { @@ -379,7 +381,7 @@ public function testCandidatesAreSeparatedAndSortedByIndex(): void } /** - * hasCandidates() reflects whether any candidate delta has been folded in. + * Tests that hasCandidates() reflects the accumulated state. */ public function testHasCandidatesReflectsAccumulatedState(): void { @@ -390,7 +392,7 @@ public function testHasCandidatesReflectsAccumulatedState(): void } /** - * A metadata-only chunk (no candidate deltas) registers no candidate. + * Tests that a metadata-only chunk registers no candidate. */ public function testMetadataOnlyChunkRegistersNoCandidate(): void { @@ -401,7 +403,7 @@ public function testMetadataOnlyChunkRegistersNoCandidate(): void } /** - * build() throws when no candidate was accumulated. + * Tests that build() throws when no candidate was accumulated. */ public function testBuildThrowsWhenNoCandidates(): void { @@ -414,7 +416,7 @@ public function testBuildThrowsWhenNoCandidates(): void } /** - * build() applies defaults when id, usage, and additional data are absent. + * Tests that build() applies defaults when metadata is absent. */ public function testBuildAppliesDefaultsWhenMetadataAbsent(): void { @@ -428,7 +430,7 @@ public function testBuildAppliesDefaultsWhenMetadataAbsent(): void } /** - * build() carries the provider and model metadata into the result. + * Tests that build() uses the provider and model metadata. */ public function testBuildUsesProviderAndModelMetadata(): void { @@ -441,7 +443,7 @@ public function testBuildUsesProviderAndModelMetadata(): void } /** - * A candidate that received no content builds a valid, empty message. + * Tests that a candidate with no parts builds an empty message. */ public function testCandidateWithNoPartsBuildsEmptyMessage(): void { @@ -454,7 +456,7 @@ public function testCandidateWithNoPartsBuildsEmptyMessage(): void } /** - * Tool-call arguments reassemble correctly no matter where the fragment boundaries fall. + * Tests that tool-call arguments reassemble from many small fragments. */ public function testToolCallArgumentsReassembleFromManySmallFragments(): void { @@ -474,7 +476,7 @@ public function testToolCallArgumentsReassembleFromManySmallFragments(): void } /** - * Parallel tool-call fragments interleaved across separate deltas stitch per slot. + * Tests that parallel tool-call fragments interleaved across deltas stitch per slot. */ public function testParallelToolCallFragmentsInterleaveAcrossDeltas(): void { @@ -501,7 +503,7 @@ public function testParallelToolCallFragmentsInterleaveAcrossDeltas(): void } /** - * Reasoning and content interleaved across deltas concatenate per channel, not merge. + * Tests that interleaved reasoning and content concatenate per channel. */ public function testReasoningAndContentInterleaveAcrossDeltas(): void { diff --git a/tests/unit/Results/StreamedGenerativeAiResultTest.php b/tests/unit/Results/StreamedGenerativeAiResultTest.php index 8f7d78a1..fa7d2f4f 100644 --- a/tests/unit/Results/StreamedGenerativeAiResultTest.php +++ b/tests/unit/Results/StreamedGenerativeAiResultTest.php @@ -108,10 +108,7 @@ private function createFailingIterator(array $chunks, \Throwable $error): Genera } /** - * Creates a strict single-use source that fails if it is read again after exhaustion. - * - * This mirrors a real consumed stream (an HTTP/SSE body cannot be re-read), so a handle that - * re-touches an exhausted source surfaces a `LogicException` instead of finishing cleanly. + * Creates a single-use source that throws if it is read after exhaustion. * * @param list $chunks The chunks to yield once. * @return Iterator @@ -172,7 +169,7 @@ private function guardNotExhausted(): void } /** - * Iterating yields every chunk, in order. + * Tests that iterating yields all chunks in order. */ public function testIteratingYieldsAllChunksInOrder(): void { @@ -189,7 +186,7 @@ public function testIteratingYieldsAllChunksInOrder(): void } /** - * getFinalResult() assembles the result without the caller iterating. + * Tests that getFinalResult() assembles the result without iterating. */ public function testGetFinalResultAssemblesWithoutIterating(): void { @@ -206,7 +203,7 @@ public function testGetFinalResultAssemblesWithoutIterating(): void } /** - * Iterating and then calling getFinalResult() yields the same assembled result. + * Tests that iterating then getFinalResult() returns the same assembled result. */ public function testIterateThenGetFinalResultIsConsistent(): void { @@ -216,14 +213,14 @@ public function testIterateThenGetFinalResultIsConsistent(): void ]); foreach ($handle as $chunk) { - // drain + // Consume the stream but do nothing with the chunks. } $this->assertSame('Hello', $handle->getFinalResult()->toText()); } /** - * getFinalResult() returns the same instance on repeated calls. + * Tests that getFinalResult() returns the same instance on repeated calls. */ public function testGetFinalResultIsIdempotent(): void { @@ -236,7 +233,7 @@ public function testGetFinalResultIsIdempotent(): void } /** - * getFinalResult() after an early break drains the remainder and returns the full result. + * Tests that getFinalResult() drains the remainder after an early break. */ public function testGetFinalResultAfterEarlyBreakDrainsRemainder(): void { @@ -254,7 +251,7 @@ public function testGetFinalResultAfterEarlyBreakDrainsRemainder(): void } /** - * The completion callback fires once, with the result, after a full iteration. + * Tests that the completion callback fires once with the result after a full iteration. */ public function testOnCompleteFiresOnceWithResultOnFullIteration(): void { @@ -265,7 +262,7 @@ public function testOnCompleteFiresOnceWithResultOnFullIteration(): void }); foreach ($handle as $chunk) { - // drain + // Consume the stream but do nothing with the chunks. } $this->assertCount(1, $received); @@ -273,7 +270,7 @@ public function testOnCompleteFiresOnceWithResultOnFullIteration(): void } /** - * The completion callback fires on getFinalResult() without iterating. + * Tests that the completion callback fires on getFinalResult() without iterating. */ public function testOnCompleteFiresOnGetFinalResult(): void { @@ -289,7 +286,7 @@ public function testOnCompleteFiresOnGetFinalResult(): void } /** - * The completion callback fires only once across iteration and getFinalResult(). + * Tests that the completion callback fires only once across iteration and getFinalResult(). */ public function testOnCompleteFiresOnlyOnceAcrossIterateAndGetFinalResult(): void { @@ -300,7 +297,7 @@ public function testOnCompleteFiresOnlyOnceAcrossIterateAndGetFinalResult(): voi }); foreach ($handle as $chunk) { - // drain + // Consume the stream but do nothing with the chunks. } $handle->getFinalResult(); @@ -308,7 +305,7 @@ public function testOnCompleteFiresOnlyOnceAcrossIterateAndGetFinalResult(): voi } /** - * Multiple completion callbacks all fire, in registration order. + * Tests that multiple completion callbacks fire in registration order. */ public function testMultipleOnCompleteCallbacksFireInRegistrationOrder(): void { @@ -327,7 +324,7 @@ public function testMultipleOnCompleteCallbacksFireInRegistrationOrder(): void } /** - * The completion callback does not fire when the caller breaks before the stream ends. + * Tests that the completion callback does not fire on an early break. */ public function testOnCompleteNotFiredOnEarlyBreak(): void { @@ -348,7 +345,7 @@ public function testOnCompleteNotFiredOnEarlyBreak(): void } /** - * An empty stream throws on getFinalResult() and never fires the completion callback. + * Tests that an empty stream throws on getFinalResult() and fires no completion callback. */ public function testEmptyStreamThrowsAndDoesNotFireOnComplete(): void { @@ -369,8 +366,7 @@ public function testEmptyStreamThrowsAndDoesNotFireOnComplete(): void } /** - * After a candidate-less stream is drained by iteration, getFinalResult() still throws the - * no-candidates error and does not re-read the already-exhausted source. + * Tests that getFinalResult() after iterating an empty stream throws without re-reading the source. */ public function testGetFinalResultAfterIteratingEmptyStreamThrowsWithoutReReadingSource(): void { @@ -383,7 +379,7 @@ public function testGetFinalResultAfterIteratingEmptyStreamThrowsWithoutReReadin }); foreach ($handle as $chunk) { - // drain the metadata-only stream to completion + // Consume the stream but do nothing with the chunks. } try { @@ -397,7 +393,7 @@ public function testGetFinalResultAfterIteratingEmptyStreamThrowsWithoutReReadin } /** - * A stream that ends without a finish reason resolves with the default (stop). + * Tests that a stream without a finish reason defaults to stop. */ public function testPartialStreamWithoutFinishReasonDefaultsToStop(): void { @@ -408,7 +404,7 @@ public function testPartialStreamWithoutFinishReasonDefaultsToStop(): void } /** - * Iterating yields every chunk produced before an error, then propagates the error. + * Tests that iteration yields all chunks produced before an error, then propagates it. */ public function testIterationYieldsChunksBeforeAnErrorThenPropagates(): void { @@ -434,7 +430,7 @@ public function testIterationYieldsChunksBeforeAnErrorThenPropagates(): void } /** - * getFinalResult() propagates an error raised while draining the stream. + * Tests that getFinalResult() propagates an error raised while draining. */ public function testGetFinalResultPropagatesStreamError(): void { @@ -448,7 +444,7 @@ public function testGetFinalResultPropagatesStreamError(): void } /** - * The completion callback does not fire when the stream errors. + * Tests that the completion callback does not fire when the stream errors. */ public function testOnCompleteNotFiredWhenStreamErrors(): void { @@ -462,34 +458,33 @@ public function testOnCompleteNotFiredWhenStreamErrors(): void try { foreach ($handle as $chunk) { - // drain + // Consume the stream but do nothing with the chunks. } } catch (RuntimeException $e) { - // expected + // Expected error here. } $this->assertSame(0, $count); } /** - * The stream is single-use: a second iteration throws rather than silently yielding nothing. + * Tests that re-iterating a consumed stream throws. */ public function testReiteratingAConsumedStreamThrows(): void { $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); foreach ($handle as $chunk) { - // drain + // Consume the stream but do nothing with the chunks. } $this->expectException(RuntimeException::class); foreach ($handle as $chunk) { - // second iteration must not silently yield nothing } } /** - * Iterating after getFinalResult() throws (the stream has already been consumed). + * Tests that iterating after getFinalResult() throws. */ public function testIteratingAfterGetFinalResultThrows(): void { @@ -498,7 +493,6 @@ public function testIteratingAfterGetFinalResultThrows(): void $this->expectException(RuntimeException::class); foreach ($handle as $chunk) { - // already consumed } } } From b2f9fb70ba73b009526f188e331cca45f3a57fcc Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 17:06:34 +0530 Subject: [PATCH 53/71] Add value objects test cases --- .../ValueObjects/ServerSentEventTest.php | 40 ++++++ .../ValueObjects/CandidateDeltaTest.php | 124 ++++++++++++++++ .../GenerativeAiResultChunkTest.php | 136 ++++++++++++++++++ .../ValueObjects/ToolCallDeltaTest.php | 50 +++++++ 4 files changed, 350 insertions(+) create mode 100644 tests/unit/Providers/Http/Streaming/ValueObjects/ServerSentEventTest.php create mode 100644 tests/unit/Results/ValueObjects/CandidateDeltaTest.php create mode 100644 tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php create mode 100644 tests/unit/Results/ValueObjects/ToolCallDeltaTest.php diff --git a/tests/unit/Providers/Http/Streaming/ValueObjects/ServerSentEventTest.php b/tests/unit/Providers/Http/Streaming/ValueObjects/ServerSentEventTest.php new file mode 100644 index 00000000..1bd44272 --- /dev/null +++ b/tests/unit/Providers/Http/Streaming/ValueObjects/ServerSentEventTest.php @@ -0,0 +1,40 @@ +assertSame('completion', $event->getEvent()); + $this->assertSame('{"x":1}', $event->getData()); + $this->assertSame('evt-1', $event->getId()); + $this->assertSame(2000, $event->getRetry()); + } + + /** + * Tests that the id defaults to an empty string and retry to null. + */ + public function testDefaults(): void + { + $event = new ServerSentEvent('message', 'payload'); + + $this->assertSame('message', $event->getEvent()); + $this->assertSame('payload', $event->getData()); + $this->assertSame('', $event->getId()); + $this->assertNull($event->getRetry()); + } +} diff --git a/tests/unit/Results/ValueObjects/CandidateDeltaTest.php b/tests/unit/Results/ValueObjects/CandidateDeltaTest.php new file mode 100644 index 00000000..ac665b8a --- /dev/null +++ b/tests/unit/Results/ValueObjects/CandidateDeltaTest.php @@ -0,0 +1,124 @@ +createContentPart('Hi')]; + $toolCallDeltas = [new ToolCallDelta(0, 'call_1', 'fn', '{}')]; + $delta = new CandidateDelta(1, $parts, FinishReasonEnum::stop(), $toolCallDeltas); + + $this->assertSame(1, $delta->getIndex()); + $this->assertSame($parts, $delta->getParts()); + $this->assertTrue($delta->getFinishReason()->is(FinishReasonEnum::stop())); + $this->assertSame($toolCallDeltas, $delta->getToolCallDeltas()); + } + + /** + * Tests that the optional fields default to empty values and a null finish reason. + */ + public function testDefaults(): void + { + $delta = new CandidateDelta(0); + + $this->assertSame([], $delta->getParts()); + $this->assertNull($delta->getFinishReason()); + $this->assertSame([], $delta->getToolCallDeltas()); + } + + /** + * Tests that getDeltaText concatenates only the content-channel parts. + */ + public function testGetDeltaTextConcatenatesContentChannelOnly(): void + { + $delta = new CandidateDelta(0, [$this->createContentPart('Hel'), $this->createContentPart('lo')]); + + $this->assertSame('Hello', $delta->getDeltaText()); + } + + /** + * Tests that getReasoningDeltaText concatenates only the thought-channel parts. + */ + public function testGetReasoningDeltaTextConcatenatesThoughtChannelOnly(): void + { + $delta = new CandidateDelta(0, [$this->createReasoningPart('Think'), $this->createReasoningPart('ing')]); + + $this->assertSame('Thinking', $delta->getReasoningDeltaText()); + } + + /** + * Tests that the content and reasoning channels do not bleed into each other. + */ + public function testContentAndReasoningChannelsDoNotBleed(): void + { + $delta = new CandidateDelta(0, [$this->createReasoningPart('reason'), $this->createContentPart('answer')]); + + $this->assertSame('answer', $delta->getDeltaText()); + $this->assertSame('reason', $delta->getReasoningDeltaText()); + } + + /** + * Tests that a non-text content part is ignored when reading the delta text. + */ + public function testNonTextPartIsIgnoredInDeltaText(): void + { + $delta = new CandidateDelta(0, [ + $this->createContentPart('a'), + new MessagePart(new FunctionCall('id', 'fn', [])), + $this->createContentPart('b'), + ]); + + $this->assertSame('ab', $delta->getDeltaText()); + } + + /** + * Tests that a candidate with no parts yields empty delta text. + */ + public function testEmptyDeltaTextWhenNoParts(): void + { + $delta = new CandidateDelta(0); + + $this->assertSame('', $delta->getDeltaText()); + $this->assertSame('', $delta->getReasoningDeltaText()); + } +} diff --git a/tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php b/tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php new file mode 100644 index 00000000..f80d7301 --- /dev/null +++ b/tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php @@ -0,0 +1,136 @@ +createContentPart('Hi')])]; + $chunk = new GenerativeAiResultChunk('chatcmpl-1', $usage, ['model' => 'x'], $candidateDeltas); + + $this->assertSame('chatcmpl-1', $chunk->getId()); + $this->assertSame($usage, $chunk->getTokenUsage()); + $this->assertSame(['model' => 'x'], $chunk->getAdditionalData()); + $this->assertSame($candidateDeltas, $chunk->getCandidateDeltas()); + } + + /** + * Tests that the optional fields default to null and empty values. + */ + public function testDefaults(): void + { + $chunk = new GenerativeAiResultChunk(); + + $this->assertNull($chunk->getId()); + $this->assertNull($chunk->getTokenUsage()); + $this->assertSame([], $chunk->getAdditionalData()); + $this->assertSame([], $chunk->getCandidateDeltas()); + } + + /** + * Tests that getDeltaText concatenates the content text across candidate deltas. + */ + public function testGetDeltaTextFlattensAcrossCandidateDeltas(): void + { + $chunk = new GenerativeAiResultChunk(null, null, [], [ + new CandidateDelta(0, [$this->createContentPart('A')]), + new CandidateDelta(1, [$this->createContentPart('B')]), + ]); + + $this->assertSame('AB', $chunk->getDeltaText()); + } + + /** + * Tests that getReasoningDeltaText concatenates the thought text across candidate deltas. + */ + public function testGetReasoningDeltaTextFlattensAcrossCandidateDeltas(): void + { + $chunk = new GenerativeAiResultChunk(null, null, [], [ + new CandidateDelta(0, [$this->createReasoningPart('think ')]), + new CandidateDelta(1, [$this->createReasoningPart('more')]), + ]); + + $this->assertSame('think more', $chunk->getReasoningDeltaText()); + } + + /** + * Tests that getToolCallDeltas flattens the tool calls across candidate deltas. + */ + public function testGetToolCallDeltasFlattensAcrossCandidateDeltas(): void + { + $chunk = new GenerativeAiResultChunk(null, null, [], [ + new CandidateDelta(0, [], null, [new ToolCallDelta(0, 'a', 'fn_a', '')]), + new CandidateDelta(1, [], null, [new ToolCallDelta(0, 'b', 'fn_b', '')]), + ]); + + $deltas = $chunk->getToolCallDeltas(); + $this->assertCount(2, $deltas); + $this->assertSame('a', $deltas[0]->getId()); + $this->assertSame('b', $deltas[1]->getId()); + } + + /** + * Tests that a metadata-only chunk has empty convenience accessors. + */ + public function testConveniencesEmptyWhenMetadataOnly(): void + { + $chunk = new GenerativeAiResultChunk('id', new TokenUsage(1, 1, 2), ['model' => 'x'], []); + + $this->assertSame('', $chunk->getDeltaText()); + $this->assertSame('', $chunk->getReasoningDeltaText()); + $this->assertSame([], $chunk->getToolCallDeltas()); + } + + /** + * Tests that the content and reasoning channels do not bleed at the chunk level. + */ + public function testContentAndReasoningDoNotBleed(): void + { + $chunk = new GenerativeAiResultChunk(null, null, [], [ + new CandidateDelta(0, [$this->createReasoningPart('reason'), $this->createContentPart('answer')]), + ]); + + $this->assertSame('answer', $chunk->getDeltaText()); + $this->assertSame('reason', $chunk->getReasoningDeltaText()); + } +} diff --git a/tests/unit/Results/ValueObjects/ToolCallDeltaTest.php b/tests/unit/Results/ValueObjects/ToolCallDeltaTest.php new file mode 100644 index 00000000..43a21f3a --- /dev/null +++ b/tests/unit/Results/ValueObjects/ToolCallDeltaTest.php @@ -0,0 +1,50 @@ +assertSame(2, $delta->getIndex()); + $this->assertSame('call_1', $delta->getId()); + $this->assertSame('get_weather', $delta->getFunctionName()); + $this->assertSame('{"city":"SF"}', $delta->getArgumentsFragment()); + } + + /** + * Tests that the optional fields default to null and an empty fragment. + */ + public function testDefaultsWhenOnlyIndexProvided(): void + { + $delta = new ToolCallDelta(0); + + $this->assertSame(0, $delta->getIndex()); + $this->assertNull($delta->getId()); + $this->assertNull($delta->getFunctionName()); + $this->assertSame('', $delta->getArgumentsFragment()); + } + + /** + * Tests that a null index is preserved. + */ + public function testNullIndexIsPreserved(): void + { + $delta = new ToolCallDelta(null, 'call_1', 'fn', '{}'); + + $this->assertNull($delta->getIndex()); + } +} From ba6202948f22d4eb91415625fb42bcbbe1732947 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 17:07:41 +0530 Subject: [PATCH 54/71] Add test cases for exception handlers --- .../Http/Exception/ResponseExceptionTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/unit/Providers/Http/Exception/ResponseExceptionTest.php diff --git a/tests/unit/Providers/Http/Exception/ResponseExceptionTest.php b/tests/unit/Providers/Http/Exception/ResponseExceptionTest.php new file mode 100644 index 00000000..f154c96d --- /dev/null +++ b/tests/unit/Providers/Http/Exception/ResponseExceptionTest.php @@ -0,0 +1,70 @@ +assertSame('Error while streaming the OpenAI API response: connection reset', $exception->getMessage()); + } + + /** + * Tests that fromStreamError chains the previous exception when given one. + */ + public function testFromStreamErrorChainsPreviousException(): void + { + $previous = new RuntimeException('underlying'); + + $exception = ResponseException::fromStreamError('OpenAI', 'connection reset', $previous); + + $this->assertSame($previous, $exception->getPrevious()); + } + + /** + * Tests that fromStreamError has no previous exception by default. + */ + public function testFromStreamErrorHasNoPreviousByDefault(): void + { + $exception = ResponseException::fromStreamError('OpenAI', 'connection reset'); + + $this->assertNull($exception->getPrevious()); + } + + /** + * Tests that fromMissingData builds the missing-key message. + */ + public function testFromMissingDataMessage(): void + { + $exception = ResponseException::fromMissingData('OpenAI', 'choices'); + + $this->assertSame('Unexpected OpenAI API response: Missing the "choices" key.', $exception->getMessage()); + } + + /** + * Tests that fromInvalidData builds the invalid-key message. + */ + public function testFromInvalidDataMessage(): void + { + $exception = ResponseException::fromInvalidData('OpenAI', 'usage', 'not an object'); + + $this->assertSame( + 'Unexpected OpenAI API response: Invalid "usage" key: not an object', + $exception->getMessage() + ); + } +} From 35c8ba7c219346417f5beadeaff1b5f336e4b3c5 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 17:09:03 +0530 Subject: [PATCH 55/71] Add test for stream response --- .../unit/Providers/Http/DTO/ResponseTest.php | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/unit/Providers/Http/DTO/ResponseTest.php b/tests/unit/Providers/Http/DTO/ResponseTest.php index c5dd7815..a6115686 100644 --- a/tests/unit/Providers/Http/DTO/ResponseTest.php +++ b/tests/unit/Providers/Http/DTO/ResponseTest.php @@ -4,8 +4,10 @@ namespace WordPress\AiClient\Tests\unit\Providers\Http\DTO; +use GuzzleHttp\Psr7\Utils; use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Http\DTO\Response; +use WordPress\AiClient\Tests\mocks\ChunkStream; /** * @covers \WordPress\AiClient\Providers\Http\DTO\Response @@ -76,4 +78,68 @@ public function testCloneWorksWithNullBody(): void $this->assertEquals(204, $cloned->getStatusCode()); $this->assertTrue($cloned->hasHeader('X-Request-Id')); } + + /** + * Tests that a stream body is returned as-is by getStream. + * + * @return void + */ + public function testGetStreamReturnsTheStreamBody(): void + { + $stream = new ChunkStream(['{"ok":true}']); + $response = new Response(200, [], $stream); + + $this->assertSame($stream, $response->getStream()); + } + + /** + * Tests that a streamed body is read into a string by getBody. + * + * @return void + */ + public function testStreamedBodyIsReadByGetBody(): void + { + $response = new Response(200, [], new ChunkStream(['{"ok":', 'true}'])); + + $this->assertSame('{"ok":true}', $response->getBody()); + } + + /** + * Tests that a streamed JSON body is decoded by getData. + * + * @return void + */ + public function testStreamedBodyIsDecodedByGetData(): void + { + $response = new Response(200, [], new ChunkStream(['{"ok":true}'])); + + $this->assertSame(['ok' => true], $response->getData()); + } + + /** + * Tests that a buffered body is wrapped in a stream by getStream. + * + * @return void + */ + public function testBufferedBodyIsWrappedInStreamByGetStream(): void + { + $response = new Response(200, [], 'hello'); + + $this->assertSame('hello', (string) $response->getStream()); + } + + /** + * Tests that a seekable stream body is rewound before being read. + * + * @return void + */ + public function testSeekableStreamBodyIsRewoundBeforeRead(): void + { + $stream = Utils::streamFor('{"ok":true}'); + $stream->read(2); + + $response = new Response(200, [], $stream); + + $this->assertSame('{"ok":true}', $response->getBody()); + } } From e55e4296820c35de123b6945669315a9c525325c Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 17:10:45 +0530 Subject: [PATCH 56/71] Add test for streaming text support in ai client --- tests/unit/AiClientTest.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index c573c756..3cbc3563 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -12,6 +12,8 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Results\StreamedGenerativeAiResult; use WordPress\AiClient\Tests\mocks\MockProvider; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; @@ -798,4 +800,30 @@ public function testGetConfiguredPromptBuilderHelperIntegration(): void $this->expectExceptionMessageMatches('/No models found that support/'); AiClient::generateResult($prompt, null, $this->createMockEmptyRegistry()); } + + /** + * Tests that streamGenerateTextResult delegates to the prompt builder and returns a handle. + */ + public function testStreamGenerateTextResultWithStringAndModel(): void + { + $model = $this->createMockStreamingTextGenerationModel([ + $this->createStreamingTextChunk('Hello', FinishReasonEnum::stop()), + ]); + $registry = $this->createRegistryWithMockProvider(); + + $handle = AiClient::streamGenerateTextResult('Generate text', $model, $registry); + + $this->assertInstanceOf(StreamedGenerativeAiResult::class, $handle); + $this->assertSame('Hello', $handle->getFinalResult()->toText()); + } + + /** + * Tests that streamGenerateTextResult validates the model-or-config parameter. + */ + public function testStreamGenerateTextResultWithInvalidModel(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter must be a ModelInterface'); + AiClient::streamGenerateTextResult('Generate text', 'invalid', $this->createRegistryWithMockProvider()); + } } From 91ae1c4c73b8c345897455e07ee5825389d53195 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 17:12:08 +0530 Subject: [PATCH 57/71] Add test case for stream option in request options --- .../Providers/Http/DTO/RequestOptionsTest.php | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php index 12f77446..b30808dc 100644 --- a/tests/unit/Providers/Http/DTO/RequestOptionsTest.php +++ b/tests/unit/Providers/Http/DTO/RequestOptionsTest.php @@ -94,4 +94,68 @@ public function testGetJsonSchemaDefinesNullableMaxRedirects(): void $this->assertSame(['integer', 'null'], $schema['properties'][RequestOptions::KEY_MAX_REDIRECTS]['type']); } + + /** + * Tests that setStream toggles the stream flag. + * + * @return void + */ + public function testSetStreamTogglesStreamFlag(): void + { + $options = new RequestOptions(); + $options->setStream(true); + $this->assertTrue($options->isStream()); + + $options->setStream(false); + $this->assertFalse($options->isStream()); + } + + /** + * Tests that the stream flag is null by default. + * + * @return void + */ + public function testStreamIsNullByDefault(): void + { + $this->assertNull((new RequestOptions())->isStream()); + } + + /** + * Tests that toArray includes the stream flag only when it is set. + * + * @return void + */ + public function testToArrayIncludesStreamWhenSet(): void + { + $options = new RequestOptions(); + $this->assertArrayNotHasKey(RequestOptions::KEY_STREAM, $options->toArray()); + + $options->setStream(true); + $this->assertTrue($options->toArray()[RequestOptions::KEY_STREAM]); + } + + /** + * Tests that fromArray reads the stream flag. + * + * @return void + */ + public function testFromArrayReadsStream(): void + { + $options = RequestOptions::fromArray([RequestOptions::KEY_STREAM => true]); + + $this->assertInstanceOf(RequestOptions::class, $options); + $this->assertTrue($options->isStream()); + } + + /** + * Tests that the JSON schema defines a nullable boolean stream flag. + * + * @return void + */ + public function testGetJsonSchemaDefinesNullableStream(): void + { + $schema = RequestOptions::getJsonSchema(); + + $this->assertSame(['boolean', 'null'], $schema['properties'][RequestOptions::KEY_STREAM]['type']); + } } From c96776bfa64a257466f7a60e71ded9b0f579e29d Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:05:46 +0530 Subject: [PATCH 58/71] Add event to generate result error event --- src/Events/GenerateResultErrorEvent.php | 122 ++++++++++++++++++ .../Events/GenerateResultErrorEventTest.php | 75 +++++++++++ 2 files changed, 197 insertions(+) create mode 100644 src/Events/GenerateResultErrorEvent.php create mode 100644 tests/unit/Events/GenerateResultErrorEventTest.php diff --git a/src/Events/GenerateResultErrorEvent.php b/src/Events/GenerateResultErrorEvent.php new file mode 100644 index 00000000..6d3ff499 --- /dev/null +++ b/src/Events/GenerateResultErrorEvent.php @@ -0,0 +1,122 @@ + The messages that were sent to the model. + */ + private array $messages; + + /** + * @var ModelInterface The model that processed the prompt. + */ + private ModelInterface $model; + + /** + * @var CapabilityEnum|null The capability that was used for generation. + */ + private ?CapabilityEnum $capability; + + /** + * @var Throwable The error that occurred during generation. + */ + private Throwable $error; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param list $messages The messages that were sent to the model. + * @param ModelInterface $model The model that processed the prompt. + * @param CapabilityEnum|null $capability The capability that was used for generation. + * @param Throwable $error The error that occurred during generation. + */ + public function __construct( + array $messages, + ModelInterface $model, + ?CapabilityEnum $capability, + Throwable $error + ) { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + $this->error = $error; + } + + /** + * Gets the messages that were sent to the model. + * + * @since n.e.x.t + * + * @return list The messages. + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Gets the model that processed the prompt. + * + * @since n.e.x.t + * + * @return ModelInterface The model. + */ + public function getModel(): ModelInterface + { + return $this->model; + } + + /** + * Gets the capability that was used for generation. + * + * @since n.e.x.t + * + * @return CapabilityEnum|null The capability, or null if not specified. + */ + public function getCapability(): ?CapabilityEnum + { + return $this->capability; + } + + /** + * Gets the error that occurred during generation. + * + * @since n.e.x.t + * + * @return Throwable The error. + */ + public function getError(): Throwable + { + return $this->error; + } + + /** + * Performs a deep clone of the event. + * + * @since n.e.x.t + */ + public function __clone() + { + $clonedMessages = []; + foreach ($this->messages as $message) { + $clonedMessages[] = clone $message; + } + $this->messages = $clonedMessages; + } +} diff --git a/tests/unit/Events/GenerateResultErrorEventTest.php b/tests/unit/Events/GenerateResultErrorEventTest.php new file mode 100644 index 00000000..fdc96e0c --- /dev/null +++ b/tests/unit/Events/GenerateResultErrorEventTest.php @@ -0,0 +1,75 @@ +createMockTextGenerationModel($this->createTestResult()); + $capability = CapabilityEnum::textGeneration(); + $error = new RuntimeException('stream failed'); + + $event = new GenerateResultErrorEvent($messages, $model, $capability, $error); + + $this->assertSame($messages, $event->getMessages()); + $this->assertSame($model, $event->getModel()); + $this->assertSame($capability, $event->getCapability()); + $this->assertSame($error, $event->getError()); + } + + /** + * Tests event construction with null capability. + * + * @return void + */ + public function testConstructionWithNullCapability(): void + { + $messages = [new UserMessage([new MessagePart('Hello')])]; + $model = $this->createMockTextGenerationModel($this->createTestResult()); + + $event = new GenerateResultErrorEvent($messages, $model, null, new RuntimeException('boom')); + + $this->assertNull($event->getCapability()); + } + + /** + * Tests that cloning copies messages but keeps the model and error instances. + * + * @return void + */ + public function testCloneClonesMessagesOnly(): void + { + $messages = [new UserMessage([new MessagePart('Hello')])]; + $model = $this->createMockTextGenerationModel($this->createTestResult()); + $error = new RuntimeException('boom'); + + $original = new GenerateResultErrorEvent($messages, $model, CapabilityEnum::textGeneration(), $error); + $cloned = clone $original; + + $this->assertNotSame($original->getMessages()[0], $cloned->getMessages()[0]); + $this->assertSame($original->getModel(), $cloned->getModel()); + $this->assertSame($original->getError(), $cloned->getError()); + } +} From 9ee700e2b3b4afcd5598bd07ab17b47079fae845 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:08:44 +0530 Subject: [PATCH 59/71] Update chunk accumulator to force channel order --- src/Results/ChunkAccumulator.php | 16 +++++++++++++--- tests/unit/Results/ChunkAccumulatorTest.php | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/Results/ChunkAccumulator.php b/src/Results/ChunkAccumulator.php index ca9f9a5b..b0511ffb 100644 --- a/src/Results/ChunkAccumulator.php +++ b/src/Results/ChunkAccumulator.php @@ -27,6 +27,14 @@ */ final class ChunkAccumulator { + /** + * @var list Canonical channel order for assembled parts, matching the buffered parser. + */ + private const CANONICAL_CHANNEL_ORDER = [ + MessagePartChannelEnum::THOUGHT, + MessagePartChannelEnum::CONTENT, + ]; + private ProviderMetadata $providerMetadata; private ModelMetadata $modelMetadata; @@ -264,10 +272,12 @@ private function buildCandidate(int $index): Candidate { $parts = []; - // Store text parts first. - foreach ($this->text[$index] ?? [] as $channel => $text) { + foreach (self::CANONICAL_CHANNEL_ORDER as $channel) { + if (!isset($this->text[$index][$channel])) { + continue; + } $parts[] = new MessagePart( - $text, + $this->text[$index][$channel], MessagePartChannelEnum::from($channel), $this->thoughtSignatures[$index][$channel] ?? null ); diff --git a/tests/unit/Results/ChunkAccumulatorTest.php b/tests/unit/Results/ChunkAccumulatorTest.php index d4afd92b..11a7bba0 100644 --- a/tests/unit/Results/ChunkAccumulatorTest.php +++ b/tests/unit/Results/ChunkAccumulatorTest.php @@ -520,4 +520,22 @@ public function testReasoningAndContentInterleaveAcrossDeltas(): void $this->assertTrue($parts[1]->getChannel()->is(MessagePartChannelEnum::content())); $this->assertSame('Hi there', $parts[1]->getText()); } + + /** + * Tests that parts are ordered thought-then-content even when content arrives first. + */ + public function testContentArrivingBeforeReasoningStillOrdersThoughtFirst(): void + { + $acc = $this->createAccumulator(); + $acc->add($this->createChunk(null, null, [], [ + new CandidateDelta(0, [$this->createContentPart('answer'), $this->createReasoningPart('reason')]), + ])); + + $parts = $acc->build()->getCandidates()[0]->getMessage()->getParts(); + $this->assertCount(2, $parts); + $this->assertTrue($parts[0]->getChannel()->is(MessagePartChannelEnum::thought())); + $this->assertSame('reason', $parts[0]->getText()); + $this->assertTrue($parts[1]->getChannel()->is(MessagePartChannelEnum::content())); + $this->assertSame('answer', $parts[1]->getText()); + } } From 6587a5faacfec753424e7178d5423d80419946bb Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:10:36 +0530 Subject: [PATCH 60/71] Fix docblock tag order --- src/Providers/Http/Exception/ResponseException.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Providers/Http/Exception/ResponseException.php b/src/Providers/Http/Exception/ResponseException.php index c37b3d51..5c90ddd2 100644 --- a/src/Providers/Http/Exception/ResponseException.php +++ b/src/Providers/Http/Exception/ResponseException.php @@ -55,9 +55,8 @@ public static function fromInvalidData(string $apiName, string $fieldName, strin * * @param string $apiName The name of the API/provider. * @param string $message The error message. - * @return self - * * @param \Throwable|null $previous The underlying exception, when wrapping one. + * @return self The exception describing the stream error. */ public static function fromStreamError(string $apiName, string $message, ?\Throwable $previous = null): self { From fec38bfe2ae56181f1f67fd7de0534e81f599817 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:18:35 +0530 Subject: [PATCH 61/71] Add lifecycle events on gen ai result streams --- src/Results/StreamedGenerativeAiResult.php | 112 ++++++++++++++++++--- 1 file changed, 98 insertions(+), 14 deletions(-) diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php index 5ed6b811..dcdcb307 100644 --- a/src/Results/StreamedGenerativeAiResult.php +++ b/src/Results/StreamedGenerativeAiResult.php @@ -7,6 +7,7 @@ use Generator; use Iterator; use IteratorAggregate; +use Throwable; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -29,11 +30,21 @@ final class StreamedGenerativeAiResult implements IteratorAggregate private ChunkAccumulator $accumulator; + /** + * @var list Callbacks run once when consumption begins. + */ + private array $startCallbacks = []; + /** * @var list Callbacks run once when the result is assembled. */ private array $completionCallbacks = []; + /** + * @var list Callbacks run once when consumption fails. + */ + private array $errorCallbacks = []; + /** * @var bool Whether the source stream has been started. */ @@ -44,6 +55,11 @@ final class StreamedGenerativeAiResult implements IteratorAggregate */ private bool $finished = false; + /** + * @var bool Whether a terminal outcome (completion or error) has been reached. + */ + private bool $finalized = false; + /** * @var GenerativeAiResult|null The assembled result, once built. */ @@ -64,6 +80,21 @@ public function __construct(Iterator $chunks, ProviderMetadata $providerMetadata $this->accumulator = new ChunkAccumulator($providerMetadata, $modelMetadata); } + /** + * Registers a callback to run once, when consumption begins. + * + * @since n.e.x.t + * + * @param callable(): void $callback The callback. + * @return self + */ + public function onStart(callable $callback): self + { + $this->startCallbacks[] = $callback; + + return $this; + } + /** * Registers a callback to run once, when the final result is first assembled. * @@ -79,6 +110,21 @@ public function onComplete(callable $callback): self return $this; } + /** + * Registers a callback to run once, when consumption fails. + * + * @since n.e.x.t + * + * @param callable(Throwable): void $callback Receives the error. + * @return self + */ + public function onError(callable $callback): self + { + $this->errorCallbacks[] = $callback; + + return $this; + } + /** * Yields each chunk as it is read, folding it into the accumulated state. * @@ -96,15 +142,20 @@ public function getIterator(): Generator ); } - while (true) { - $chunk = $this->pull(); - if ($chunk === null) { - break; + try { + while (true) { + $chunk = $this->pull(); + if ($chunk === null) { + break; + } + yield $chunk; } - yield $chunk; - } - $this->finalize(); + $this->finalize(); + } catch (Throwable $e) { + $this->fail($e); + throw $e; + } } /** @@ -118,14 +169,22 @@ public function getIterator(): Generator public function getFinalResult(): GenerativeAiResult { if ($this->result === null) { - while ($this->pull() !== null) { - // Drain any remaining chunks so the result is complete. + try { + while ($this->pull() !== null) { + // Drain any remaining chunks so the result is complete. + } + + $this->finalize(); + } catch (Throwable $e) { + $this->fail($e); + throw $e; } - $this->finalize(); } if ($this->result === null) { - throw new RuntimeException('The stream produced no candidates.'); + $error = new RuntimeException('The stream produced no candidates.'); + $this->fail($error); + throw $error; } return $this->result; @@ -140,10 +199,11 @@ public function getFinalResult(): GenerativeAiResult */ private function finalize(): void { - if (!$this->accumulator->hasCandidates()) { + if ($this->finalized || !$this->accumulator->hasCandidates()) { return; } + $this->finalized = true; $this->result = $this->accumulator->build(); foreach ($this->completionCallbacks as $callback) { @@ -151,6 +211,27 @@ private function finalize(): void } } + /** + * Reaches the terminal "errored" state exactly once and runs the error callbacks. + * + * @since n.e.x.t + * + * @param Throwable $error The error that ended consumption. + * @return void + */ + private function fail(Throwable $error): void + { + if ($this->finalized) { + return; + } + + $this->finalized = true; + + foreach ($this->errorCallbacks as $callback) { + $callback($error); + } + } + /** * Reads the next chunk from the source and folds it into the accumulated state. * @@ -160,13 +241,16 @@ private function finalize(): void */ private function pull(): ?GenerativeAiResultChunk { - if ($this->finished) { + if ($this->finished || $this->finalized) { return null; } if (!$this->started) { - $this->chunks->rewind(); $this->started = true; + foreach ($this->startCallbacks as $callback) { + $callback(); + } + $this->chunks->rewind(); } else { $this->chunks->next(); } From 43b15c2861bf17daef25516cfe4f5b4d12335706 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:20:08 +0530 Subject: [PATCH 62/71] Add tets cases for stream lifecycle events --- .../StreamedGenerativeAiResultTest.php | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/unit/Results/StreamedGenerativeAiResultTest.php b/tests/unit/Results/StreamedGenerativeAiResultTest.php index fa7d2f4f..129bd512 100644 --- a/tests/unit/Results/StreamedGenerativeAiResultTest.php +++ b/tests/unit/Results/StreamedGenerativeAiResultTest.php @@ -495,4 +495,168 @@ public function testIteratingAfterGetFinalResultThrows(): void foreach ($handle as $chunk) { } } + + /** + * Tests that the start callback fires exactly once when consumption begins. + */ + public function testOnStartFiresOnceWhenConsumptionBegins(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->onStart(function () use (&$count) { + $count++; + }); + + foreach ($handle as $chunk) { + } + + $this->assertSame(1, $count); + } + + /** + * Tests that the start callback does not fire until the stream is consumed. + */ + public function testOnStartNotFiredUntilConsumed(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->onStart(function () use (&$count) { + $count++; + }); + + $this->assertSame(0, $count); + + $handle->getFinalResult(); + + $this->assertSame(1, $count); + } + + /** + * Tests that the start callback fires even when the caller breaks early. + */ + public function testOnStartFiresOnEarlyBreak(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([ + $this->createContentChunk('a'), + $this->createContentChunk('b', FinishReasonEnum::stop()), + ]); + $handle->onStart(function () use (&$count) { + $count++; + }); + + foreach ($handle as $chunk) { + break; + } + + $this->assertSame(1, $count); + } + + /** + * Tests that the error callback fires once with the error when iterating fails. + */ + public function testOnErrorFiresOnceWhenIterationFails(): void + { + $received = []; + $handle = $this->createHandle( + $this->createFailingIterator([$this->createContentChunk('a')], new RuntimeException('stream failed')) + ); + $handle->onError(function (\Throwable $e) use (&$received) { + $received[] = $e; + }); + + try { + foreach ($handle as $chunk) { + } + } catch (RuntimeException $e) { + } + + $this->assertCount(1, $received); + $this->assertSame('stream failed', $received[0]->getMessage()); + } + + /** + * Tests that the error callback fires when getFinalResult() drains a failing stream. + */ + public function testOnErrorFiresWhenGetFinalResultFails(): void + { + $count = 0; + $handle = $this->createHandle( + $this->createFailingIterator([$this->createContentChunk('a')], new RuntimeException('stream failed')) + ); + $handle->onError(function () use (&$count) { + $count++; + }); + + try { + $handle->getFinalResult(); + } catch (RuntimeException $e) { + } + + $this->assertSame(1, $count); + } + + /** + * Tests that the error callback does not fire on a successful stream. + */ + public function testOnErrorNotFiredOnSuccess(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([$this->createContentChunk('hi', FinishReasonEnum::stop())]); + $handle->onError(function () use (&$count) { + $count++; + }); + + $handle->getFinalResult(); + + $this->assertSame(0, $count); + } + + /** + * Tests that the error callback does not fire when the caller breaks early. + */ + public function testOnErrorNotFiredOnEarlyBreak(): void + { + $count = 0; + $handle = $this->createHandleFromChunks([ + $this->createContentChunk('a'), + $this->createContentChunk('b', FinishReasonEnum::stop()), + ]); + $handle->onError(function () use (&$count) { + $count++; + }); + + foreach ($handle as $chunk) { + break; + } + + $this->assertSame(0, $count); + } + + /** + * Tests that the error callback fires at most once across iteration and getFinalResult(). + */ + public function testOnErrorFiresAtMostOnceAcrossIterationAndGetFinalResult(): void + { + $count = 0; + $handle = $this->createHandle( + $this->createFailingIterator([$this->createContentChunk('a')], new RuntimeException('stream failed')) + ); + $handle->onError(function () use (&$count) { + $count++; + }); + + try { + foreach ($handle as $chunk) { + } + } catch (RuntimeException $e) { + } + + try { + $handle->getFinalResult(); + } catch (RuntimeException $e) { + } + + $this->assertSame(1, $count); + } } From b21bb8fcb26d8cfebbc82381f84005dc56b20211 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:21:14 +0530 Subject: [PATCH 63/71] Add coercion for usage values --- ...actOpenAiCompatibleTextGenerationModel.php | 29 ++++++++++++------- ...penAiCompatibleTextGenerationModelTest.php | 26 +++++++++++++++++ 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index ae753f4c..ebf9282c 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -364,22 +364,29 @@ protected function parseStreamToolCallDeltas(array $delta): array */ protected function parseUsageData(array $usage): TokenUsage { - $thoughtTokens = null; - if ( - isset($usage['completion_tokens_details']['reasoning_tokens']) - && is_int($usage['completion_tokens_details']['reasoning_tokens']) - ) { - $thoughtTokens = $usage['completion_tokens_details']['reasoning_tokens']; - } + $reasoningTokens = $usage['completion_tokens_details']['reasoning_tokens'] ?? null; return new TokenUsage( - $usage['prompt_tokens'] ?? 0, - $usage['completion_tokens'] ?? 0, - $usage['total_tokens'] ?? 0, - $thoughtTokens + $this->toIntOrZero($usage['prompt_tokens'] ?? 0), + $this->toIntOrZero($usage['completion_tokens'] ?? 0), + $this->toIntOrZero($usage['total_tokens'] ?? 0), + is_numeric($reasoningTokens) ? (int) $reasoningTokens : null ); } + /** + * Coerces an untrusted usage value to an integer, defaulting to zero. + * + * @since n.e.x.t + * + * @param mixed $value The raw value from the decoded usage payload. + * @return int The coerced integer, or 0 when the value is not numeric. + */ + private function toIntOrZero($value): int + { + return is_numeric($value) ? (int) $value : 0; + } + /** * Extracts provider-specific metadata from a response or stream event. * diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 7b0d98f4..36c87e46 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -2107,6 +2107,32 @@ public function usageDataProvider(): array 2, null, ], + 'numeric-string tokens are coerced to int' => [ + ['prompt_tokens' => '15', 'completion_tokens' => '20', 'total_tokens' => '35'], + 15, + 20, + 35, + null, + ], + 'float tokens are coerced to int' => [ + ['prompt_tokens' => 15.0, 'completion_tokens' => 20.0, 'total_tokens' => 35.0], + 15, + 20, + 35, + null, + ], + 'numeric-string reasoning tokens are coerced to int' => [ + [ + 'prompt_tokens' => 1, + 'completion_tokens' => 1, + 'total_tokens' => 2, + 'completion_tokens_details' => ['reasoning_tokens' => '10'], + ], + 1, + 1, + 2, + 10, + ], ]; } From f05dce8c8f187eb63ec198230ab25e9b709cc76f Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:22:48 +0530 Subject: [PATCH 64/71] Update stream chunks data type --- tests/traits/MockModelCreationTrait.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index 750a480f..4a6c9a3d 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -393,12 +393,12 @@ protected function createStreamingTextChunk( /** * Creates a mock model that streams the given chunks. * - * @param list $chunks The chunks to stream. + * @param iterable $chunks The chunks to stream. * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). * @return ModelInterface&StreamingTextGenerationModelInterface The mock model. */ protected function createMockStreamingTextGenerationModel( - array $chunks, + iterable $chunks, ?ModelMetadata $metadata = null ): ModelInterface { $metadata = $metadata ?? $this->createTestTextModelMetadata(); @@ -416,17 +416,17 @@ protected function createMockStreamingTextGenerationModel( ) implements ModelInterface, TextGenerationModelInterface, StreamingTextGenerationModelInterface { private ModelMetadata $metadata; private ProviderMetadata $providerMetadata; - /** @var list */ - private array $chunks; + /** @var iterable */ + private iterable $chunks; private ModelConfig $config; /** - * @param list $chunks + * @param iterable $chunks */ public function __construct( ModelMetadata $metadata, ProviderMetadata $providerMetadata, - array $chunks + iterable $chunks ) { $this->metadata = $metadata; $this->providerMetadata = $providerMetadata; @@ -461,8 +461,10 @@ public function generateTextResult(array $prompt): GenerativeAiResult public function streamGenerateTextResult(array $prompt): StreamedGenerativeAiResult { + $source = is_array($this->chunks) ? new ArrayIterator($this->chunks) : $this->chunks; + return new StreamedGenerativeAiResult( - new ArrayIterator($this->chunks), + $source, $this->providerMetadata, $this->metadata ); From f866fcf1a814d1bab749dd230f19ca54c46a9e36 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 19:25:38 +0530 Subject: [PATCH 65/71] Update event dispatch in prompt builder --- src/Builders/PromptBuilder.php | 16 ++++--- .../PromptBuilderEventDispatchingTest.php | 42 +++++++++++++++++-- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 22a59853..c9e56a2d 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateResultEvent; use WordPress\AiClient\Events\BeforeGenerateResultEvent; +use WordPress\AiClient\Events\GenerateResultErrorEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Files\Enums\MediaOrientationEnum; @@ -1078,13 +1079,16 @@ public function streamGenerateTextResult(): StreamedGenerativeAiResult $messages = $this->messages; - $this->dispatchEvent(new BeforeGenerateResultEvent($messages, $model, $capability)); - - return $model->streamGenerateTextResult($messages)->onComplete( - function (GenerativeAiResult $result) use ($messages, $model, $capability): void { + return $model->streamGenerateTextResult($messages) + ->onStart(function () use ($messages, $model, $capability): void { + $this->dispatchEvent(new BeforeGenerateResultEvent($messages, $model, $capability)); + }) + ->onComplete(function (GenerativeAiResult $result) use ($messages, $model, $capability): void { $this->dispatchEvent(new AfterGenerateResultEvent($messages, $model, $capability, $result)); - } - ); + }) + ->onError(function (\Throwable $error) use ($messages, $model, $capability): void { + $this->dispatchEvent(new GenerateResultErrorEvent($messages, $model, $capability, $error)); + }); } /** diff --git a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php index 724455a4..a281ca40 100644 --- a/tests/unit/Builders/PromptBuilderEventDispatchingTest.php +++ b/tests/unit/Builders/PromptBuilderEventDispatchingTest.php @@ -5,9 +5,11 @@ namespace WordPress\AiClient\Tests\unit\Builders; use PHPUnit\Framework\TestCase; +use RuntimeException; use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Events\AfterGenerateResultEvent; use WordPress\AiClient\Events\BeforeGenerateResultEvent; +use WordPress\AiClient\Events\GenerateResultErrorEvent; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\TokenUsage; @@ -180,16 +182,50 @@ private function createStreamingBuilderWithDispatcher(): PromptBuilder } /** - * Tests that the Before event is dispatched before the stream is consumed. + * Tests that the Before event is not dispatched until the stream is consumed. * * @return void */ - public function testStreamingDispatchesBeforeEventBeforeConsumption(): void + public function testStreamingDoesNotDispatchBeforeEventUntilConsumed(): void { - $this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult(); + $handle = $this->createStreamingBuilderWithDispatcher()->streamGenerateTextResult(); + + $this->assertCount(0, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); + + $handle->getFinalResult(); + + $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); + } + + /** + * Tests that a stream failure dispatches the error event and no After event. + * + * @return void + */ + public function testStreamingDispatchesErrorEventOnStreamFailure(): void + { + $chunk = $this->createStreamingTextChunk('Hello'); + $source = (function () use ($chunk) { + yield $chunk; + throw new RuntimeException('stream failed'); + })(); + $model = $this->createMockStreamingTextGenerationModel($source); + + $builder = new PromptBuilder($this->registry, 'Hello', $this->dispatcher); + $builder->usingModel($model); + + try { + foreach ($builder->streamGenerateTextResult() as $streamChunk) { + } + } catch (RuntimeException $e) { + } $this->assertCount(1, $this->dispatcher->getDispatchedEventsOfType(BeforeGenerateResultEvent::class)); $this->assertCount(0, $this->dispatcher->getDispatchedEventsOfType(AfterGenerateResultEvent::class)); + + $errorEvents = $this->dispatcher->getDispatchedEventsOfType(GenerateResultErrorEvent::class); + $this->assertCount(1, $errorEvents); + $this->assertSame('stream failed', $errorEvents[0]->getError()->getMessage()); } /** From 5f460ac6e04944a63f3b9a9931055aa17cc8e5f9 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 20:15:30 +0530 Subject: [PATCH 66/71] Update logic to determine the channel order --- src/Results/ChunkAccumulator.php | 33 ++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/Results/ChunkAccumulator.php b/src/Results/ChunkAccumulator.php index b0511ffb..d0b4fbbd 100644 --- a/src/Results/ChunkAccumulator.php +++ b/src/Results/ChunkAccumulator.php @@ -272,10 +272,7 @@ private function buildCandidate(int $index): Candidate { $parts = []; - foreach (self::CANONICAL_CHANNEL_ORDER as $channel) { - if (!isset($this->text[$index][$channel])) { - continue; - } + foreach ($this->orderedChannels($index) as $channel) { $parts[] = new MessagePart( $this->text[$index][$channel], MessagePartChannelEnum::from($channel), @@ -298,6 +295,34 @@ private function buildCandidate(int $index): Candidate return new Candidate($message, $finishReason); } + /** + * Returns the candidate's text channels in canonical order, with any unknown channel last. + * + * @since n.e.x.t + * + * @param int $index The candidate index. + * @return list The present channels, ordered. + */ + private function orderedChannels(int $index): array + { + $present = array_keys($this->text[$index] ?? []); + + $ordered = []; + foreach (self::CANONICAL_CHANNEL_ORDER as $channel) { + if (in_array($channel, $present, true)) { + $ordered[] = $channel; + } + } + + foreach ($present as $channel) { + if (!in_array($channel, self::CANONICAL_CHANNEL_ORDER, true)) { + $ordered[] = $channel; + } + } + + return $ordered; + } + /** * Assembles the stored tool call slots for a candidate into message parts. * From 2fa7159ae60c1b5d3b36ba0e9757a803c99a95b8 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 20:19:10 +0530 Subject: [PATCH 67/71] Update initial candidate index to 0 --- .../ValueObjects/GenerativeAiResultChunk.php | 28 +++++++++++-------- .../GenerativeAiResultChunkTest.php | 18 +++++++----- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/Results/ValueObjects/GenerativeAiResultChunk.php b/src/Results/ValueObjects/GenerativeAiResultChunk.php index a57f4c75..6ce6b820 100644 --- a/src/Results/ValueObjects/GenerativeAiResultChunk.php +++ b/src/Results/ValueObjects/GenerativeAiResultChunk.php @@ -106,37 +106,41 @@ public function getCandidateDeltas(): array } /** - * Gets the content text delta carried by this chunk. + * Gets the content text delta for a single candidate. * * @since n.e.x.t * - * @return string The content text delta, or an empty string when this chunk carries none. + * @param int $candidateIndex The candidate index to read. + * @return string The content text delta, or an empty string when the candidate carries none. */ - public function getDeltaText(): string + public function getDeltaText(int $candidateIndex = 0): string { - $text = ''; foreach ($this->candidateDeltas as $delta) { - $text .= $delta->getDeltaText(); + if ($delta->getIndex() === $candidateIndex) { + return $delta->getDeltaText(); + } } - return $text; + return ''; } /** - * Gets the reasoning (thought) text delta carried by this chunk. + * Gets the reasoning (thought) text delta for a single candidate. * * @since n.e.x.t * - * @return string The reasoning text delta, or an empty string when this chunk carries none. + * @param int $candidateIndex The candidate index to read. + * @return string The reasoning text delta, or an empty string when the candidate carries none. */ - public function getReasoningDeltaText(): string + public function getReasoningDeltaText(int $candidateIndex = 0): string { - $text = ''; foreach ($this->candidateDeltas as $delta) { - $text .= $delta->getReasoningDeltaText(); + if ($delta->getIndex() === $candidateIndex) { + return $delta->getReasoningDeltaText(); + } } - return $text; + return ''; } /** diff --git a/tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php b/tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php index f80d7301..9487acf4 100644 --- a/tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php +++ b/tests/unit/Results/ValueObjects/GenerativeAiResultChunkTest.php @@ -68,29 +68,33 @@ public function testDefaults(): void } /** - * Tests that getDeltaText concatenates the content text across candidate deltas. + * Tests that getDeltaText returns the primary candidate's content by default. */ - public function testGetDeltaTextFlattensAcrossCandidateDeltas(): void + public function testGetDeltaTextReturnsPrimaryCandidateByDefault(): void { $chunk = new GenerativeAiResultChunk(null, null, [], [ new CandidateDelta(0, [$this->createContentPart('A')]), new CandidateDelta(1, [$this->createContentPart('B')]), ]); - $this->assertSame('AB', $chunk->getDeltaText()); + $this->assertSame('A', $chunk->getDeltaText()); + $this->assertSame('B', $chunk->getDeltaText(1)); + $this->assertSame('', $chunk->getDeltaText(99)); } /** - * Tests that getReasoningDeltaText concatenates the thought text across candidate deltas. + * Tests that getReasoningDeltaText returns the primary candidate's reasoning by default. */ - public function testGetReasoningDeltaTextFlattensAcrossCandidateDeltas(): void + public function testGetReasoningDeltaTextReturnsPrimaryCandidateByDefault(): void { $chunk = new GenerativeAiResultChunk(null, null, [], [ - new CandidateDelta(0, [$this->createReasoningPart('think ')]), + new CandidateDelta(0, [$this->createReasoningPart('think')]), new CandidateDelta(1, [$this->createReasoningPart('more')]), ]); - $this->assertSame('think more', $chunk->getReasoningDeltaText()); + $this->assertSame('think', $chunk->getReasoningDeltaText()); + $this->assertSame('more', $chunk->getReasoningDeltaText(1)); + $this->assertSame('', $chunk->getReasoningDeltaText(99)); } /** From a3f098e8affdb87aaf8517993d051db9360992d2 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 20:24:24 +0530 Subject: [PATCH 68/71] Remove finalized state flag --- src/Results/StreamedGenerativeAiResult.php | 27 ++++++++++--------- .../StreamedGenerativeAiResultTest.php | 20 ++++++++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/Results/StreamedGenerativeAiResult.php b/src/Results/StreamedGenerativeAiResult.php index dcdcb307..03bbdc2a 100644 --- a/src/Results/StreamedGenerativeAiResult.php +++ b/src/Results/StreamedGenerativeAiResult.php @@ -56,14 +56,14 @@ final class StreamedGenerativeAiResult implements IteratorAggregate private bool $finished = false; /** - * @var bool Whether a terminal outcome (completion or error) has been reached. + * @var GenerativeAiResult|null The assembled result, once built. */ - private bool $finalized = false; + private ?GenerativeAiResult $result = null; /** - * @var GenerativeAiResult|null The assembled result, once built. + * @var Throwable|null The error that ended consumption, once it failed. */ - private ?GenerativeAiResult $result = null; + private ?Throwable $error = null; /** * Constructor. @@ -164,10 +164,16 @@ public function getIterator(): Generator * @since n.e.x.t * * @return GenerativeAiResult The assembled result. + * + * @throws Throwable The original stream error, if consumption failed. * @throws RuntimeException If the stream produced no candidates. */ public function getFinalResult(): GenerativeAiResult { + if ($this->error !== null) { + throw $this->error; + } + if ($this->result === null) { try { while ($this->pull() !== null) { @@ -199,11 +205,10 @@ public function getFinalResult(): GenerativeAiResult */ private function finalize(): void { - if ($this->finalized || !$this->accumulator->hasCandidates()) { + if (!$this->accumulator->hasCandidates()) { return; } - $this->finalized = true; $this->result = $this->accumulator->build(); foreach ($this->completionCallbacks as $callback) { @@ -212,7 +217,7 @@ private function finalize(): void } /** - * Reaches the terminal "errored" state exactly once and runs the error callbacks. + * Handles a failure in the stream, storing the error and running the error callbacks. * * @since n.e.x.t * @@ -221,11 +226,7 @@ private function finalize(): void */ private function fail(Throwable $error): void { - if ($this->finalized) { - return; - } - - $this->finalized = true; + $this->error = $error; foreach ($this->errorCallbacks as $callback) { $callback($error); @@ -241,7 +242,7 @@ private function fail(Throwable $error): void */ private function pull(): ?GenerativeAiResultChunk { - if ($this->finished || $this->finalized) { + if ($this->finished) { return null; } diff --git a/tests/unit/Results/StreamedGenerativeAiResultTest.php b/tests/unit/Results/StreamedGenerativeAiResultTest.php index 129bd512..e8d0ceb3 100644 --- a/tests/unit/Results/StreamedGenerativeAiResultTest.php +++ b/tests/unit/Results/StreamedGenerativeAiResultTest.php @@ -633,6 +633,26 @@ public function testOnErrorNotFiredOnEarlyBreak(): void $this->assertSame(0, $count); } + /** + * Tests that getFinalResult() after a failure re-throws the original stream error. + */ + public function testGetFinalResultAfterFailureRethrowsOriginalError(): void + { + $handle = $this->createHandle( + $this->createFailingIterator([$this->createContentChunk('a')], new RuntimeException('stream failed')) + ); + + try { + foreach ($handle as $chunk) { + } + } catch (RuntimeException $e) { + } + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('stream failed'); + $handle->getFinalResult(); + } + /** * Tests that the error callback fires at most once across iteration and getFinalResult(). */ From 77c730d5fa127f7f48ef64c4ef841c3e8fa45193 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 20:30:55 +0530 Subject: [PATCH 69/71] Add coverage ignore for forward compat code --- src/Results/ChunkAccumulator.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Results/ChunkAccumulator.php b/src/Results/ChunkAccumulator.php index d0b4fbbd..ed3937e8 100644 --- a/src/Results/ChunkAccumulator.php +++ b/src/Results/ChunkAccumulator.php @@ -316,7 +316,10 @@ private function orderedChannels(int $index): array foreach ($present as $channel) { if (!in_array($channel, self::CANONICAL_CHANNEL_ORDER, true)) { + // Add any unknown channel last, in arrival order in case the provider sends multiple unknown channels. + // @codeCoverageIgnoreStart $ordered[] = $channel; + // @codeCoverageIgnoreEnd } } From 4a345d631fc20abe1859c718b4a0a00395e43555 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 21:58:33 +0530 Subject: [PATCH 70/71] Add test case to assert default token usage values --- ...penAiCompatibleTextGenerationModelTest.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 36c87e46..7f3b4c27 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -174,6 +174,38 @@ public function testGenerateTextResultApiFailure(): void $model->generateTextResult($prompt); } + /** + * Tests that token usage defaults to zero when the response omits usage. + * + * @return void + */ + public function testGenerateTextResultDefaultsTokenUsageWhenUsageAbsent(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'chatcmpl-123', + 'choices' => [ + [ + 'message' => ['role' => 'assistant', 'content' => 'Hi there!'], + 'finish_reason' => 'stop', + ], + ], + ]) + ); + + $this->mockRequestAuthentication->method('authenticateRequest')->willReturnArgument(0); + $this->mockHttpTransporter->method('send')->willReturn($response); + + $result = $this->createModel()->generateTextResult($prompt); + + $this->assertSame(0, $result->getTokenUsage()->getPromptTokens()); + $this->assertSame(0, $result->getTokenUsage()->getCompletionTokens()); + $this->assertSame(0, $result->getTokenUsage()->getTotalTokens()); + } + /** * Tests prepareGenerateTextParams() with basic text prompt. * From 1bd7ca9156118454014164685074f719a241a3f0 Mon Sep 17 00:00:00 2001 From: thelovekesh Date: Wed, 24 Jun 2026 22:01:18 +0530 Subject: [PATCH 71/71] Add test case for streamed body serialization --- tests/unit/Providers/Http/DTO/ResponseTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/Providers/Http/DTO/ResponseTest.php b/tests/unit/Providers/Http/DTO/ResponseTest.php index a6115686..f7ff91f6 100644 --- a/tests/unit/Providers/Http/DTO/ResponseTest.php +++ b/tests/unit/Providers/Http/DTO/ResponseTest.php @@ -142,4 +142,19 @@ public function testSeekableStreamBodyIsRewoundBeforeRead(): void $this->assertSame('{"ok":true}', $response->getBody()); } + + /** + * Tests that toArray serializes the body, reading a streamed body when needed. + * + * @return void + */ + public function testToArraySerializesStreamedBody(): void + { + $response = new Response(200, ['X-Test' => 'value'], new ChunkStream(['streamed body'])); + + $array = $response->toArray(); + + $this->assertSame(200, $array[Response::KEY_STATUS_CODE]); + $this->assertSame('streamed body', $array[Response::KEY_BODY]); + } }