Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
87115db
Add stream to request options
thelovekesh Jun 18, 2026
71ff869
Add stream helpers to Response
thelovekesh Jun 18, 2026
8a21822
Add streaming support to http transport
thelovekesh Jun 18, 2026
f633f1b
Update phpcs config to lint 20 files in parallel
thelovekesh Jun 18, 2026
1d03f00
Add interface for sse parser
thelovekesh Jun 19, 2026
252be37
Add DTO for SSE
thelovekesh Jun 19, 2026
6d41c14
Add SSE spec compliant parser
thelovekesh Jun 19, 2026
2fa2740
Update stream id type to string
thelovekesh Jun 19, 2026
76e5f6c
Remove dispatching event if file end is reached
thelovekesh Jun 19, 2026
ff80a36
Add mock for chunking stream
thelovekesh Jun 19, 2026
842f447
Add test cases for SseEventStreamParser
thelovekesh Jun 19, 2026
d12e439
Fix aliases order
thelovekesh Jun 19, 2026
8811c05
Remove dead code after adding spec driven stream discard if it doesn'…
thelovekesh Jun 19, 2026
e581fec
Add test cases for unexpected body endings
thelovekesh Jun 19, 2026
8fbd253
Add link to PHP_SOCK_CHUNK_SIZE default size
thelovekesh Jun 19, 2026
8ac19d2
Update `@see` usage
thelovekesh Jun 19, 2026
fe6cbbb
Add test case for streams having no terminating end line
thelovekesh Jun 19, 2026
b0e4e04
Add interface for text generation models with streaming support
thelovekesh Jun 19, 2026
048bdd2
Add immutable class to hold generative ai result chunk
thelovekesh Jun 19, 2026
098cc8c
Add streamed result aggregator
thelovekesh Jun 19, 2026
6fb0de3
Add text streaming support in prompt builder
thelovekesh Jun 19, 2026
d1e339c
Add text stream result API on client
thelovekesh Jun 19, 2026
beefcee
Add streaming to openai compat model
thelovekesh Jun 19, 2026
988d830
Add stream-text support to cli
thelovekesh Jun 19, 2026
4151366
Add completion callback support
thelovekesh Jun 19, 2026
b9e990b
Add event dispatchers after streaming text results
thelovekesh Jun 19, 2026
a9e6ae9
Add value object to store tool call delta
thelovekesh Jun 23, 2026
47da82a
Update gen ai result chink value object to store tool call delta
thelovekesh Jun 23, 2026
54514e5
Add tool calls accumulation
thelovekesh Jun 23, 2026
f4d1f5b
Add support to parse function call for open ai compat provider
thelovekesh Jun 23, 2026
a71e90b
Update gen ai result chunk to store additional data
thelovekesh Jun 23, 2026
b023fa8
Add additional data support
thelovekesh Jun 23, 2026
cdf2b04
Add exception for stream responses
thelovekesh Jun 23, 2026
155aa46
Add stream error exception
thelovekesh Jun 23, 2026
f6080a9
Update gen ai result chunk responsibilities
thelovekesh Jun 24, 2026
321706c
Add value object to store candidate delta
thelovekesh Jun 24, 2026
4eb6c5c
Add streamed chunks accumulator
thelovekesh Jun 24, 2026
dbaf89b
Update gen ai result class to use chunk accumulator
thelovekesh Jun 24, 2026
78e8b74
Update candidate chinks handling
thelovekesh Jun 24, 2026
f585db2
Update ServerSentEvent class path
thelovekesh Jun 24, 2026
b5996be
Fix docblock
thelovekesh Jun 24, 2026
29bf3a9
Add exception if stream is being consumed again
thelovekesh Jun 24, 2026
5ac692b
Add mock to simulate stream failure
thelovekesh Jun 24, 2026
292bbc6
Add stream helpers in openai compat model mock
thelovekesh Jun 24, 2026
5bd1da7
Add test cases for ChunkAccumulator
thelovekesh Jun 24, 2026
284256c
Add test cases for StreamedGenerativeAiResult
thelovekesh Jun 24, 2026
2e257bc
Add test case for streaming in open ai compat text gen modal
thelovekesh Jun 24, 2026
2a92d88
Add streaming support in modal creation mock
thelovekesh Jun 24, 2026
3f5037d
Add test cases for event dispatch in streamed results
thelovekesh Jun 24, 2026
c9815f3
Add text stream in prompt builder
thelovekesh Jun 24, 2026
21288f3
Add streaming support test cases for http transporter
thelovekesh Jun 24, 2026
9293c92
Update code comments
thelovekesh Jun 24, 2026
8c61415
Add value objects test cases
thelovekesh Jun 24, 2026
1519f96
Add test cases for exception handlers
thelovekesh Jun 24, 2026
390191f
Add test for stream response
thelovekesh Jun 24, 2026
a1d0086
Add test for streaming text support in ai client
thelovekesh Jun 24, 2026
17ddb02
Add test case for stream option in request options
thelovekesh Jun 24, 2026
37e8f33
Add event to generate result error event
thelovekesh Jun 24, 2026
d1ec578
Update chunk accumulator to force channel order
thelovekesh Jun 24, 2026
6d59175
Fix docblock tag order
thelovekesh Jun 24, 2026
c0aa717
Add lifecycle events on gen ai result streams
thelovekesh Jun 24, 2026
9e55904
Add tets cases for stream lifecycle events
thelovekesh Jun 24, 2026
366c6a1
Add coercion for usage values
thelovekesh Jun 24, 2026
136fa7d
Update stream chunks data type
thelovekesh Jun 24, 2026
67803d5
Update event dispatch in prompt builder
thelovekesh Jun 24, 2026
0475fb6
Update logic to determine the channel order
thelovekesh Jun 24, 2026
a1b107f
Update initial candidate index to 0
thelovekesh Jun 24, 2026
03f934e
Remove finalized state flag
thelovekesh Jun 24, 2026
306b4c3
Add coverage ignore for forward compat code
thelovekesh Jun 24, 2026
c2b0771
Add test case to assert default token usage values
thelovekesh Jun 24, 2026
4e7197b
Add test case for streamed body serialization
thelovekesh Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
* Embedding output formats require both --providerId and --modelId, because embeddings are only
* comparable to other embeddings from the same model:
* OPENAI_API_KEY=123456 php cli.php 'Your text here' --providerId=openai \
Expand Down Expand Up @@ -229,6 +232,14 @@ static function ($item) {
try {
if ($isEmbedding) {
$result = $builder->generateEmbeddingResult();
} elseif ($outputFormat === 'stream-text') {
$stream = $builder->streamGenerateTextResult();
foreach ($stream as $chunk) {
echo $chunk->getDeltaText();
flush();
}
echo PHP_EOL;
$result = $stream->getFinalResult();
} elseif ($outputFormat === 'image-json' || $outputFormat === 'image-base64') {
$result = $builder->generateImageResult();
} else {
Expand All @@ -243,7 +254,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;
Expand All @@ -267,4 +282,6 @@ static function ($item) {
$output = $result->toText();
}

printOutput($output);
if (is_string($output)) {
printOutput($output);
}
3 changes: 3 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<!-- Use PSR-12 standard -->
<rule ref="PSR12"/>

<!-- Check up to 20 files simultaneously. -->
<arg name="parallel" value="20"/>

<!-- Check PHP 7.4 compatibility -->
<rule ref="PHPCompatibility">
<!-- Exclude functions that are polyfilled in src/polyfills.php -->
Expand Down
28 changes: 28 additions & 0 deletions src/AiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use WordPress\AiClient\Results\DTO\Embedding;
use WordPress\AiClient\Results\DTO\EmbeddingResult;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\StreamedGenerativeAiResult;

/**
* Main AI Client class providing both fluent and traditional APIs for AI operations.
Expand Down Expand Up @@ -322,6 +323,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.
*
Expand Down
62 changes: 62 additions & 0 deletions src/Builders/PromptBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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;
Expand All @@ -25,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;
Expand Down Expand Up @@ -861,6 +864,65 @@ 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.
Comment thread
justlevine marked this conversation as resolved.
*/
public function streamGenerateTextResult(): StreamedGenerativeAiResult
{
$this->includeOutputModalities(ModalityEnum::text());
$this->validateMessages();

$capability = CapabilityEnum::textGeneration();
$model = $this->getConfiguredModel($capability);

if (!$model instanceof StreamingTextGenerationModelInterface) {
throw new RuntimeException(
sprintf(
'Model "%s" does not support streaming text generation.',
$model->metadata()->getId()
)
);
}
Comment on lines +884 to +891

@thelovekesh thelovekesh Jun 24, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a model doesn't implement this interface, it will fail even if the model supports text generation. It fails because streaming is gated behind this opt-in interface that no provider actually implements. We can't discover our way around it either, since streaming isn't a CapabilityEnum, so discovery can't tell which models stream and may still pick one that doesn't.

The clean solution is to remove StreamingTextGenerationModelInterface and rather add streamGenerateTextResult(array $prompt): StreamedGenerativeAiResult to TextGenerationModelInterface. Streaming is a networking primitive, not a real model capability, so every text model can stream: natively where the provider's API supports it, or by emulating it (one generateTextResult() call yielded as a single chunk) where it doesn't. Adding a method to the interface sounds like a breaking change, but if we put the emulation default in the shared base (AbstractApiBasedModel) that every provider already extends, they all inherit it and nothing breaks.

Another way is to add a new capability and wire discovery to find a model with it, but that isn't standard given streaming isn't a model-level capability, it's a networking primitive, not something a model generates. We'd also still need a method on an interface plus every provider opting in, so it's more machinery for something that isn't really model-level.


$messages = $this->messages;

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));
});
}

/**
* Streams generated text from the prompt as it arrives.
*
* @since n.e.x.t
*
* @return iterable<string> 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.
*
Expand Down
122 changes: 122 additions & 0 deletions src/Events/GenerateResultErrorEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace WordPress\AiClient\Events;

use Throwable;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;

/**
* Class GenerateResultErrorEvent.
*
* @since n.e.x.t
*/
class GenerateResultErrorEvent
{
/**
* @var list<Message> 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<Message> $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<Message> 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;
}
}
Loading
Loading