From 5e077f0c477c0767987660f5a846918a0fccd8ae Mon Sep 17 00:00:00 2001 From: MrCrayon Date: Mon, 2 Jun 2025 07:27:50 +0200 Subject: [PATCH 1/2] Add prism events - Add Trace class that uses Laravel Context to keep track of operations stack - Add events - Add middlewares to trace http request and response. - Streams are traced with a `StreamWrapper` to keep stream intact. - Tracing of all not nested Prism operations is done in shared `PendingRequest` classes. - Tracing of tool calls is done in `CallsTools` if called or where tools are called. - Removed "'Maximum tool call" Exception in Anthropic Stream Handler and implemented `shouldContinue` check that does not raise an exception. - Added `$step` property in Anthropic Stream Handler so that we are not passing `$depth` everywhere. - Remove `isToolUseFinish` in `handleMessageStop`for ANthropic Stream Handler - Added `Content-Type` headers for Gemini and OpenAI stream tests, `Content-type` is used in client middleware to recognize if it's a streamed response or normal. --- src/Concerns/CallsTools.php | 9 + src/Concerns/InitializesClient.php | 52 ++++- src/Embeddings/PendingRequest.php | 13 +- src/Events/HttpRequestCompleted.php | 21 ++ src/Events/HttpRequestStarted.php | 21 ++ src/Events/PrismRequestCompleted.php | 19 ++ src/Events/PrismRequestStarted.php | 18 ++ src/Events/ToolCallCompleted.php | 18 ++ src/Events/ToolCallStarted.php | 18 ++ src/Events/TraceEvent.php | 25 +++ src/Http/Stream/StreamWrapper.php | 114 +++++++++++ src/Images/PendingRequest.php | 13 +- src/Providers/Anthropic/Handlers/Stream.php | 56 ++++-- src/Providers/Anthropic/Handlers/Text.php | 10 + src/Structured/PendingRequest.php | 15 +- src/Support/Trace.php | 79 ++++++++ src/Text/PendingRequest.php | 32 ++- .../Providers/Anthropic/AnthropicTextTest.php | 186 ++++++++++++++++++ tests/Providers/Anthropic/StreamTest.php | 67 +++++++ tests/Providers/Gemini/GeminiStreamTest.php | 6 +- tests/Providers/OpenAI/StreamTest.php | 14 +- 21 files changed, 770 insertions(+), 36 deletions(-) create mode 100644 src/Events/HttpRequestCompleted.php create mode 100644 src/Events/HttpRequestStarted.php create mode 100644 src/Events/PrismRequestCompleted.php create mode 100644 src/Events/PrismRequestStarted.php create mode 100644 src/Events/ToolCallCompleted.php create mode 100644 src/Events/ToolCallStarted.php create mode 100644 src/Events/TraceEvent.php create mode 100644 src/Http/Stream/StreamWrapper.php create mode 100644 src/Support/Trace.php diff --git a/src/Concerns/CallsTools.php b/src/Concerns/CallsTools.php index 92a79baa5..2e03a9c18 100644 --- a/src/Concerns/CallsTools.php +++ b/src/Concerns/CallsTools.php @@ -6,7 +6,10 @@ use Illuminate\Support\ItemNotFoundException; use Illuminate\Support\MultipleItemsFoundException; +use Prism\Prism\Events\ToolCallCompleted; +use Prism\Prism\Events\ToolCallStarted; use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Support\Trace; use Prism\Prism\Tool; use Prism\Prism\ValueObjects\ToolCall; use Prism\Prism\ValueObjects\ToolResult; @@ -25,12 +28,16 @@ protected function callTools(array $tools, array $toolCalls): array function (ToolCall $toolCall) use ($tools): ToolResult { $tool = $this->resolveTool($toolCall->name, $tools); + Trace::begin('tool_call', fn () => event(new ToolCallStarted($toolCall->name, $toolCall->arguments()))); + try { $result = call_user_func_array( $tool->handle(...), $toolCall->arguments() ); + Trace::end(fn () => event(new ToolCallCompleted(attributes: ['result' => $result]))); + return new ToolResult( toolCallId: $toolCall->id, toolCallResultId: $toolCall->resultId, @@ -39,6 +46,8 @@ function (ToolCall $toolCall) use ($tools): ToolResult { result: $result, ); } catch (Throwable $e) { + Trace::end(fn () => event(new ToolCallCompleted(exception: $e))); + if ($e instanceof PrismException) { throw $e; } diff --git a/src/Concerns/InitializesClient.php b/src/Concerns/InitializesClient.php index 3ab45fa3a..ffbe255ad 100644 --- a/src/Concerns/InitializesClient.php +++ b/src/Concerns/InitializesClient.php @@ -5,7 +5,13 @@ namespace Prism\Prism\Concerns; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; +use Prism\Prism\Events\HttpRequestCompleted; +use Prism\Prism\Events\HttpRequestStarted; +use Prism\Prism\Http\Stream\StreamWrapper; +use Prism\Prism\Support\Trace; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -13,8 +19,48 @@ trait InitializesClient { protected function baseClient(): PendingRequest { - return Http::withRequestMiddleware(fn (RequestInterface $request): RequestInterface => $request) - ->withResponseMiddleware(fn (ResponseInterface $response): ResponseInterface => $response) - ->throw(); + return Http::throw() + ->withRequestMiddleware( + function (RequestInterface $request): RequestInterface { + Trace::begin( + 'http', + fn () => event(new HttpRequestStarted( + method: $request->getMethod(), + url: (string) $request->getUri(), + headers: Arr::mapWithKeys( + $request->getHeaders(), + fn ($value, $key) => [ + $key => in_array($key, ['Authentication', 'x-api-key', 'x-goog-api-key']) + ? Str::mask($value[0], '*', 3) + : $value, + ] + ), + attributes: json_decode((string) $request->getBody(), true), + )), + ); + + return $request; + } + ) + ->withResponseMiddleware( + function (ResponseInterface $response): ResponseInterface { + if (! str_contains($response->getHeaderLine('Content-Type'), 'text/event-stream')) { + Trace::end( + fn () => event(new HttpRequestCompleted( + statusCode: $response->getStatusCode(), + headers: $response->getHeaders(), + attributes: json_decode((string) $response->getBody(), true) ?? [], + )) + ); + + return $response; + } + + // Wrap the stream to enable logging preserving the stream + $loggingStream = new StreamWrapper($response); + + return $response->withBody($loggingStream); + } + ); } } diff --git a/src/Embeddings/PendingRequest.php b/src/Embeddings/PendingRequest.php index 38716f4ac..3046f46de 100644 --- a/src/Embeddings/PendingRequest.php +++ b/src/Embeddings/PendingRequest.php @@ -8,7 +8,10 @@ use Prism\Prism\Concerns\ConfiguresClient; use Prism\Prism\Concerns\ConfiguresProviders; use Prism\Prism\Concerns\HasProviderOptions; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Support\Trace; class PendingRequest { @@ -69,9 +72,17 @@ public function asEmbeddings(): Response $request = $this->toRequest(); + Trace::begin('embeddings', fn () => event(new PrismRequestStarted($this->providerKey(), ['request' => $request]))); + try { - return $this->provider->embeddings($request); + $response = $this->provider->embeddings($request); + + Trace::end(fn () => event(new PrismRequestCompleted($this->providerKey(), ['response' => $response]))); + + return $response; } catch (RequestException $e) { + Trace::end(fn () => event(new PrismRequestCompleted(exception: $e))); + $this->provider->handleRequestException($request->model(), $e); } } diff --git a/src/Events/HttpRequestCompleted.php b/src/Events/HttpRequestCompleted.php new file mode 100644 index 000000000..e37a93ecd --- /dev/null +++ b/src/Events/HttpRequestCompleted.php @@ -0,0 +1,21 @@ + $attributes + * @param string[][] $headers + */ + public function __construct( + public readonly int $statusCode, + public readonly array $headers, + public readonly array $attributes, + public readonly ?\Throwable $exception = null + ) { + parent::__construct(); + } +} diff --git a/src/Events/HttpRequestStarted.php b/src/Events/HttpRequestStarted.php new file mode 100644 index 000000000..e630b8d20 --- /dev/null +++ b/src/Events/HttpRequestStarted.php @@ -0,0 +1,21 @@ + $attributes + * @param string[][] $headers + */ + public function __construct( + public readonly string $method, + public readonly string $url, + public readonly array $headers, + public readonly array $attributes, + ) { + parent::__construct(); + } +} diff --git a/src/Events/PrismRequestCompleted.php b/src/Events/PrismRequestCompleted.php new file mode 100644 index 000000000..476cb7504 --- /dev/null +++ b/src/Events/PrismRequestCompleted.php @@ -0,0 +1,19 @@ + $attributes + */ + public function __construct( + public readonly string $provider = '', + public readonly array $attributes = [], + public readonly ?\Throwable $exception = null + ) { + parent::__construct(); + } +} diff --git a/src/Events/PrismRequestStarted.php b/src/Events/PrismRequestStarted.php new file mode 100644 index 000000000..32b903474 --- /dev/null +++ b/src/Events/PrismRequestStarted.php @@ -0,0 +1,18 @@ + $attributes + */ + public function __construct( + public readonly string $provider, + public readonly array $attributes + ) { + parent::__construct(); + } +} diff --git a/src/Events/ToolCallCompleted.php b/src/Events/ToolCallCompleted.php new file mode 100644 index 000000000..fa2db0ad5 --- /dev/null +++ b/src/Events/ToolCallCompleted.php @@ -0,0 +1,18 @@ + $attributes + */ + public function __construct( + public readonly array $attributes = [], + public readonly ?\Throwable $exception = null + ) { + parent::__construct(); + } +} diff --git a/src/Events/ToolCallStarted.php b/src/Events/ToolCallStarted.php new file mode 100644 index 000000000..8e9bbbd50 --- /dev/null +++ b/src/Events/ToolCallStarted.php @@ -0,0 +1,18 @@ + $attributes + */ + public function __construct( + public readonly string $toolName, + public readonly array $attributes + ) { + parent::__construct(); + } +} diff --git a/src/Events/TraceEvent.php b/src/Events/TraceEvent.php new file mode 100644 index 000000000..81e543a01 --- /dev/null +++ b/src/Events/TraceEvent.php @@ -0,0 +1,25 @@ +trace = Trace::get(); + } +} diff --git a/src/Http/Stream/StreamWrapper.php b/src/Http/Stream/StreamWrapper.php new file mode 100644 index 000000000..11cc44dcc --- /dev/null +++ b/src/Http/Stream/StreamWrapper.php @@ -0,0 +1,114 @@ +stream = $response->getBody(); + } + + public function __toString(): string + { + return $this->stream->__toString(); + } + + public function read($length): string + { + $chunk = $this->stream->read($length); + + if ($chunk !== '') { + $this->loggedChunks .= $chunk; + } + + if ($this->stream->eof()) { + Trace::end( + fn () => event(new HttpRequestCompleted( + statusCode: $this->response->getStatusCode(), + headers: $this->response->getHeaders(), + attributes: ['chunks' => $this->loggedChunks], + )) + ); + } + + return $chunk; + } + + // Delegate all other methods to the original stream + public function getContents(): string + { + return $this->stream->getContents(); + } + + public function close(): void + { + $this->stream->close(); + } + + public function detach() + { + return $this->stream->detach(); + } + + public function getSize(): ?int + { + return $this->stream->getSize(); + } + + public function isReadable(): bool + { + return $this->stream->isReadable(); + } + + public function isWritable(): bool + { + return $this->stream->isWritable(); + } + + public function isSeekable(): bool + { + return $this->stream->isSeekable(); + } + + public function eof(): bool + { + return $this->stream->eof(); + } + + public function tell(): int + { + return $this->stream->tell(); + } + + public function rewind(): void + { + $this->stream->rewind(); + } + + public function seek($offset, $whence = SEEK_SET): void + { + $this->stream->seek($offset, $whence); + } + + public function write($string): int + { + return $this->stream->write($string); + } + + public function getMetadata($key = null): mixed + { + return $this->stream->getMetadata(); + } +} diff --git a/src/Images/PendingRequest.php b/src/Images/PendingRequest.php index 8581dca5c..c63efca07 100644 --- a/src/Images/PendingRequest.php +++ b/src/Images/PendingRequest.php @@ -10,6 +10,9 @@ use Prism\Prism\Concerns\ConfiguresModels; use Prism\Prism\Concerns\ConfiguresProviders; use Prism\Prism\Concerns\HasProviderOptions; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; +use Prism\Prism\Support\Trace; class PendingRequest { @@ -31,9 +34,17 @@ public function generate(): Response { $request = $this->toRequest(); + Trace::begin('images', fn () => event(new PrismRequestStarted($this->providerKey(), ['request' => $request]))); + try { - return $this->provider->images($this->toRequest()); + $response = $this->provider->images($request); + + Trace::end(fn () => event(new PrismRequestCompleted($this->providerKey(), ['response' => $response]))); + + return $response; } catch (RequestException $e) { + Trace::end(fn () => event(new PrismRequestCompleted(exception: $e))); + $this->provider->handleRequestException($request->model(), $e); } } diff --git a/src/Providers/Anthropic/Handlers/Stream.php b/src/Providers/Anthropic/Handlers/Stream.php index 1943ab3c6..17b85778e 100644 --- a/src/Providers/Anthropic/Handlers/Stream.php +++ b/src/Providers/Anthropic/Handlers/Stream.php @@ -11,6 +11,11 @@ use InvalidArgumentException; use Prism\Prism\Concerns\CallsTools; use Prism\Prism\Enums\ChunkType; +use Prism\Prism\Enums\Provider; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; +use Prism\Prism\Events\ToolCallCompleted; +use Prism\Prism\Events\ToolCallStarted; use Prism\Prism\Exceptions\PrismChunkDecodeException; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Exceptions\PrismProviderOverloadedException; @@ -19,6 +24,7 @@ use Prism\Prism\Providers\Anthropic\Maps\FinishReasonMap; use Prism\Prism\Providers\Anthropic\ValueObjects\MessagePartWithCitations; use Prism\Prism\Providers\Anthropic\ValueObjects\StreamState; +use Prism\Prism\Support\Trace; use Prism\Prism\Text\Chunk; use Prism\Prism\Text\Request; use Prism\Prism\ValueObjects\Messages\AssistantMessage; @@ -36,6 +42,8 @@ class Stream protected StreamState $state; + protected int $step = 0; + public function __construct(protected PendingRequest $client) { $this->state = new StreamState; @@ -61,20 +69,24 @@ public function handle(Request $request): Generator * @throws PrismChunkDecodeException * @throws PrismException */ - protected function processStream(Response $response, Request $request, int $depth = 0): Generator + protected function processStream(Response $response, Request $request): Generator { $this->state->reset(); - yield from $this->processStreamChunks($response, $request, $depth); + foreach ($this->processStreamChunks($response, $request) as $chunk) { + yield $chunk; + } if ($this->state->hasToolCalls()) { - yield from $this->handleToolCalls($request, $this->mapToolCalls(), $depth, $this->state->buildAdditionalContent()); + Trace::end(fn () => event(new PrismRequestCompleted(Provider::Anthropic->value, ['chunk' => $chunk ?? '']))); + + yield from $this->handleToolCalls($request, $this->mapToolCalls(), $this->state->buildAdditionalContent()); } } - protected function shouldContinue(Request $request, int $depth): bool + protected function shouldContinue(Request $request): bool { - return $depth < $request->maxSteps(); + return $this->step < $request->maxSteps(); } /** @@ -83,7 +95,7 @@ protected function shouldContinue(Request $request, int $depth): bool * @throws PrismChunkDecodeException * @throws PrismException */ - protected function processStreamChunks(Response $response, Request $request, int $depth): Generator + protected function processStreamChunks(Response $response, Request $request): Generator { while (! $response->getBody()->eof()) { $chunk = $this->parseNextChunk($response->getBody()); @@ -92,7 +104,7 @@ protected function processStreamChunks(Response $response, Request $request, int continue; } - $outcome = $this->processChunk($chunk, $response, $request, $depth); + $outcome = $this->processChunk($chunk, $response, $request); if ($outcome instanceof Generator) { yield from $outcome; @@ -111,15 +123,15 @@ protected function processStreamChunks(Response $response, Request $request, int * @throws PrismException * @throws PrismRateLimitedException */ - protected function processChunk(array $chunk, Response $response, Request $request, int $depth): Generator|Chunk|null + protected function processChunk(array $chunk, Response $response, Request $request): Generator|Chunk|null { return match ($chunk['type'] ?? null) { 'message_start' => $this->handleMessageStart($response, $chunk), 'content_block_start' => $this->handleContentBlockStart($chunk), 'content_block_delta' => $this->handleContentBlockDelta($chunk), 'content_block_stop' => $this->handleContentBlockStop(), - 'message_delta' => $this->handleMessageDelta($chunk, $request, $depth), - 'message_stop' => $this->handleMessageStop($response, $request, $depth), + 'message_delta' => $this->handleMessageDelta($chunk, $request), + 'message_stop' => $this->handleMessageStop($response, $request), 'error' => $this->handleError($chunk), default => null, }; @@ -348,7 +360,7 @@ protected function handleContentBlockStop(): ?Chunk /** * @param array $chunk */ - protected function handleMessageDelta(array $chunk, Request $request, int $depth): ?Generator + protected function handleMessageDelta(array $chunk, Request $request): ?Generator { $stopReason = data_get($chunk, 'delta.stop_reason', ''); @@ -363,7 +375,7 @@ protected function handleMessageDelta(array $chunk, Request $request, int $depth } if ($this->state->isToolUseFinish()) { - return $this->handleToolUseFinish($request, $depth); + return $this->handleToolUseFinish($request); } return null; @@ -374,7 +386,7 @@ protected function handleMessageDelta(array $chunk, Request $request, int $depth * @throws PrismException * @throws PrismRateLimitedException */ - protected function handleMessageStop(Response $response, Request $request, int $depth): Generator|Chunk + protected function handleMessageStop(Response $response, Request $request): Chunk { $usage = $this->state->usage(); @@ -405,7 +417,7 @@ protected function handleMessageStop(Response $response, Request $request, int $ * @throws PrismException * @throws PrismRateLimitedException */ - protected function handleToolUseFinish(Request $request, int $depth): Generator + protected function handleToolUseFinish(Request $request): Generator { $mappedToolCalls = $this->mapToolCalls(); $additionalContent = $this->state->buildAdditionalContent(); @@ -557,13 +569,15 @@ protected function parseJsonData(string $jsonDataLine, ?string $eventType = null * @throws PrismException * @throws PrismRateLimitedException */ - protected function handleToolCalls(Request $request, array $toolCalls, int $depth, ?array $additionalContent = null): Generator + protected function handleToolCalls(Request $request, array $toolCalls, ?array $additionalContent = null): Generator { $toolResults = []; foreach ($toolCalls as $toolCall) { $tool = $this->resolveTool($toolCall->name, $request->tools()); + Trace::begin('tool_call', fn () => event(new ToolCallStarted($toolCall->name, $toolCall->arguments()))); + try { $result = call_user_func_array( $tool->handle(...), @@ -577,6 +591,8 @@ protected function handleToolCalls(Request $request, array $toolCalls, int $dept result: $result, ); + Trace::end(fn () => event(new ToolCallCompleted(['result' => $toolResult]))); + $toolResults[] = $toolResult; yield new Chunk( @@ -585,6 +601,8 @@ protected function handleToolCalls(Request $request, array $toolCalls, int $dept chunkType: ChunkType::ToolResult ); } catch (Throwable $e) { + Trace::end(fn () => event(new ToolCallCompleted(exception: $e))); + if ($e instanceof PrismException) { throw $e; } @@ -595,11 +613,13 @@ protected function handleToolCalls(Request $request, array $toolCalls, int $dept $this->addMessagesToRequest($request, $toolResults, $additionalContent); - $depth++; + $this->step++; + + if ($this->shouldContinue($request)) { + Trace::begin('stream', fn () => event(new PrismRequestStarted(Provider::Anthropic->value, ['request' => $request]))); - if ($this->shouldContinue($request, $depth)) { $nextResponse = $this->sendRequest($request); - yield from $this->processStream($nextResponse, $request, $depth); + yield from $this->processStream($nextResponse, $request); } } diff --git a/src/Providers/Anthropic/Handlers/Text.php b/src/Providers/Anthropic/Handlers/Text.php index cd61a2981..2dbf3d290 100644 --- a/src/Providers/Anthropic/Handlers/Text.php +++ b/src/Providers/Anthropic/Handlers/Text.php @@ -10,6 +10,9 @@ use Prism\Prism\Concerns\CallsTools; use Prism\Prism\Contracts\PrismRequest; use Prism\Prism\Enums\FinishReason; +use Prism\Prism\Enums\Provider; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Providers\Anthropic\Concerns\ExtractsCitations; use Prism\Prism\Providers\Anthropic\Concerns\ExtractsText; @@ -20,6 +23,7 @@ use Prism\Prism\Providers\Anthropic\Maps\MessageMap; use Prism\Prism\Providers\Anthropic\Maps\ToolChoiceMap; use Prism\Prism\Providers\Anthropic\Maps\ToolMap; +use Prism\Prism\Support\Trace; use Prism\Prism\Text\Request as TextRequest; use Prism\Prism\Text\Response; use Prism\Prism\Text\ResponseBuilder; @@ -101,6 +105,8 @@ public static function buildHttpRequestPayload(PrismRequest $request): array protected function handleToolCalls(): Response { + Trace::end(fn () => event(new PrismRequestCompleted(Provider::Anthropic->value, ['response' => $this->tempResponse]))); + $toolResults = $this->callTools($this->request->tools(), $this->tempResponse->toolCalls); $message = new ToolResultMessage($toolResults); @@ -114,6 +120,10 @@ protected function handleToolCalls(): Response $this->addStep($toolResults); if ($this->responseBuilder->steps->count() < $this->request->maxSteps()) { + $request = static::buildHttpRequestPayload($this->request); + + Trace::begin('text', fn () => event(new PrismRequestStarted(Provider::Anthropic->value, ['request' => $request]))); + return $this->handle(); } diff --git a/src/Structured/PendingRequest.php b/src/Structured/PendingRequest.php index ed9c593df..f7cdd6950 100644 --- a/src/Structured/PendingRequest.php +++ b/src/Structured/PendingRequest.php @@ -13,7 +13,10 @@ use Prism\Prism\Concerns\HasPrompts; use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasSchema; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Support\Trace; use Prism\Prism\ValueObjects\Messages\UserMessage; class PendingRequest @@ -39,11 +42,19 @@ public function asStructured(): Response { $request = $this->toRequest(); + Trace::begin('structured', fn () => event(new PrismRequestStarted($this->providerKey(), ['request' => $request]))); + try { - return $this->provider->structured($request); + $response = $this->provider->structured($request); + + Trace::end(fn () => event(new PrismRequestCompleted($this->providerKey(), ['response' => $response]))); } catch (RequestException $e) { - $this->provider->handleRequestException($request->model(), $e); + Trace::end(fn () => event(new PrismRequestCompleted(exception: $e))); + + throw $e; } + + return $response; } public function toRequest(): Request diff --git a/src/Support/Trace.php b/src/Support/Trace.php new file mode 100644 index 000000000..6a09f22df --- /dev/null +++ b/src/Support/Trace.php @@ -0,0 +1,79 @@ + Str::uuid()->toString(), + 'parentTraceId' => self::getParentId(), + 'traceName' => $operation, + 'startTime' => microtime(true), + 'endTime' => null, + ]; + + Context::pushHidden(self::$stackName, $data); + + if ($callback) { + $callback(); + } + + return $data; + } + + public static function end(?callable $callback = null): void + { + $current = Context::popHidden(self::$stackName); + + $current['endTime'] = microtime(true); + + Context::pushHidden(self::$stackName, $current); + + if ($callback) { + $callback(); + } + + Context::popHidden(self::$stackName); + } + + /** + * @return array{traceId: string, parentTraceId: string|null, traceName: string, startTime: float, endTime: float|null}|null + */ + public static function get(): ?array + { + $traces = Context::getHidden(self::$stackName); + + if (is_array($traces) && $traces !== []) { + return end($traces); + } + + return null; + } + + /** + * @param array{traceId: string, parentTraceId: string|null, traceName: string, startTime: float, endTime: float|null} $trace + */ + public static function isSameType(array $trace): bool + { + $current = self::get(); + + return $trace['traceName'] === ($current ? $current['traceName'] : null); + } + + protected static function getParentId(): ?string + { + return self::get()['traceId'] ?? null; + } +} diff --git a/src/Text/PendingRequest.php b/src/Text/PendingRequest.php index d08ac6b50..8a8062c87 100644 --- a/src/Text/PendingRequest.php +++ b/src/Text/PendingRequest.php @@ -16,7 +16,10 @@ use Prism\Prism\Concerns\HasProviderOptions; use Prism\Prism\Concerns\HasProviderTools; use Prism\Prism\Concerns\HasTools; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; use Prism\Prism\Exceptions\PrismException; +use Prism\Prism\Support\Trace; use Prism\Prism\ValueObjects\Messages\UserMessage; class PendingRequest @@ -44,9 +47,22 @@ public function asText(): Response { $request = $this->toRequest(); + $trace = Trace::begin('text', fn () => event(new PrismRequestStarted($this->providerKey(), ['request' => $request]))); + try { - return $this->provider->text($request); + $response = $this->provider->text($request); + + // Trace might already be ended if tool is called, end only if same type + if (Trace::isSameType($trace)) { + Trace::end(fn () => event(new PrismRequestCompleted($this->providerKey(), ['response' => $response]))); + } + + return $response; } catch (RequestException $e) { + if (Trace::isSameType($trace)) { + Trace::end(callback: fn () => event(new PrismRequestCompleted(exception: $e))); + } + $this->provider->handleRequestException($request->model(), $e); } } @@ -58,13 +74,27 @@ public function asStream(): Generator { $request = $this->toRequest(); + $trace = Trace::begin('stream', fn () => event(new PrismRequestStarted($this->providerKey(), ['request' => $request]))); + try { $chunks = $this->provider->stream($request); + $result = []; + foreach ($chunks as $chunk) { + $result[] = $chunk; + yield $chunk; } + + if (Trace::isSameType($trace)) { + Trace::end(fn () => event(new PrismRequestCompleted($this->providerKey(), ['chunks' => $result]))); + } } catch (RequestException $e) { + if (Trace::isSameType($trace)) { + Trace::end(callback: fn () => event(new PrismRequestCompleted(exception: $e))); + } + $this->provider->handleRequestException($request->model(), $e); } } diff --git a/tests/Providers/Anthropic/AnthropicTextTest.php b/tests/Providers/Anthropic/AnthropicTextTest.php index 1f2d1b550..1e2efb827 100644 --- a/tests/Providers/Anthropic/AnthropicTextTest.php +++ b/tests/Providers/Anthropic/AnthropicTextTest.php @@ -5,8 +5,15 @@ namespace Tests\Providers\Anthropic; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\Provider; +use Prism\Prism\Events\HttpRequestCompleted; +use Prism\Prism\Events\HttpRequestStarted; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; +use Prism\Prism\Events\ToolCallCompleted; +use Prism\Prism\Events\ToolCallStarted; use Prism\Prism\Exceptions\PrismProviderOverloadedException; use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Exceptions\PrismRequestTooLargeException; @@ -43,6 +50,61 @@ ); }); +it('dispatch events while generating text with a prompt', function (): void { + Event::fake(); + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-a-prompt'); + + $provider = 'anthropic'; + $model = 'claude-3-5-sonnet-20240620'; + $prompt = 'Who are you?'; + + $response = Prism::text() + ->using($provider, $model) + ->withPrompt($prompt) + ->asText(); + + Event::assertDispatchedTimes(PrismRequestStarted::class, 1); + Event::assertDispatched(function (PrismRequestStarted $event) use ($provider, $model, $prompt): true { + $attributes = $event->attributes; + + expect($event->provider)->toBe($provider); + expect($attributes['request']->model())->toBe($model); + expect($attributes['request']->prompt())->toBe($prompt); + + return true; + }); + + Event::assertDispatchedTimes(HttpRequestStarted::class, 1); + Event::assertDispatched(function (HttpRequestStarted $event) use ($model): true { + $attributes = $event->attributes; + + expect($attributes['model'])->toBe($model); + + return true; + }); + + Event::assertDispatchedTimes(HttpRequestCompleted::class, 1); + Event::assertDispatched(function (HttpRequestCompleted $event) use ($response): true { + $attributes = $event->attributes; + + expect($attributes['id'])->toBe($response->meta->id); + expect($attributes['stop_reason'])->toBe('end_turn'); + expect($attributes['usage']['input_tokens'])->toBe($response->usage->promptTokens); + expect($attributes['usage']['output_tokens'])->toBe($response->usage->completionTokens); + + return true; + }); + + Event::assertDispatchedTimes(PrismRequestCompleted::class, 1); + Event::assertDispatched(function (PrismRequestCompleted $event) use ($response): true { + $attributes = $event->attributes; + + expect($attributes['response'])->toBe($response); + + return true; + }); +}); + it('can generate text with a system prompt', function (): void { FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-system-prompt'); @@ -113,6 +175,130 @@ expect($response->text)->toContain("you likely won't need a coat"); }); + it('dispatch events while generating text using multiple tools and multiple steps', function (): void { + FixtureResponse::fakeResponseSequence('v1/messages', 'anthropic/generate-text-with-multiple-tools'); + + $provider = 'anthropic'; + $model = 'claude-3-5-sonnet-20240620'; + $prompt = 'What time is the tigers game today and should I wear a coat?'; + + $capturedEvents = []; + $traceId = ''; + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'the city you want the weather for') + ->using(fn (string $city): string => 'The weather will be 75° and sunny'), + Tool::as('search') + ->for('useful for searching curret events or data') + ->withStringParameter('query', 'The detailed search query') + ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'), + ]; + + Event::listen('*', function ($eventName, $event) use (&$capturedEvents, &$traceId, $provider, $tools): void { + $capturedEvents[$eventName][] = $eventName; + + switch (count($capturedEvents[$eventName])) { + // First step + case 1: + switch ($eventName) { + case PrismRequestStarted::class: + expect($event[0]->provider)->toBe($provider); + expect($event[0]->attributes['request']->messages())->toHaveCount(1); + expect($event[0]->attributes['request']->tools())->toHaveCount(2); + expect($event[0]->attributes['request']->tools())->toBe($tools); + $traceId = $event[0]->trace['traceId']; + break; + case PrismRequestCompleted::class: + $response = $event[0]->attributes['response']; + expect($response->text)->toBe("To answer your questions, I'll need to search for information about the Tigers game and check the weather. Let me do that for you."); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('search'); + expect($response->toolCalls[0]->arguments())->toBe([ + 'query' => 'Detroit Tigers baseball game time today', + ]); + expect($event[0]->trace['traceId'])->toBe($traceId); + break; + case ToolCallStarted::class: + expect($event[0]->toolName)->toBe('search'); + expect($event[0]->attributes['query'])->toBe('Detroit Tigers baseball game time today'); + $traceId = $event[0]->trace['traceId']; + break; + case ToolCallCompleted::class: + expect($event[0]->attributes['result'])->toBe('The tigers game is at 3pm in detroit'); + expect($event[0]->trace['traceId'])->toBe($traceId); + break; + } + break; + // Second step + case 2: + switch ($eventName) { + case PrismRequestStarted::class: + $request = $event[0]->attributes['request']; + expect($request['messages'])->toHaveCount(3); + expect($request['messages'][2]['content'][0]['type'])->toBe('tool_result'); + break; + case PrismRequestCompleted::class: + $response = $event[0]->attributes['response']; + expect($response->text)->toBe("Thank you for that information. Now, let's check the weather in Detroit to determine if you should wear a coat."); + expect($response->toolCalls)->toHaveCount(1); + expect($response->toolCalls[0]->name)->toBe('weather'); + expect($response->toolCalls[0]->arguments())->toBe([ + 'city' => 'Detroit', + ]); + break; + case ToolCallStarted::class: + expect($event[0]->toolName)->toBe('weather'); + expect($event[0]->attributes['city'])->toBe('Detroit'); + break; + case ToolCallCompleted::class: + expect($event[0]->attributes['result'])->toBe('The weather will be 75° and sunny'); + break; + } + break; + // Third step + case 3: + switch ($eventName) { + case PrismRequestStarted::class: + $request = $event[0]->attributes['request']; + expect($request['messages'])->toHaveCount(5); + expect($request['messages'][2]['content'][0]['type'])->toBe('tool_result'); + expect($request['messages'][2]['content'][0]['content'])->toBe('The tigers game is at 3pm in detroit'); + expect($request['messages'][4]['content'][0]['type'])->toBe('tool_result'); + expect($request['messages'][4]['content'][0]['content'])->toBe('The weather will be 75° and sunny'); + $traceId = $event[0]->trace['traceId']; + break; + case HttpRequestStarted::class: + case HttpRequestCompleted::class: + expect($event[0]->trace['parentTraceId'])->toBe($traceId); + break; + case PrismRequestCompleted::class: + $response = $event[0]->attributes['response']; + expect($response->text)->toContain('The Tigers game is scheduled for 3:00 PM today in Detroit'); + expect($response->text)->toContain('it will be 75°F (about 24°C) and sunny'); + expect($response->text)->toContain("you likely won't need a coat"); + break; + } + break; + } + }); + + Prism::text() + ->using($provider, $model) + ->withTools($tools) + ->withMaxSteps(3) + ->withPrompt($prompt) + ->asText(); + + expect($capturedEvents[PrismRequestStarted::class])->toHaveCount(3); + expect($capturedEvents[PrismRequestCompleted::class])->toHaveCount(3); + expect($capturedEvents[HttpRequestStarted::class])->toHaveCount(3); + expect($capturedEvents[HttpRequestCompleted::class])->toHaveCount(3); + expect($capturedEvents[ToolCallStarted::class])->toHaveCount(2); + expect($capturedEvents[ToolCallCompleted::class])->toHaveCount(2); + }); + it('it handles a provider tool', function (): void { config()->set('prism.providers.anthropic.anthropic_beta', 'code-execution-2025-05-22'); diff --git a/tests/Providers/Anthropic/StreamTest.php b/tests/Providers/Anthropic/StreamTest.php index 8c77aa96d..89277d2dd 100644 --- a/tests/Providers/Anthropic/StreamTest.php +++ b/tests/Providers/Anthropic/StreamTest.php @@ -7,10 +7,15 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Http\Client\Request; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\ChunkType; use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; +use Prism\Prism\Events\HttpRequestCompleted; +use Prism\Prism\Events\HttpRequestStarted; +use Prism\Prism\Events\PrismRequestCompleted; +use Prism\Prism\Events\PrismRequestStarted; use Prism\Prism\Exceptions\PrismProviderOverloadedException; use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Exceptions\PrismRequestTooLargeException; @@ -18,12 +23,15 @@ use Prism\Prism\Prism; use Prism\Prism\Providers\Anthropic\ValueObjects\Citation; use Prism\Prism\Providers\Anthropic\ValueObjects\MessagePartWithCitations; +use Prism\Prism\Text\Chunk; use Prism\Prism\ValueObjects\Messages\Support\Document; use Prism\Prism\ValueObjects\Messages\UserMessage; use Prism\Prism\ValueObjects\ProviderRateLimit; use Tests\Fixtures\FixtureResponse; beforeEach(function (): void { + Event::fake(); + config()->set('prism.providers.anthropic.api_key', env('ANTHROPIC_API_KEY', 'fake-key')); }); @@ -89,6 +97,65 @@ }); }); +it('dispatch events while generating text with a basic stream', function (): void { + FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-basic-text'); + + $provider = 'anthropic'; + $model = 'claude-3-7-sonnet-20250219'; + $prompt = 'Who are you?'; + + $response = Prism::text() + ->using($provider, $model) + ->withPrompt($prompt) + ->asStream(); + + $text = ''; + $chunks = []; + + foreach ($response as $chunk) { + $chunks[] = $chunk; + $text .= $chunk->text; + } + + Event::assertDispatched(PrismRequestStarted::class, 1); + Event::assertDispatched(function (PrismRequestStarted $event) use ($provider, $model, $prompt): true { + $attributes = $event->attributes; + + expect($event->provider)->toBe($provider); + expect($attributes['request']->model())->toBe($model); + expect($attributes['request']->prompt())->toBe($prompt); + + return true; + }); + + Event::assertDispatched(HttpRequestStarted::class, 1); + Event::assertDispatched(function (HttpRequestStarted $event) use ($model): true { + $attributes = $event->attributes; + + expect($attributes['model'])->toBe($model); + + return true; + }); + + Event::assertDispatched(HttpRequestCompleted::class, 1); + Event::assertDispatched(function (HttpRequestCompleted $event): true { + expect($event->attributes['chunks'])->toBe(file_get_contents('tests/Fixtures/anthropic/stream-basic-text-1.sse')); + + return true; + }); + + Event::assertDispatched(PrismRequestCompleted::class, 1); + Event::assertDispatched(function (PrismRequestCompleted $event): true { + $chunks = $event->attributes['chunks']; + + expect($chunks)->toHaveCount(33); + expect($chunks[0])->toBeInstanceOf(Chunk::class); + expect(end($chunks)->finishReason)->toBe(FinishReason::Stop); + + return true; + }); +}); + describe('tools', function (): void { it('can generate text using tools with streaming', function (): void { FixtureResponse::fakeStreamResponses('v1/messages', 'anthropic/stream-with-tools'); diff --git a/tests/Providers/Gemini/GeminiStreamTest.php b/tests/Providers/Gemini/GeminiStreamTest.php index 219ece259..02550df12 100644 --- a/tests/Providers/Gemini/GeminiStreamTest.php +++ b/tests/Providers/Gemini/GeminiStreamTest.php @@ -16,7 +16,7 @@ }); it('can generate text stream with a basic prompt', function (): void { - FixtureResponse::fakeResponseSequence('*', 'gemini/stream-basic-text'); + FixtureResponse::fakeResponseSequence('*', 'gemini/stream-basic-text', ['Content-Type' => 'text/event-stream']); $response = Prism::text() ->using(Provider::Gemini, 'gemini-2.0-flash') @@ -44,7 +44,7 @@ }); it('can generate text stream using searchGrounding', function (): void { - FixtureResponse::fakeResponseSequence('*', 'gemini/stream-with-tools'); + FixtureResponse::fakeResponseSequence('*', 'gemini/stream-with-tools', ['Content-Type' => 'text/event-stream']); $response = Prism::text() ->using(Provider::Gemini, 'gemini-2.0-flash') @@ -100,7 +100,7 @@ }); it('can generate text stream using tools ', function (): void { - FixtureResponse::fakeResponseSequence('*', 'gemini/stream-with-tools'); + FixtureResponse::fakeResponseSequence('*', 'gemini/stream-with-tools', ['Content-Type' => 'text/event-stream']); $tools = [ Tool::as('weather') diff --git a/tests/Providers/OpenAI/StreamTest.php b/tests/Providers/OpenAI/StreamTest.php index bb1c17ae1..0d7cb588d 100644 --- a/tests/Providers/OpenAI/StreamTest.php +++ b/tests/Providers/OpenAI/StreamTest.php @@ -18,7 +18,7 @@ }); it('can generate text with a basic stream', function (): void { - FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-basic-text-responses'); + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-basic-text-responses', ['Content-Type' => 'text/event-stream']); $response = Prism::text() ->using('openai', 'gpt-4') @@ -58,7 +58,7 @@ }); it('can generate text using tools with streaming', function (): void { - FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-with-tools-responses'); + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-with-tools-responses', ['Content-Type' => 'text/event-stream']); $tools = [ Tool::as('weather') @@ -113,7 +113,7 @@ }); it('can process a complete conversation with multiple tool calls', function (): void { - FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-multi-tool-conversation-responses'); + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-multi-tool-conversation-responses', ['Content-Type' => 'text/event-stream']); $tools = [ Tool::as('weather') @@ -152,7 +152,7 @@ }); it('can process a complete conversation with multiple tool calls for reasoning models', function (): void { - FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-multi-tool-conversation-responses-reasoning'); + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-multi-tool-conversation-responses-reasoning', ['Content-Type' => 'text/event-stream']); $tools = [ Tool::as('weather') ->for('Get weather information') @@ -190,7 +190,7 @@ }); it('can process a complete conversation with multiple tool calls for reasoning models that require past reasoning', function (): void { - FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-multi-tool-conversation-responses-reasoning-past-reasoning'); + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-multi-tool-conversation-responses-reasoning-past-reasoning', ['Content-Type' => 'text/event-stream']); $tools = [ Tool::as('weather') ->for('Get weather information') @@ -250,7 +250,7 @@ }); it('emits usage information', function (): void { - FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-basic-text-responses'); + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-basic-text-responses', ['Content-Type' => 'text/event-stream']); $response = Prism::text() ->using('openai', 'gpt-4') @@ -283,7 +283,7 @@ })->throws(PrismRateLimitedException::class); it('can accept falsy parameters', function (): void { - FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-falsy-argument-conversation-responses'); + FixtureResponse::fakeResponseSequence('v1/responses', 'openai/stream-falsy-argument-conversation-responses', ['Content-Type' => 'text/event-stream']); $modelTool = Tool::as('get_models') ->for('Returns info about of available models') From 7fe99bf5ac480297c4316fc90fedbdd1d70e50a6 Mon Sep 17 00:00:00 2001 From: MrCrayon Date: Mon, 23 Jun 2025 07:58:57 +0200 Subject: [PATCH 2/2] chore: bump laravel 11 minum version requirement v11.31 minumum is needed to use Context::popHidden() --- .github/workflows/tests.yml | 4 ++-- composer.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fce0187c8..ed0616f20 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,10 +16,10 @@ jobs: matrix: os: [ubuntu-latest] php: [8.2, 8.3, 8.4] - laravel: [11.*, 12.*] + laravel: [11.31.*, 12.*] stability: [prefer-lowest, prefer-stable] include: - - laravel: 11.* + - laravel: 11.31.* testbench: 9.* carbon: ^2.63 - laravel: 12.* diff --git a/composer.json b/composer.json index 6a9841852..24cedeb3b 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "require": { "php": "^8.2", "ext-fileinfo": "*", - "laravel/framework": "^11.0|^12.0" + "laravel/framework": "^11.31|^12.0" }, "config": { "allow-plugins": {