diff --git a/README.md b/README.md index 1c03840..be2ae22 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,11 @@ Prism Bedrock supports three of those API schemas: Each schema supports different capabilities: -| Schema | Text | Structured | Embeddings | -|--------|:----:|:----------:|:----------:| -| Converse | ✅ | ✅ | ❌ | -| Anthropic | ✅ | ✅ | ❌ | -| Cohere | ❌ | ❌ | ✅ | +| Schema | Text | Structured | Embeddings | Stream | +|--------|:----:|:----------:|:----------:|:------:| +| Converse | ✅ | ✅ | ❌ | ✅ | +| Anthropic | ✅ | ✅ | ❌ | ❌| +| Cohere | ❌ | ❌ | ✅ | ❌ | \* A unified interface for multiple providers. See [AWS documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-supported-models-features.html) for a list of supported models. diff --git a/composer.json b/composer.json index 2e87153..2e2a173 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "php": "^8.2", "laravel/framework": "^11.0|^12.0", "aws/aws-sdk-php": "^3.339", - "prism-php/prism": ">=0.88.0" + "prism-php/prism": ">=0.92.0" }, "config": { "allow-plugins": { diff --git a/src/Bedrock.php b/src/Bedrock.php index f141dcb..22efdbb 100644 --- a/src/Bedrock.php +++ b/src/Bedrock.php @@ -4,9 +4,11 @@ use Aws\Credentials\Credentials; use Aws\Signature\SignatureV4; +use Generator; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Request; use Prism\Bedrock\Enums\BedrockSchema; +use Prism\Bedrock\Schemas\Converse\ConverseStreamHandler; use Prism\Prism\Concerns\InitializesClient; use Prism\Prism\Contracts\PrismRequest; use Prism\Prism\Embeddings\Request as EmbeddingRequest; @@ -73,6 +75,18 @@ public function structured(StructuredRequest $request): StructuredResponse return $handler->handle($request); } + #[\Override] + public function stream(TextRequest $request): Generator + { + $handler = new ConverseStreamHandler($this->client( + $request, + $request->clientOptions(), + $request->clientRetry() + )); + + return $handler->handle($request); + } + #[\Override] public function embeddings(EmbeddingRequest $request): EmbeddingsResponse { diff --git a/src/Schemas/Converse/ConverseStreamHandler.php b/src/Schemas/Converse/ConverseStreamHandler.php new file mode 100644 index 0000000..5e72e0a --- /dev/null +++ b/src/Schemas/Converse/ConverseStreamHandler.php @@ -0,0 +1,520 @@ +state = new ConverseStreamState; + } + + /** + * @return Generator + */ + public function handle(Request $request): Generator + { + $response = $this->sendRequest($request); + + yield from $this->processStream($response, $request); + } + + /** + * @return array + */ + public static function buildPayload(Request $request, int $stepCount = 0): array + { + return ConverseTextHandler::buildPayload( + $request, + $stepCount + ); + } + + protected function sendRequest(Request $request): Response + { + return $this->client + ->withOptions(['stream' => true]) + ->post( + 'converse-stream', + static::buildPayload($request) + ); + } + + /** + * @return Generator + */ + protected function processStream(Response $response, Request $request, int $depth = 0): Generator + { + $this->state->reset(); + + $this->state + ->withModel($request->model()); + + $stream = $response->getBody(); + + if ($stream->isSeekable()) { + $decoder = new DecodingEventStreamIterator($stream); + } else { + $decoder = new NonSeekableStreamDecodingEventStreamIterator($stream); + } + + foreach ($decoder as $event) { + $streamEvent = $this->processEvent($event); + + if ($streamEvent instanceof Generator) { + yield from $streamEvent; + } elseif ($streamEvent instanceof StreamEvent) { + yield $streamEvent; + } + } + + if ($this->state->hasToolCalls()) { + yield from $this->handleToolCalls($request, $this->mapToolCalls(), $depth); + } + } + + /** + * @param array $toolCalls + * @return Generator + */ + protected function handleToolCalls(Request $request, array $toolCalls, int $depth): Generator + { + $toolResults = []; + + foreach ($toolCalls as $toolCall) { + $tool = $this->resolveTool($toolCall->name, $request->tools()); + + try { + $result = call_user_func_array( + $tool->handle(...), + $toolCall->arguments() + ); + + $toolResult = new ToolResult( + toolCallId: $toolCall->id, + toolName: $toolCall->name, + args: $toolCall->arguments(), + result: $result, + ); + + $toolResults[] = $toolResult; + + yield new ToolResultEvent( + id: EventID::generate(), + timestamp: time(), + toolResult: $toolResult, + messageId: $this->state->messageId(), + success: true + ); + } catch (Throwable $e) { + $errorResultObj = new ToolResult( + toolCallId: $toolCall->id, + toolName: $toolCall->name, + args: $toolCall->arguments(), + result: [] + ); + + yield new ToolResultEvent( + id: EventID::generate(), + timestamp: time(), + toolResult: $errorResultObj, + messageId: $this->state->messageId(), + success: false, + error: $e->getMessage() + ); + } + } + + if ($toolResults !== []) { + $request->addMessage(new AssistantMessage( + content: $this->state->currentText(), + toolCalls: $toolCalls + )); + + $request->addMessage(new ToolResultMessage($toolResults)); + + // Continue streaming if within step limit + $depth++; + if ($depth < $request->maxSteps()) { + $this->state->reset(); + $nextResponse = $this->sendRequest($request); + yield from $this->processStream($nextResponse, $request, $depth); + } + } + } + + protected function shouldContinue(Request $request, int $depth): bool + { + return $depth < $request->maxSteps(); + } + + /** + * @return array + */ + protected function mapToolCalls(): array + { + return array_values(array_map(function (array $toolCall): ToolCall { + $input = data_get($toolCall, 'input'); + if (is_string($input) && $this->isValidJson($input)) { + $input = json_decode($input, true); + } + + return new ToolCall( + id: data_get($toolCall, 'id'), + name: data_get($toolCall, 'name'), + arguments: $input + ); + }, $this->state->toolCalls())); + } + + protected function isValidJson(string $string): bool + { + if ($string === '' || $string === '0') { + return false; + } + + try { + json_decode($string, true, 512, JSON_THROW_ON_ERROR); + + return true; + } catch (Throwable) { + return false; + } + } + + /** + * @param array $event + */ + protected function processEvent(array $event): null|StreamEvent|Generator + { + $json = json_decode((string) $event['payload'], true); + + return match ($event['headers'][':event-type']) { + 'messageStart' => $this->handleMessageStart($json), + 'contentBlockStart' => $this->handleContentBlockStart($json), + 'contentBlockDelta' => $this->handleContentBlockDelta($json), + 'contentBlockStop' => $this->handleContentBlockStop($json), + 'messageStop' => $this->handleMessageStop($json), + 'metadata' => $this->handleMetadata($json), + 'internalServerException', + 'throttlingException', + 'modelStreamErrorException', + 'serviceUnavailableException', + 'validationException' => $this->handleError($json), + default => null, + }; + } + + /** + * @param array $event + */ + protected function handleContentBlockStart(array $event): ?StreamEvent + { + $blockType = (bool) data_get($event, 'start.toolUse') + ? 'tool_use' : 'text'; + + $blockIndex = (int) data_get($event, 'contentBlockIndex'); + + $this->state->withBlockContext($blockIndex, $blockType); + + if ($blockType === 'tool_use') { + $this->state->addToolCall($blockIndex, [ + 'id' => data_get($event, 'start.toolUse.toolUseId'), + 'name' => data_get($event, 'start.toolUse.name'), + 'input' => '', + ]); + + return null; + } + + return new TextStartEvent( + id: EventID::generate(), + timestamp: time(), + messageId: $this->state->messageId() + ); + } + + /** + * @param array $event + */ + protected function handleContentBlockDelta(array $event): null|StreamEvent|Generator + { + $this->state->withBlockIndex($event['contentBlockIndex']); + $delta = $event['delta']; + + return match (array_keys($delta)[0] ?? null) { + 'text' => $this->handleTextDelta($delta['text']), + 'citation' => $this->handleCitationDelta($delta['citation']), + 'reasoningContent' => $this->handleReasoningContentDelta($delta['reasoningContent']), + 'toolUse' => $this->handleToolUseDelta($delta['toolUse']), + default => null, + }; + } + + /** + * @param array $event + */ + protected function handleContentBlockStop(array $event): ?StreamEvent + { + $result = match ($this->state->currentBlockType()) { + 'text' => new TextCompleteEvent( + id: EventID::generate(), + timestamp: time(), + messageId: $this->state->messageId() + ), + 'tool_use' => $this->handleToolUseComplete(), + 'thinking' => new ThinkingCompleteEvent( + id: EventID::generate(), + timestamp: time(), + reasoningId: $this->state->reasoningId() + ), + default => null, + }; + + $this->state->resetBlockContext(); + + return $result; + } + + /** + * @param array $event + */ + protected function handleMessageStart(array $event): StreamStartEvent + { + $this->state + ->withMessageId(EventID::generate()); + + return new StreamStartEvent( + id: EventID::generate(), + timestamp: time(), + model: $this->state->model(), + provider: Bedrock::KEY, + ); + } + + /** + * @param array $event + */ + protected function handleMessageStop(array $event): void + { + $this->state->withFinishReason(FinishReasonMap::map(data_get($event, 'stopReason'))); + } + + /** + * @param array $event + */ + protected function handleMetadata(array $event): StreamEndEvent + { + return new StreamEndEvent( + id: EventID::generate(), + timestamp: time(), + finishReason: $this->state->finishReason() ?? throw new PrismException('Finish reason not set'), + usage: new Usage( + promptTokens: data_get($event, 'usage.inputTokens', 0), + completionTokens: data_get($event, 'usage.outputTokens', 0), + cacheWriteInputTokens: data_get($event, 'usage.cacheWriteInputTokens', 0), + cacheReadInputTokens: data_get($event, 'usage.cacheReadInputTokens', 0), + ), + citations: $this->state->citations() !== [] ? $this->state->citations() : null + ); + } + + /** + * @param array $contentBlock + */ + protected function handleToolUseStart(array $contentBlock): null + { + $blockIndex = $this->state->currentBlockIndex(); + if ($this->state->currentBlockType() !== null && $blockIndex !== null) { + $this->state->addToolCall($blockIndex, [ + 'id' => $contentBlock['id'] ?? EventID::generate(), + 'name' => $contentBlock['name'] ?? 'unknown', + 'input' => '', + ]); + } + + return null; + } + + /** + * @param array $event + * @return never + */ + protected function handleError(array $event) + { + if ($event[':headers']['event-type'] === 'throttlingException') { + throw PrismRateLimitedException::make(); + } + + throw PrismException::providerResponseError(vsprintf( + 'Bedrock Converse Stream Error: %s', + $event[':headers']['event-type'] + )); + } + + protected function handleTextDelta(string $text): ?TextDeltaEvent + { + if ($text === '') { + return null; + } + + $this->state->appendText($text); + + return new TextDeltaEvent( + id: EventID::generate(), + timestamp: time(), + delta: $text, + messageId: $this->state->messageId() + ); + } + + /** + * @param array $citationData + */ + protected function handleCitationDelta(array $citationData): CitationEvent + { + // Map citation data using CitationsMapper + $citation = CitationsMapper::mapCitationFromConverse($citationData); + + // Create MessagePartWithCitations for aggregation + $messagePartWithCitations = new MessagePartWithCitations( + outputText: $this->state->currentText(), + citations: [$citation] + ); + + // Store for later aggregation + $this->state->addCitation($messagePartWithCitations); + + return new CitationEvent( + id: EventID::generate(), + timestamp: time(), + citation: $citation, + messageId: $this->state->messageId(), + blockIndex: $this->state->currentBlockIndex() + ); + } + + /** + * @param array $reasoningContent + * @return Generator + */ + protected function handleReasoningContentDelta(array $reasoningContent): Generator + { + $thinking = $reasoningContent['text'] ?? ''; + + if ($thinking === '') { + return; + } + + $this->state->withBlockType('thinking'); + + if ($this->state->reasoningId() === '' || $this->state->reasoningId() === '0') { + $this->state->withReasoningId(EventID::generate()); + + yield new ThinkingStartEvent( + id: EventID::generate(), + timestamp: time(), + reasoningId: $this->state->reasoningId() + ); + } + + $this->state->appendThinking($thinking); + + yield new ThinkingEvent( + id: EventID::generate(), + timestamp: time(), + delta: $thinking, + reasoningId: $this->state->reasoningId() + ); + } + + /** + * @param array $toolUse + */ + protected function handleToolUseDelta(array $toolUse): null + { + $jsonDelta = data_get($toolUse, 'input'); + + $blockIndex = $this->state->currentBlockIndex(); + + if ($blockIndex !== null) { + $this->state->appendToolCallInput($blockIndex, $jsonDelta); + } + + return null; + } + + protected function handleToolUseComplete(): ?ToolCallEvent + { + $blockIndex = $this->state->currentBlockIndex(); + if ($blockIndex === null) { + return null; + } + + $toolCall = $this->state->toolCalls()[$blockIndex]; + $input = $toolCall['input']; + + // Parse the JSON input + if (is_string($input) && json_validate($input)) { + $input = json_decode($input, true); + } elseif (is_string($input) && $input !== '') { + // If it's not valid JSON but not empty, wrap in array + $input = ['input' => $input]; + } else { + $input = []; + } + + $toolCallObj = new ToolCall( + id: $toolCall['id'], + name: $toolCall['name'], + arguments: $input, + ); + + return new ToolCallEvent( + id: EventID::generate(), + timestamp: time(), + toolCall: $toolCallObj, + messageId: $this->state->messageId() + ); + } +} diff --git a/src/Schemas/Converse/Maps/CitationsMapper.php b/src/Schemas/Converse/Maps/CitationsMapper.php new file mode 100644 index 0000000..9049bd8 --- /dev/null +++ b/src/Schemas/Converse/Maps/CitationsMapper.php @@ -0,0 +1,124 @@ + $contentBlock + */ + public static function mapFromConverse(array $contentBlock): ?MessagePartWithCitations + { + if (! isset($contentBlock['citationsContent']['citations'])) { + return null; + } + + $citations = array_map( + fn (array $citationData): Citation => self::mapCitationFromConverse($citationData), + $contentBlock['citationsContent']['citations'] + ); + + return new MessagePartWithCitations( + outputText: implode('', array_map(fn (array $text) => $text['text'] ?? '', $contentBlock['citationsContent']['content'] ?? [])), + citations: $citations, + ); + } + + /** + * @param array $citationData + */ + public static function mapCitationFromConverse(array $citationData): Citation + { + $location = $citationData['location'] ?? []; + + $indices = $location['documentChar'] ?? $location['documentChunk'] ?? $location['documentPage'] ?? null; + + return new Citation( + sourceType: CitationSourceType::Document, + source: $indices['documentIndex'] ?? 0, + sourceText: self::mapSourceText($citationData['sourceContent'] ?? []), + sourceTitle: $citationData['title'] ?? '', + sourcePositionType: self::mapSourcePositionType($location), + sourceStartIndex: $indices['start'] ?? null, + sourceEndIndex: $indices['end'] ?? null, + ); + } + + /** + * @return array + */ + public static function mapToConverse(MessagePartWithCitations $part): array + { + $citations = array_map( + fn (Citation $citation): array => self::mapCitationToConverse($citation), + $part->citations + ); + + return [ + 'citationsContent' => [ + 'citations' => array_filter($citations), + 'content' => [ + ['text' => $part->outputText], + ], + ], + ]; + } + + /** + * @param array> $citationData + */ + protected static function mapSourceText(array $citationData): ?string + { + return implode("\n", array_map( + fn (array $sourceContent): string => $sourceContent['text'] ?? '', + $citationData + )); + } + + /** + * @param array $location + */ + protected static function mapSourcePositionType(array $location): ?CitationSourcePositionType + { + return match (array_keys($location)[0] ?? null) { + 'documentChar' => CitationSourcePositionType::Character, + 'documentChunk' => CitationSourcePositionType::Chunk, + 'documentPage' => CitationSourcePositionType::Page, + default => null, + }; + } + + /** + * @return array + */ + protected static function mapCitationToConverse(Citation $citation): array + { + $locationKey = match ($citation->sourcePositionType) { + CitationSourcePositionType::Character => 'documentChar', + CitationSourcePositionType::Chunk => 'documentChunk', + CitationSourcePositionType::Page => 'documentPage', + default => null, + }; + + $location = $locationKey ? [ + $locationKey => [ + 'documentIndex' => $citation->source, + 'start' => $citation->sourceStartIndex, + 'end' => $citation->sourceEndIndex, + ], + ] : []; + + return array_filter([ + 'location' => $location, + 'sourceContent' => $citation->sourceText ? [ + ['text' => $citation->sourceText], + ] : null, + 'title' => $citation->sourceTitle ?: null, + ]); + } +} diff --git a/src/Schemas/Converse/Maps/DocumentMapper.php b/src/Schemas/Converse/Maps/DocumentMapper.php index a19d790..68d22ac 100644 --- a/src/Schemas/Converse/Maps/DocumentMapper.php +++ b/src/Schemas/Converse/Maps/DocumentMapper.php @@ -12,11 +12,13 @@ class DocumentMapper extends ProviderMediaMapper { /** * @param Document $media - * @param array $cacheControl + * @param array|null $cacheControl + * @param array|null $citationsConfig */ public function __construct( public readonly Media $media, - public ?array $cacheControl = null + public ?array $cacheControl = null, + public ?array $citationsConfig = null, ) {} /** @@ -29,6 +31,7 @@ public function toPayload(): array 'format' => $this->media->mimeType() ? Mimes::tryFrom($this->media->mimeType())?->toExtension() : null, 'name' => $this->media->documentTitle(), 'source' => ['bytes' => $this->media->base64()], + ...($this->citationsConfig ? ['citations' => $this->citationsConfig] : []), ], ]; } diff --git a/src/Schemas/Converse/Maps/MessageMap.php b/src/Schemas/Converse/Maps/MessageMap.php index 5170266..7dd89e3 100644 --- a/src/Schemas/Converse/Maps/MessageMap.php +++ b/src/Schemas/Converse/Maps/MessageMap.php @@ -9,6 +9,7 @@ use Prism\Prism\Exceptions\PrismException; use Prism\Prism\ValueObjects\Media\Document; use Prism\Prism\ValueObjects\Media\Image; +use Prism\Prism\ValueObjects\MessagePartWithCitations; use Prism\Prism\ValueObjects\Messages\AssistantMessage; use Prism\Prism\ValueObjects\Messages\SystemMessage; use Prism\Prism\ValueObjects\Messages\ToolResultMessage; @@ -110,7 +111,7 @@ protected static function mapUserMessage(UserMessage $message): array 'content' => array_filter([ ['text' => $message->text()], ...self::mapImageParts($message->images()), - ...self::mapDocumentParts($message->documents()), + ...self::mapDocumentParts($message->documents(), $message->providerOptions()), $cacheType ? ['cachePoint' => ['type' => $cacheType]] : null, ]), ]; @@ -128,6 +129,7 @@ protected static function mapAssistantMessage(AssistantMessage $message): array 'content' => array_values(array_filter([ $message->content === '' || $message->content === '0' ? null : ['text' => $message->content], ...self::mapToolCalls($message->toolCalls), + ...self::mapCitations($message->additionalContent['citations'] ?? []), $cacheType ? ['cachePoint' => ['type' => $cacheType]] : null, ])), ]; @@ -148,6 +150,18 @@ protected static function mapToolCalls(array $parts): array ], $parts); } + /** + * @param array $parts + * @return array> + */ + protected static function mapCitations(array $parts): array + { + return array_map( + fn (MessagePartWithCitations $part): array => CitationsMapper::mapToConverse($part), + $parts + ); + } + /** * @param Image[] $parts * @return array @@ -162,12 +176,16 @@ protected static function mapImageParts(array $parts): array /** * @param Document[] $parts + * @param array $providerOptions * @return array> */ - protected static function mapDocumentParts(array $parts): array + protected static function mapDocumentParts(array $parts, array $providerOptions = []): array { return array_map( - fn (Document $document): array => (new DocumentMapper($document))->toPayload(), + fn (Document $document): array => (new DocumentMapper( + media: $document, + citationsConfig: data_get($providerOptions, 'citations', null) + ))->toPayload(), $parts ); } diff --git a/src/ValueObjects/ConverseStreamState.php b/src/ValueObjects/ConverseStreamState.php new file mode 100644 index 0000000..09ddd1b --- /dev/null +++ b/src/ValueObjects/ConverseStreamState.php @@ -0,0 +1,24 @@ +currentBlockIndex = $index; + + return $this; + } + + public function withBlockType(string $type): self + { + $this->currentBlockType = $type; + + return $this; + } +} diff --git a/tests/Fixtures/FixtureResponse.php b/tests/Fixtures/FixtureResponse.php index 6180d20..c333bf3 100644 --- a/tests/Fixtures/FixtureResponse.php +++ b/tests/Fixtures/FixtureResponse.php @@ -6,6 +6,7 @@ use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; class FixtureResponse { @@ -21,6 +22,59 @@ public static function fromFile( ); } + public static function fakeStreamResponses(string $requestPath, string $name, array $headers = []): void + { + $basePath = dirname(static::filePath("{$name}-1.sse")); + + // Find all recorded .sse files for this test + $files = collect(is_dir($basePath) ? scandir($basePath) : []) + ->filter(fn ($file): int|false => preg_match('/^'.preg_quote(basename($name), '/').'-\d+\.sse$/', $file)) + ->map(fn ($file): string => $basePath.'/'.$file) + ->values() + ->toArray(); + + // If no files exist, automatically record the streaming responses + if (empty($files)) { + static::recordStreamResponses($requestPath, $name); + + return; + } + + // Sort files numerically + usort($files, function ($a, $b): int { + preg_match('/-(\d+)\.sse$/', $a, $matchesA); + preg_match('/-(\d+)\.sse$/', $b, $matchesB); + + return (int) $matchesA[1] <=> (int) $matchesB[1]; + }); + + // Create response sequence from the files + $responses = array_map(fn ($file) => Http::response( + file_get_contents($file), + 200, + [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'Transfer-Encoding' => 'chunked', + ...$headers, + ] + ), $files); + + if ($responses === []) { + $responses[] = Http::response( + "data: {\"error\":\"No recorded stream responses found\"}\n\ndata: [DONE]\n\n", + 200, + ['Content-Type' => 'text/event-stream'] + ); + } + + // Register the fake responses + Http::fake([ + $requestPath => Http::sequence($responses), + ])->preventStrayRequests(); + } + public static function filePath(string $filePath): string { return sprintf('%s/%s', __DIR__, $filePath); @@ -68,4 +122,63 @@ public static function fakeResponseSequence(string $requestPath, string $name, a $requestPath => Http::sequence($responses->toArray()), ])->preventStrayRequests(); } + + protected static function recordStreamResponses(string $requestPath, string $name): void + { + Http::fake(function ($request) use ($requestPath, $name) { + if (Str::contains($request->url(), $requestPath)) { + static $iterator = 0; + $iterator++; + + // Create directory for the response file if needed + $path = static::filePath("{$name}-{$iterator}.sse"); + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), recursive: true); + } + + // Get content type or default to application/json + $contentType = $request->hasHeader('Content-Type') + ? $request->header('Content-Type')[0] + : 'application/json'; + + // Forward the request to the real API with stream option + $client = new \GuzzleHttp\Client(['stream' => true]); + $options = [ + 'headers' => $request->headers(), + 'body' => $request->body(), + 'stream' => true, + ]; + + $response = $client->request($request->method(), $request->url(), $options); + + $stream = $response->getBody(); + + // Open file for writing + $fileHandle = fopen($path, 'w'); + + // Write stream to file in small chunks to avoid memory issues + while (! $stream->eof()) { + $chunk = $stream->read(1024); // Read 1KB at a time + fwrite($fileHandle, $chunk); + } + + fclose($fileHandle); + + // Return the file contents as the response for the test + return Http::response( + file_get_contents($path), + $response->getStatusCode(), + [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + ] + ); + } + + // For non-matching requests, pass through + return Http::response('{"error":"Not mocked"}', 404); + }); + } } diff --git a/tests/Fixtures/converse/stream-basic-text-1.sse b/tests/Fixtures/converse/stream-basic-text-1.sse new file mode 100644 index 0000000..d2beba8 Binary files /dev/null and b/tests/Fixtures/converse/stream-basic-text-1.sse differ diff --git a/tests/Fixtures/converse/stream-basic-text-with-cache-usage-1.sse b/tests/Fixtures/converse/stream-basic-text-with-cache-usage-1.sse new file mode 100644 index 0000000..7fa0f2c Binary files /dev/null and b/tests/Fixtures/converse/stream-basic-text-with-cache-usage-1.sse differ diff --git a/tests/Fixtures/converse/stream-handle-tool-cals-1.sse b/tests/Fixtures/converse/stream-handle-tool-cals-1.sse new file mode 100644 index 0000000..1d3548f Binary files /dev/null and b/tests/Fixtures/converse/stream-handle-tool-cals-1.sse differ diff --git a/tests/Fixtures/converse/stream-handle-tool-cals-2.sse b/tests/Fixtures/converse/stream-handle-tool-cals-2.sse new file mode 100644 index 0000000..0df9ab3 Binary files /dev/null and b/tests/Fixtures/converse/stream-handle-tool-cals-2.sse differ diff --git a/tests/Fixtures/converse/stream-with-citations-1.sse b/tests/Fixtures/converse/stream-with-citations-1.sse new file mode 100644 index 0000000..2e1a772 Binary files /dev/null and b/tests/Fixtures/converse/stream-with-citations-1.sse differ diff --git a/tests/Fixtures/converse/stream-with-previous-citations-1.sse b/tests/Fixtures/converse/stream-with-previous-citations-1.sse new file mode 100644 index 0000000..117ce85 Binary files /dev/null and b/tests/Fixtures/converse/stream-with-previous-citations-1.sse differ diff --git a/tests/Fixtures/converse/stream-with-reasoning-1.sse b/tests/Fixtures/converse/stream-with-reasoning-1.sse new file mode 100644 index 0000000..3e6670b Binary files /dev/null and b/tests/Fixtures/converse/stream-with-reasoning-1.sse differ diff --git a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php index c4b6cde..98712f7 100644 --- a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php @@ -135,7 +135,11 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ - 'temperature' => 0, - ])); + Http::assertSent(function (Request $request): bool { + expect($request->data())->toMatchArray([ + 'temperature' => 0, + ]); + + return true; + }); }); diff --git a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php index e678916..3f03fe4 100644 --- a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php @@ -203,7 +203,11 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ - 'temperature' => 0, - ])); + Http::assertSent(function (Request $request): bool { + expect($request->data())->toMatchArray([ + 'temperature' => 0, + ]); + + return true; + }); }); diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php new file mode 100644 index 0000000..8df97cb --- /dev/null +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -0,0 +1,346 @@ +using('bedrock', 'us.amazon.nova-micro-v1:0') + ->withProviderOptions(['apiSchema' => BedrockSchema::Converse]) + ->withPrompt('Who are you?') + ->asStream(); + + $text = ''; + $events = []; + + foreach ($response as $event) { + $events[] = $event; + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect($events)->not->toBeEmpty(); + expect($text)->not->toBeEmpty(); + + $finalEvent = end($events); + + expect($finalEvent->finishReason)->toBe(FinishReason::Stop); + + // Verify the HTTP request + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), 'converse-stream')); + + expect($text) + ->toBe('I am an AI assistant called Claude. I was created by Anthropic to be helpful, '. + 'harmless, and honest. I don\'t have a physical body or avatar - I\'m a language '. + 'model trained to engage in conversation and help with tasks. How can I assist you today?'); +}); + +it('can return usage with a basic stream', function (): void { + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-basic-text-with-cache-usage'); + + // Read a large system prompt from cache + // Write a conversation to cache + + $response = Prism::text() + ->using('bedrock', 'us.amazon.nova-micro-v1:0') + ->withSystemPrompt( + (new SystemMessage( + collect(range(1, 1000)) + ->map(fn ($i): string|false => NumberFormatter::create('en', NumberFormatter::SPELLOUT)->format($i)) + ->implode(' ') + )) + ->withProviderOptions(['cacheType' => 'default']) + ) + ->withMessages([ + new UserMessage('Who are you?'), + (new AssistantMessage('Hi I\'m Nova')) + ->withProviderOptions(['cacheType' => 'default']), + new UserMessage('Nice to meet you Nova'), + ]) + ->asStream(); + + $text = ''; + $event = []; + + foreach ($response as $chunk) { + $event[] = $chunk; + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect((array) end($event)->usage)->toBe([ + 'promptTokens' => 67, + 'completionTokens' => 48, + 'cacheWriteInputTokens' => 131, + 'cacheReadInputTokens' => 4230, + 'thoughtTokens' => null, + ]); + + // Verify the HTTP request + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), 'converse-stream')); +}); + +it('can handle tool calls', function (): void { + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-handle-tool-cals'); + + $tools = [ + Tool::as('weather') + ->for('useful when you need to search for current weather conditions') + ->withStringParameter('city', 'The city that 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'), + ]; + + $response = Prism::text() + ->using('bedrock', 'us.amazon.nova-micro-v1:0') + ->withProviderOptions(['apiSchema' => BedrockSchema::Converse]) + ->withPrompt('What is the weather like in Detroit today?') + ->withMaxSteps(2) + ->withTools($tools) + ->asStream(); + + $toolCallFound = false; + $toolResults = []; + $events = []; + $text = ''; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof ToolCallEvent) { + $toolCallFound = true; + expect($event->toolCall->name)->not->toBeEmpty(); + expect($event->toolCall->arguments())->toBeArray(); + } + + if ($event instanceof ToolResultEvent) { + $toolResults[] = $event; + } + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + } + + expect($events)->not->toBeEmpty(); + expect($toolCallFound)->toBeTrue('Expected to find at least one tool call in the stream'); + + $lastEvent = end($events); + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->finishReason)->toBe(FinishReason::Stop); + + // Verify the HTTP request + Http::assertSent(function (Request $request): bool { + $body = json_decode($request->body(), true); + + return str_ends_with($request->url(), 'us.amazon.nova-micro-v1:0/converse-stream') + && isset($body['toolConfig']); + }); +}); + +it('can handle thinking', function (): void { + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-reasoning'); + + $response = Prism::text() + ->using('bedrock', 'us.anthropic.claude-sonnet-4-20250514-v1:0') + ->withProviderOptions([ + 'apiSchema' => BedrockSchema::Converse, + 'additionalModelRequestFields' => [ + 'thinking' => [ + 'type' => 'enabled', + 'budget_tokens' => 1024, + ], + ], + 'inferenceConfig' => [ + 'maxTokens' => 5000, + ], + ]) + ->withPrompt('Who are you?') + ->asStream(); + + $events = collect($response); + + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingStart)->sole()) + ->toBeInstanceOf(ThinkingStartEvent::class); + + $thinkingDeltas = $events->where( + fn (StreamEvent $event): bool => $event->type() === StreamEventType::ThinkingDelta + ); + + $thinkingDeltas + ->each(function (StreamEvent $event): void { + expect($event)->toBeInstanceOf(ThinkingEvent::class); + }); + + expect($thinkingDeltas->count())->toBeGreaterThan(2); + + expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); + + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingComplete)->sole()) + ->toBeInstanceOf(ThinkingCompleteEvent::class); +}); + +describe('citations', function (): void { + it('emits CitationEvent and includes citations in StreamEndEvent', function (): void { + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-citations'); + + $response = Prism::text() + ->using('bedrock', 'us.anthropic.claude-sonnet-4-20250514-v1:0') + ->withMessages([ + (new UserMessage( + content: 'What is the answer to life?', + additionalContent: [ + Document::fromLocalPath('tests/Fixtures/document.pdf', 'The Answer To Life'), + ] + ))->withProviderOptions([ + 'citations' => [ + 'enabled' => true, + ], + ]), + ]) + ->asStream(); + + $text = ''; + $events = []; + $citationEvents = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + + if ($event instanceof CitationEvent) { + $citationEvents[] = $event; + } + } + + $lastEvent = end($events); + + // Check that citation events were emitted + expect($citationEvents)->not->toBeEmpty(); + expect($citationEvents[0])->toBeInstanceOf(CitationEvent::class); + expect($citationEvents[0]->citation)->toBeInstanceOf(Citation::class); + expect($citationEvents[0]->messageId)->not->toBeEmpty(); + + // Check that the StreamEndEvent contains citations + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->citations)->toBeArray(); + expect($lastEvent->citations)->not->toBeEmpty(); + expect($lastEvent->citations[0])->toBeInstanceOf(MessagePartWithCitations::class); + expect($lastEvent->citations[0]->citations[0])->toBeInstanceOf(Citation::class); + expect($lastEvent->finishReason)->toBe(FinishReason::Stop); + }); + + it('can send citation data to model', function (): void { + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-previous-citations'); + + $messageWithCitation = new AssistantMessage( + content: '', + additionalContent: [ + 'citations' => [ + new MessagePartWithCitations( + outputText: 'The answer to life is 42.', + citations: [ + new Citation( + sourceType: CitationSourceType::Document, + source: 0, + sourceText: 'The answer to the ultimate question of life, the universe, and everything is "42".', + sourceTitle: 'The Answer To Life Document', + sourcePositionType: CitationSourcePositionType::Page, + sourceStartIndex: 1, + sourceEndIndex: 2, + ), + ], + ), + ], + ], + ); + + $response = Prism::text() + ->using('bedrock', 'us.anthropic.claude-sonnet-4-20250514-v1:0') + ->withMessages([ + (new UserMessage( + content: 'What is the answer to life?', + additionalContent: [ + Document::fromLocalPath('tests/Fixtures/document.pdf', 'The Answer To Life'), + ] + ))->withProviderOptions([ + 'citations' => [ + 'enabled' => true, + ], + ]), + $messageWithCitation, + new UserMessage('Can you explain that further?'), + ]) + ->asStream(); + + $text = ''; + $events = []; + $citationEvents = []; + + foreach ($response as $event) { + $events[] = $event; + + if ($event instanceof TextDeltaEvent) { + $text .= $event->delta; + } + + if ($event instanceof CitationEvent) { + $citationEvents[] = $event; + } + } + + $lastEvent = end($events); + + // Check that citation events were emitted + expect($citationEvents)->not->toBeEmpty(); + expect($citationEvents[0])->toBeInstanceOf(CitationEvent::class); + expect($citationEvents[0]->citation)->toBeInstanceOf(Citation::class); + expect($citationEvents[0]->messageId)->not->toBeEmpty(); + + // Check that the StreamEndEvent contains citations + expect($lastEvent)->toBeInstanceOf(StreamEndEvent::class); + expect($lastEvent->citations)->toBeArray(); + expect($lastEvent->citations)->not->toBeEmpty(); + expect($lastEvent->citations[0])->toBeInstanceOf(MessagePartWithCitations::class); + expect($lastEvent->citations[0]->citations[0])->toBeInstanceOf(Citation::class); + expect($lastEvent->finishReason)->toBe(FinishReason::Stop); + }); +}); diff --git a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php index ae3e9d5..63b7337 100644 --- a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php @@ -193,10 +193,16 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ - 'inferenceConfig' => [ - 'maxTokens' => 2048, - 'temperature' => 0, - ], - ])->not()->toHaveKey('guardRailConfig')); + Http::assertSent(function (Request $request): bool { + expect($request->data())->toMatchArray([ + 'inferenceConfig' => [ + 'maxTokens' => 2048, + 'temperature' => 0, + ], + ]) + ->not() + ->toHaveKey('guardRailConfig'); + + return true; + }); }); diff --git a/tests/Schemas/Converse/ConverseTextHandlerTest.php b/tests/Schemas/Converse/ConverseTextHandlerTest.php index b2b7f1f..cdcfc9d 100644 --- a/tests/Schemas/Converse/ConverseTextHandlerTest.php +++ b/tests/Schemas/Converse/ConverseTextHandlerTest.php @@ -283,10 +283,14 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ - 'inferenceConfig' => [ - 'temperature' => 0, - 'maxTokens' => 2048, - ], - ])); + Http::assertSent(function (Request $request): bool { + expect($request->data())->toMatchArray([ + 'inferenceConfig' => [ + 'temperature' => 0, + 'maxTokens' => 2048, + ], + ]); + + return true; + }); }); diff --git a/tests/Schemas/Converse/Maps/CitationsMapperTest.php b/tests/Schemas/Converse/Maps/CitationsMapperTest.php new file mode 100644 index 0000000..895f908 --- /dev/null +++ b/tests/Schemas/Converse/Maps/CitationsMapperTest.php @@ -0,0 +1,181 @@ + [ + 'documentPage' => [ + 'documentIndex' => 3, + 'end' => 17, + 'start' => 5, + ], + ], + 'sourceContent' => [ + [ + 'text' => 'The answer to the ultimate question of life, the universe, and everything is "42".', + ], + ], + 'title' => 'The Answer To Life', + ]); + + expect($citation->sourceType)->toBe(CitationSourceType::Document); + expect($citation->source)->toBe(3); + expect($citation->sourceText)->toBe('The answer to the ultimate question of life, the universe, and everything is "42".'); + expect($citation->sourceTitle)->toBe('The Answer To Life'); + expect($citation->sourcePositionType)->toBe(CitationSourcePositionType::Page); + expect($citation->sourceStartIndex)->toBe(5); + expect($citation->sourceEndIndex)->toBe(17); + }); + + it('can map citations with character location', function (): void { + $citation = CitationsMapper::mapCitationFromConverse([ + 'location' => [ + 'documentChar' => [ + 'documentIndex' => 7, + 'end' => 42, + 'start' => 13, + ], + ], + 'sourceContent' => [ + [ + 'text' => 'The answer to the ultimate question of life, the universe, and everything is "42".', + ], + ], + 'title' => 'The Answer To Life', + ]); + + expect($citation->sourceType)->toBe(CitationSourceType::Document); + expect($citation->source)->toBe(7); + expect($citation->sourceText)->toBe('The answer to the ultimate question of life, the universe, and everything is "42".'); + expect($citation->sourceTitle)->toBe('The Answer To Life'); + expect($citation->sourcePositionType)->toBe(CitationSourcePositionType::Character); + expect($citation->sourceStartIndex)->toBe(13); + expect($citation->sourceEndIndex)->toBe(42); + }); + + it('can map citations with chunk location', function (): void { + $citation = CitationsMapper::mapCitationFromConverse([ + 'location' => [ + 'documentChunk' => [ + 'documentIndex' => 2, + 'end' => 99, + 'start' => 77, + ], + ], + 'sourceContent' => [ + [ + 'text' => 'The answer to the ultimate question of life, the universe, and everything is "42".', + ], + ], + 'title' => 'The Answer To Life', + ]); + + expect($citation->sourceType)->toBe(CitationSourceType::Document); + expect($citation->source)->toBe(2); + expect($citation->sourceText)->toBe('The answer to the ultimate question of life, the universe, and everything is "42".'); + expect($citation->sourceTitle)->toBe('The Answer To Life'); + expect($citation->sourcePositionType)->toBe(CitationSourcePositionType::Chunk); + expect($citation->sourceStartIndex)->toBe(77); + expect($citation->sourceEndIndex)->toBe(99); + }); +}); + +describe('to converse api', function (): void { + it('can map citations from converse api', function (): void { + $originalConverseData = [ + 'citationsContent' => [ + 'citations' => [ + [ + 'location' => [ + 'documentPage' => [ + 'documentIndex' => 3, + 'start' => 5, + 'end' => 17, + ], + ], + 'sourceContent' => [ + [ + 'text' => 'The answer to the ultimate question of life, the universe, and everything is "42".', + ], + ], + 'title' => 'The Answer To Life', + ], + ], + 'content' => [ + ['text' => 'The answer is "42".'], + ], + ], + ]; + + $messagePartWithCitations = CitationsMapper::mapFromConverse($originalConverseData); + $roundTripResult = CitationsMapper::mapToConverse($messagePartWithCitations); + + expect($roundTripResult)->toBe($originalConverseData); + }); + + it('can map citations with character location', function (): void { + $originalConverseData = [ + 'citationsContent' => [ + 'citations' => [ + [ + 'location' => [ + 'documentChar' => [ + 'documentIndex' => 7, + 'start' => 13, + 'end' => 42, + ], + ], + 'sourceContent' => [ + [ + 'text' => 'The answer to the ultimate question of life, the universe, and everything is "42".', + ], + ], + 'title' => 'The Answer To Life', + ], + ], + 'content' => [ + ['text' => 'The answer is "42".'], + ], + ], + ]; + + $messagePartWithCitations = CitationsMapper::mapFromConverse($originalConverseData); + $roundTripResult = CitationsMapper::mapToConverse($messagePartWithCitations); + + expect($roundTripResult)->toBe($originalConverseData); + }); + + it('can map citations with chunk location', function (): void { + $originalConverseData = [ + 'citationsContent' => [ + 'citations' => [[ + 'location' => [ + 'documentChunk' => [ + 'documentIndex' => 2, + 'start' => 77, + 'end' => 99, + ], + ], + 'sourceContent' => [ + [ + 'text' => 'The answer to the ultimate question of life, the universe, and everything is "42".', + ], + ], + 'title' => 'The Answer To Life', + ]], + 'content' => [ + ['text' => 'The answer is "42".'], + ], + ], + ]; + + $messagePartWithCitations = CitationsMapper::mapFromConverse($originalConverseData); + $roundTripResult = CitationsMapper::mapToConverse($messagePartWithCitations); + + expect($roundTripResult)->toBe($originalConverseData); + }); +}); diff --git a/tests/Schemas/Converse/Maps/MessageMapTest.php b/tests/Schemas/Converse/Maps/MessageMapTest.php index ce03ec7..0bc7f5c 100644 --- a/tests/Schemas/Converse/Maps/MessageMapTest.php +++ b/tests/Schemas/Converse/Maps/MessageMapTest.php @@ -75,6 +75,36 @@ ]]); }); +it('maps a document with citations enabled correctly', function (): void { + expect(MessageMap::map([ + (new UserMessage( + content: 'Who are you?', + additionalContent: [ + Document::fromPath('tests/Fixtures/document.md', 'Answer To Life'), + ] + ))->withProviderOptions([ + 'citations' => [ + 'enabled' => true, + ], + ]), + ]))->toBe([[ + 'role' => 'user', + 'content' => [ + ['text' => 'Who are you?'], + [ + 'document' => [ + 'format' => 'txt', + 'name' => 'Answer To Life', + 'source' => ['bytes' => base64_encode(file_get_contents('tests/Fixtures/document.md'))], + 'citations' => [ + 'enabled' => true, + ], + ], + ], + ], + ]]); +}); + it('maps an image correctly', function (): void { expect(MessageMap::map([ new UserMessage( diff --git a/tests/TestCase.php b/tests/TestCase.php index 7a19656..419abad 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -22,6 +22,8 @@ protected function defineEnvironment($app) 'api_key' => env('PRISM_BEDROCK_API_KEY', 'test-api-key'), 'api_secret' => env('PRISM_BEDROCK_API_SECRET', 'test-api-secret'), 'region' => env('PRISM_BEDROCK_REGION', 'us-west-2'), + 'session_token' => env('PRISM_BEDROCK_SESSION_TOKEN', null), + 'use_default_credential_provider' => env('PRISM_BEDROCK_USE_DEFAULT_CREDENTIAL_PROVIDER', false), ]); }); }