From c48a5dd46d6a035c965b65394acea0dc3961f21c Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Wed, 15 Oct 2025 09:02:32 +1000 Subject: [PATCH 01/15] Text and tool calls working --- src/Bedrock.php | 14 + .../Converse/ConverseStreamHandler.php | 429 ++++++++++++++++++ src/ValueObjects/ConverseStreamState.php | 24 + tests/Fixtures/FixtureResponse.php | 112 +++++ .../Fixtures/converse/stream-basic-text-1.sse | Bin 0 -> 5045 bytes .../stream-basic-text-with-cache-usage-1.sse | Bin 0 -> 5176 bytes .../converse/stream-handle-tool-cals-1.sse | Bin 0 -> 4909 bytes .../converse/stream-handle-tool-cals-2.sse | Bin 0 -> 14243 bytes .../AnthropicStructuredHandlerTest.php | 69 +-- .../Anthropic/AnthropicTextHandlerTest.php | 2 +- .../Converse/ConverseStreamHandlerTest.php | 164 +++++++ .../ConverseStructuredHandlerTest.php | 73 +-- .../Converse/ConverseTextHandlerTest.php | 2 +- tests/TestCase.php | 4 +- 14 files changed, 750 insertions(+), 143 deletions(-) create mode 100644 src/Schemas/Converse/ConverseStreamHandler.php create mode 100644 src/ValueObjects/ConverseStreamState.php create mode 100644 tests/Fixtures/converse/stream-basic-text-1.sse create mode 100644 tests/Fixtures/converse/stream-basic-text-with-cache-usage-1.sse create mode 100644 tests/Fixtures/converse/stream-handle-tool-cals-1.sse create mode 100644 tests/Fixtures/converse/stream-handle-tool-cals-2.sse create mode 100644 tests/Schemas/Converse/ConverseStreamHandlerTest.php 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..6838861 --- /dev/null +++ b/src/Schemas/Converse/ConverseStreamHandler.php @@ -0,0 +1,429 @@ +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 + { + try { + return $this->client + ->withOptions(['stream' => true]) + ->post( + 'converse-stream', + static::buildPayload($request) + ); + } catch (RequestException|Throwable $e) { + throw PrismException::providerRequestError($request->model(), $e); + } + } + + protected function processStream(Response $response, Request $request, int $depth = 0) + { + $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) { + $event = $this->processEvent($event); + if ($event instanceof \Prism\Prism\Streaming\Events\StreamEvent) { + yield $event; + } + } + + if ($this->state->hasToolCalls()) { + yield from $this->handleToolCalls($request, $this->mapToolCalls(), $depth); + } + } + + 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; + } + } + + protected function processEvent(array $event): ?StreamEvent + { + $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), + }; + } + + 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() + ); + } + + protected function handleContentBlockDelta(array $event): ?StreamEvent + { + $this->state->withBlockIndex($event['contentBlockIndex']); + $delta = $event['delta']; + + return match (true) { + array_key_exists('text', $delta) => $this->handleTextDelta($delta['text']), + array_key_exists('citation', $delta) => $this->handleCitationDelta($delta['citation']), + array_key_exists('reasoningContent', $delta) => $this->handleReasoningContentDelta($delta['reasoningContent']), + array_key_exists('toolUse', $delta) => $this->handleToolUseDelta($delta['toolUse']), + default => null, + }; + } + + 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(), + default => null, + }; + + $this->state->resetBlockContext(); + + return $result; + } + + 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, + ); + } + + protected function handleMessageStop(array $event): void + { + $this->state->withFinishReason(FinishReasonMap::map(data_get($event, 'stopReason'))); + } + + protected function handleMetadata(array $event): StreamEndEvent + { + return new StreamEndEvent( + id: EventID::generate(), + timestamp: time(), + finishReason: $this->state->finishReason(), + 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), + ) + ); + } + + /** + * @param array $contentBlock + */ + protected function handleToolUseStart(array $contentBlock): null + { + if ($this->state->currentBlockType() !== null) { + $this->state->addToolCall($this->state->currentBlockIndex(), [ + 'id' => $contentBlock['id'] ?? EventID::generate(), + 'name' => $contentBlock['name'] ?? 'unknown', + 'input' => '', + ]); + } + + return null; + } + + 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'] + )); + } + + /** + * @param array $delta + */ + 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 $delta + */ + protected function handleCitationDelta(array $citation): ?CitationEvent + { + throw new \RuntimeException('Citations not yet supported in Bedrock Converse'); + } + + protected function handleReasoningContentDelta(array $reasoningContent): ?ThinkingEvent + { + throw new \RuntimeException('Reasoning content not yet supported in Bedrock Converse'); + } + + /** + * @param array $event + */ + 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 + { + $toolCall = $this->state->toolCalls()[$this->state->currentBlockIndex()]; + $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/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..6d3f094 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,62 @@ 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 0000000000000000000000000000000000000000..d2beba881583c281c6e8b6e8a335fb9c05fa7551 GIT binary patch literal 5045 zcmd6rU1(HC6o9vaziDbjMW4zrEmdKoN!09Ssg#)5W;O9=vq?0lb@txL?lpI3uJ_K} z>{W;2Lmny=MHC?l;)9YGYgHuvAgG9fKZrj?D`~Mlh~ks2FKM;(j7>EuS+m^;HxKi$ zyR*ah&DlNooD&EHP6q;k*sGnLcZ(u4LzyTQo<+w8K1@@`AxYXOh%JgE8fW6|hb6?a zOhY5W;B4MThqK~H|MvcGk9~dA7Hr`NCJ9ZaZON3evE58FZf&w1;buCr-sbAXHML8= z{HiXxbXk4F@)avr#Tw&H%`L0fw3d}u%wMpuGK7QJ<|b9AIF8{6!UUGdzPnL?J_)O( zZqokN!QfN+Q5TzBYpa=3>>3 zZ9dBw8WJ2OC`wV&YIDuJO>$)nERP?V{Q=nBSB7)C=N?A<#QMeUXZ$|vaEgcH;zqJvT(XvIq0rF@`W+*yqj-e=Y;B zQS<8|?+OQ&MF)3I1KiP)dyz?4(uL9-r8(51NZ7<+g%xmE_KfRn0k=D_TGnSj?(zAb zp+iCNu3!w`H(By z%|&&*Bfr=T*?VJYWz%~==7JKcUHzcRXCY`%8I)xIG_1(u?l5v#{xR`T4B(F`fpgm< zU49D)t`qNzoY+{dFgJ7o>UlU@8T}8Orppg=<5uz4T&J5`ZtmTE1)Q_nm`*C#0z$ zI#iGa-z^+q8=9jALnd_SO!FGxMfrkocwlb?e6AX+scix|7 zQ70M`q7pS45!7JtMN+G#^o8Ibcp#8~#t09Drp7;LqE&n_5s~=h_nmFMt8Ll?lIg=f z+|KRn?>FDf>^GmLX+xT(wO92xvF!5Y!iyrsZ+*U*mF` z$+(V1h2!xxJ%Pt7&GEmFKYc}^tA3IpCZSJRHcNCTot~7N=3XZ22O`Y%_T{(S-_*SJ zfd|*M#2#Az@P^h$9&Kyy=-k*9e~cK!_gse027wa@$_0^^f4}lxKs^Fie0b#PH%f7@ z83$}~Ju9`Axq@CWb8$~GZnHjOMhs$OpTz7Zg7pa?ivf~RrXP{R%N9ByFoqH@ytq?W zEU_xkxnnV1czT*Kq36A@e5;A5ZQ2}(*3{PBS>Hfpbm>j4^C(y#=vC>lL^*Oqo_?o+$8>nY^@;Qanx)x4MFTR`M9&tJzu>0{J-`S0xGs<8#e*oYw zFdR$A4xLpT4g)`8Jz-FO7D(ix9Zy~c8i!D2;rVyBDpt{Z3;FfjlUX7wHYPB&IEn_b zmi;%YHh_(pUAJPS27TqH%pi5-9Ed3r8`m^`2##gFgUjltl6VDIJ!;qRVDMMtcu6P z*_p^&zuEC4IK2c{*X#G>rW`t#7Z)cwgqOL*RgU6%4I~Le_Km=SNkUuaFT00I$GBp$ zLUa)r#V?#|fe6Y&0jRe#3Y8})#V~F^Awr)MdH8I$ThkWc1aIX>-%OMOFHSQ-ZF&)< zA?Uz&tN_bEE`@rSmEQnAqLG^V@@qi`39-J0eOc5>GSIPOJQIpeFU5GX7edj?tgESo z+S4@c@Y(eY=ev?DXNIg_u0JM|3)@j@{HfG=n?i0}k;3q*LMuAyGa J?;09+{sSa=@(ut1 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1d3548f343459b9a908eafe79c93181e6a5439da GIT binary patch literal 4909 zcmd6rU2GIp6vvk+Vkj7hga=~C)+Xhcn_5-~BRk{ALaMKO^FV?!X8L?9*jU}BU&h~df47|&gH+ugFeEfzC< zm}GZ$cJlk5bI<>ta|40Ep+F$e@>*a2*HtR*rxt&Vr)|0*uqZ`cmn3N`Ck|h%YRux3 zKQ1A*Z5kTk2D8@nxXfCt<{r=edw%v+8L&YGvPaWtcXF@M(`%+IX7@QR_xcA0)BB#< z_H?A~nP(vg4l}8I)O8J)6N`fo^P1m9vLkrK-??()R59Js9M3kBX}u9@a`K-M3*QvA zbUFxXbr5v3x+)a)r4;s?3S0aZW>`vua)%k18>W+V zuv~gTeDU_lC}Pi*T2`W~O|v8|h#@xo0;0~DQm`o1F}{lD&Sd%D_S4%-2t?Pxmlf3V z177hT>p!^U_lm(fP;%v!!HrKhWDeZQNdV!!`PL_>Vy@Kh<7Ih4Jim6$ zDnz|3gMQ@qkpaJhzMWz(Q@U}uV%UJoL4>LIy~j!9X6uG!k)x2)7OZUkc8 z`;EUM?XV=ix#H(>zlqCa0mS*2K0l3+BNFuJP~wKqpgO}=hj68vRqPo+tm+uvg3O;u zZm;LWb)UHvY}(2fX13)AvEZ-$k0P(M#rE&tTQnusOA1@@R?h5u+|y4PMnJ57@gwxK zdH2Z>K62q$V}%gTZVW0EmYXqZ?$#7=O2W~|!C?f462{Cn>N*{5XxUOebhYd37Lq?;pz`w=bQ;H0(Ux398Zw z*!q`OkRR|t#DZ7fKqbG(EG!%k+?qpKkdsR%i0M`2k0~l_&#xZ5C{1KPUhTWD{xQW! z{#Rcs^brvI9$qR-S%U2RX2iT!9Pg@$Go4v@YoUu~J&D;Zl;9aq&TqQ1qsrl?13M66 z46kN!{M^UIAP=S}CpwwMsSUosF*H{?ph@s#N=rB4mr$s7Qz_FxPEQ_Y$;Y$y5@Wp- j`(8C14g~>^7tnF?w>IJ(&NwmiH`dpLg=qNu`qqB|gXf*? literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0df9ab351c8d3fe233e9d9c9c5aa9faa8dce1619 GIT binary patch literal 14243 zcmd5@eQ;FO6<@{{BLRyYWq=v$^^6p$YsyDL!m6$03lj-6Ku8EwdD-_i`;y&vm-pUo zve0YGR2T!D+LlovqGd!yQ`-TNl7TXFUoIyb)FWN~ARulLt`|N4uGSFM5w_&{m!t6HEjs5do-!Um75 zFfBV8YiW(IoLyW}T2?;io{GwQ=T^;||E=l;3m4UZ7fc?~=npMRw`|3*L2R73#g90j z#;5j%br4L&dR;HN;t=;Ymuewf88+bLH&qy_)(XC7b zOJ=|#CXeZ=rm}{(138ZN15a#4oWCcmw{%t=lZ}-|xNkqnsm6dyr=OfB) zLOSE2Go`YT#(5NXreY_yXYZnMqRtd8K}@N6dndAZnV6K;9j}qiL^oKeW}93`E?iaO zOt-QT0g?0OwKpL6-9%t|_hFxG0xP1rogBu~!y=wkAYxT_f7eap9tmlRl{gtcJAg7G z?>%Ed6dr%34GFwOmECg}WE6+TFj9wyE z(WYIeC9`4`C{?mg2#C;gw~s`;ZV5({vu@ZdQ-N!$>mL`8$sQX0?)u#IvPXZQvF{U{ zAWGXdQy6`Nko(6MUM(4UVL{h+R;~n1Z77l7_+DlUpBAaK45wBd4BbiZ9!xQ0F z$Ao|wA9(Ulh_I7TZf~zUBN?UAz@r21*!W5icH|HV<6VUNo>80YCF5q(^BqH*E-gXq zJz23B0l!M1KWrUYCK+_s_5m@cCxjs8RIjDX?-gS4gU*^sl3B14EoA9YYYI~ayH`Pc z=iJ4=ApTzwhq8&E-X@uY%B-ki#PisqV0O6{je(fG=zS7wM+p0bx79|;*ar}pS-QH89a3cKvb?B8AWuiSvfZ6EXZvPq$+cwb=ZX7b>n>SV{IXF4D@jk$}I-5~;d?WwL0WP=SW&BI$xAT~_8vjl0pM^w6IJ-k{r z70dt>)@Y(9%Fr1a?s~eM@Pp{sac?!UbB*I@b7n;{k7n{DZh&$Jh%AG%+|vi!+cET+_ab;a4Z-!PS$+8909w=ZXg#osKikHp!Lrq z5pLR1L$}jbI*8mC51&Wmbb!^~@ADm$yc+VDV{DVqBz;lYi{~+NzTOE26ND$pG+~!4Au90rZ z`Mho!lWm5552hB@LPHM*)YSpvM)foqk#4DaP~w@#Dx4xC`5?^PqXHs2F?JI&`71uP zRq>TK%MRBlLnfo`5(z1Wp&N}?wiFYEH4x#uUMDldHQ~EgXsZd4I248;=6`S>C3t_P z>FWIW#7WuHg$mcg!_~`h-iE>?sY5EociKB@P6={5Mf@IWdyL8E7uFktHfzD!G|30d zy@{=PrA$HejN32*Il4tdQ?HNN-*!U*dh?wTMEiG|l~M0_{w;e}P&8P6Q`4|RhErRW zt@%^!L5JZskdK}y!GqXUT)Pb^x}~r|d&*>1Gj^o}Ode)6i~C)7AYOZ_j&cOoIEp5U zp{$u%<^$F` zAO-<)f&6+v_h(veSi)ShtOvnU*{Sxv*01cA9Sle@s4S#6qjkYEK^~|}7sP+|ufK?N z+|oe7nhoB<66JiPGz_;9#KG=%>hN+)ZiJRWUpMLH9=oh_1)?}Migx@lT4z7&>1>g_ z&OV2-+RVZt5U+0C_YqS1CDHm)$KTdTrp2(nsd74M67FB-IRWwT=rt!1@lHa$zf!*- z8#TtNM2^sJ7zXk9st`%#y#)CqAKW1uGPTHN9$v>K0ua}qtE8mt9fE%Sfsx~6LrhJCk=!3IzaroCZ8gzYs#8RAb_fOu=pJ^1Vq(`KP9c`7OhK{ zBFK75xrCCr2N*>C?y2VyyKA<`5PIHIjAe(|1+jEoSuPTD%bgp_cYABi-yGEL2l4&Y z<&PqR$M9MF;OHaIrADr?$@h^K+C1_(Uu^28D-h_rt4#OiStl5gHT)_=6 ze1WO^ExPR$!jggEk5}OrGl~jk4ba-1T!JFE1}Q}81|7XgHzHAc5pUM83Bgw|E#C|1 c10{WH+RP#^*xXh^eZMrbXu1$Dk2t^S|4Y&(d;kCd literal 0 HcmV?d00001 diff --git a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php index c4b6cde..daa993e 100644 --- a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php @@ -44,73 +44,6 @@ expect($response->structured['coat_required'])->toBeBool(); }); -it('uses custom jsonModeMessage when provided via providerOptions', function (): void { - FixtureResponse::fakeResponseSequence('invoke', 'anthropic/structured'); - - $schema = new ObjectSchema( - 'output', - 'the output object', - [ - new StringSchema('weather', 'The weather forecast'), - new StringSchema('game_time', 'The tigers game time'), - new BooleanSchema('coat_required', 'whether a coat is required'), - ], - ['weather', 'game_time', 'coat_required'] - ); - - $customMessage = 'Please return a JSON response using this custom format instruction'; - - Prism::structured() - ->withSchema($schema) - ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') - ->withProviderOptions([ - 'jsonModeMessage' => $customMessage, - ]) - ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') - ->withPrompt('What time is the tigers game today and should I wear a coat?') - ->asStructured(); - - Http::assertSent(function (Request $request) use ($customMessage): bool { - $messages = $request->data()['messages'] ?? []; - $lastMessage = end($messages); - - return isset($lastMessage['content'][0]['text']) && - str_contains((string) $lastMessage['content'][0]['text'], $customMessage); - }); -}); - -it('uses default jsonModeMessage when no custom message is provided', function (): void { - FixtureResponse::fakeResponseSequence('invoke', 'anthropic/structured'); - - $schema = new ObjectSchema( - 'output', - 'the output object', - [ - new StringSchema('weather', 'The weather forecast'), - new StringSchema('game_time', 'The tigers game time'), - new BooleanSchema('coat_required', 'whether a coat is required'), - ], - ['weather', 'game_time', 'coat_required'] - ); - - $defaultMessage = 'Respond with ONLY JSON (i.e. not in backticks or a code block, with NO CONTENT outside the JSON) that matches the following schema:'; - - Prism::structured() - ->withSchema($schema) - ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') - ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') - ->withPrompt('What time is the tigers game today and should I wear a coat?') - ->asStructured(); - - Http::assertSent(function (Request $request) use ($defaultMessage): bool { - $messages = $request->data()['messages'] ?? []; - $lastMessage = end($messages); - - return isset($lastMessage['content'][0]['text']) && - str_contains((string) $lastMessage['content'][0]['text'], $defaultMessage); - }); -}); - it('does not remove 0 values from payloads', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/structured'); @@ -135,7 +68,7 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ 'temperature' => 0, ])); }); diff --git a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php index e678916..66ffa76 100644 --- a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php @@ -203,7 +203,7 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ 'temperature' => 0, ])); }); diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php new file mode 100644 index 0000000..cab9f6e --- /dev/null +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -0,0 +1,164 @@ +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(); + + $toolCalls = []; + $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 $request->url() === 'https://bedrock-runtime.ap-southeast-2.amazonaws.com/model/'. + 'us.amazon.nova-micro-v1:0/converse-stream' + && isset($body['toolConfig']); + }); +}); diff --git a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php index ae3e9d5..c4ef76b 100644 --- a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php @@ -97,77 +97,6 @@ $fake->assertRequest(fn (array $requests): mixed => expect($requests[0]->providerOptions())->toBe($providerOptions)); }); -it('uses custom jsonModeMessage when provided via providerOptions', function (): void { - FixtureResponse::fakeResponseSequence('converse', 'converse/structured'); - - $schema = new ObjectSchema( - 'output', - 'the output object', - [ - new StringSchema('weather', 'The weather forecast'), - new StringSchema('game_time', 'The tigers game time'), - new BooleanSchema('coat_required', 'whether a coat is required'), - ], - ['weather', 'game_time', 'coat_required'] - ); - - $customMessage = 'Please return a JSON response using this custom format instruction'; - - Prism::structured() - ->withSchema($schema) - ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') - ->withProviderOptions([ - 'apiSchema' => BedrockSchema::Converse, - 'jsonModeMessage' => $customMessage, - ]) - ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') - ->withPrompt('What time is the tigers game today and should I wear a coat?') - ->asStructured(); - - Http::assertSent(function (Request $request) use ($customMessage): bool { - $messages = $request->data()['messages'] ?? []; - $lastMessage = end($messages); - - return isset($lastMessage['content'][0]['text']) && - str_contains((string) $lastMessage['content'][0]['text'], $customMessage); - }); -}); - -it('uses default jsonModeMessage when no custom message is provided', function (): void { - FixtureResponse::fakeResponseSequence('converse', 'converse/structured'); - - $schema = new ObjectSchema( - 'output', - 'the output object', - [ - new StringSchema('weather', 'The weather forecast'), - new StringSchema('game_time', 'The tigers game time'), - new BooleanSchema('coat_required', 'whether a coat is required'), - ], - ['weather', 'game_time', 'coat_required'] - ); - - $defaultMessage = 'Respond with ONLY JSON (i.e. not in backticks or a code block, with NO CONTENT outside the JSON) that matches the following schema:'; - - Prism::structured() - ->withSchema($schema) - ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') - ->withProviderOptions([ - 'apiSchema' => BedrockSchema::Converse, - ]) - ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') - ->withPrompt('What time is the tigers game today and should I wear a coat?') - ->asStructured(); - - Http::assertSent(function (Request $request) use ($defaultMessage): bool { - $messages = $request->data()['messages'] ?? []; - $lastMessage = end($messages); - - return isset($lastMessage['content'][0]['text']) && - str_contains((string) $lastMessage['content'][0]['text'], $defaultMessage); - }); -}); - it('does not remove 0 values from payloads', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/structured'); @@ -193,7 +122,7 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ 'inferenceConfig' => [ 'maxTokens' => 2048, 'temperature' => 0, diff --git a/tests/Schemas/Converse/ConverseTextHandlerTest.php b/tests/Schemas/Converse/ConverseTextHandlerTest.php index b2b7f1f..55de33b 100644 --- a/tests/Schemas/Converse/ConverseTextHandlerTest.php +++ b/tests/Schemas/Converse/ConverseTextHandlerTest.php @@ -283,7 +283,7 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation|\Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ 'inferenceConfig' => [ 'temperature' => 0, 'maxTokens' => 2048, diff --git a/tests/TestCase.php b/tests/TestCase.php index 7a19656..f5969a3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,7 +21,9 @@ protected function defineEnvironment($app) $config->set('prism.providers.bedrock', [ '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'), + 'region' => env('PRISM_BEDROCK_REGION', 'ap-southeast-2'), + 'session_token' => env('PRISM_BEDROCK_SESSION_TOKEN', null), + 'use_default_credential_provider' => env('PRISM_BEDROCK_USE_DEFAULT_CREDENTIAL_PROVIDER', false), ]); }); } From 4661f1cbea742ec556ddd37af8e97ee41fe4340f Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Wed, 15 Oct 2025 16:25:13 +1000 Subject: [PATCH 02/15] Added thinking events --- composer.json | 2 +- .../Converse/ConverseStreamHandler.php | 33 +++++++++----- .../Fixtures/converse/stream-thinking-1-1.sse | Bin 0 -> 8977 bytes .../Converse/ConverseStreamHandlerTest.php | 42 +++++++++++++++++- tests/TestCase.php | 2 +- 5 files changed, 65 insertions(+), 14 deletions(-) create mode 100644 tests/Fixtures/converse/stream-thinking-1-1.sse diff --git a/composer.json b/composer.json index 2e87153..31d7aad 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/Schemas/Converse/ConverseStreamHandler.php b/src/Schemas/Converse/ConverseStreamHandler.php index 6838861..c50fc71 100644 --- a/src/Schemas/Converse/ConverseStreamHandler.php +++ b/src/Schemas/Converse/ConverseStreamHandler.php @@ -69,16 +69,12 @@ public static function buildPayload(Request $request, int $stepCount = 0): array protected function sendRequest(Request $request): Response { - try { - return $this->client - ->withOptions(['stream' => true]) - ->post( - 'converse-stream', - static::buildPayload($request) - ); - } catch (RequestException|Throwable $e) { - throw PrismException::providerRequestError($request->model(), $e); - } + return $this->client + ->withOptions(['stream' => true]) + ->post( + 'converse-stream', + static::buildPayload($request) + ); } protected function processStream(Response $response, Request $request, int $depth = 0) @@ -379,7 +375,22 @@ protected function handleCitationDelta(array $citation): ?CitationEvent protected function handleReasoningContentDelta(array $reasoningContent): ?ThinkingEvent { - throw new \RuntimeException('Reasoning content not yet supported in Bedrock Converse'); + $thinking = $reasoningContent['text'] ?? ''; + + if ($thinking === '') { + return null; + } + + $this->state->appendThinking($thinking); + + $this->state->withReasoningId(EventID::generate()); + + return new ThinkingEvent( + id: EventID::generate(), + timestamp: time(), + delta: $thinking, + reasoningId: $this->state->reasoningId() + ); } /** diff --git a/tests/Fixtures/converse/stream-thinking-1-1.sse b/tests/Fixtures/converse/stream-thinking-1-1.sse new file mode 100644 index 0000000000000000000000000000000000000000..82d98e0edce487fc03bbc8c85bb3f6dd7ff84427 GIT binary patch literal 8977 zcmd6tdu&_P9mg{sr4v-^)@e$mX*wKfD=caKiW?_eHT;h4*iK?QjtvqzzW3Vq+V_=v zj~_Sm3ij@pKmt^RVA>F!DjJDt48aB)qN+5yt&B~3wS@sgTUkZ5!Pb?P{W0x#lC+7F zG-+VS{=W(REqk2$eL|Q8hJn%kaXVFMRvzxmVqWbv%qoiQ|QGMU<*FSy458 z+AwLof%~zc$pZVmX-Xy~D#i94(k2z;`~p6C&%QIEc1a&y5D}0yt`-txO5U@srEiL< zywJkK&VJ0ZcjDpOuptnDlGI9Y9-BR*LW_dhtSBI{t`HU5cK@aLuQbNE{LotYIS*i;^i?N8=uHzbCdb0Lec4Rdj`DNevW4M!*D#$ zhUA^rrf?Sy36CmLLs)|r+drE9(g`5=C!=`eWbc(-NP!6AWPup{NYqq_Eh4Jf6pZ=! zt^vo^))ncA?dGFH$`$8X}Rts-93oy)93E}7oeVDxWBsbZ*O-a zE?aQX&W(mLm2S&QY+G9ne;+Wk&m-N4N>tpE3dKFhjF^XHRt@+79`W zbsXD|C*S=%Xz%9s8I=UYONPL)g2p3JkoEG4)&$!xe*KkwK+?@=f}}OJ0NCCdd-og= z{DsY>q&0JW7tSTtB^rXMYpS_=vag*n*nV@{qflY5_#s=&Qz6aKC*_i6$TwY>^u?==SX7wtdW$}e zkB?Qf3=Is)lOy@;WGj&jlv_D*U?i7mX|WkynsP^mG;TC9=1V5Mq?pb6vW`^N9F$T? z8VV?q+v_$4C5I8I^hHJ2fMgDgrMYai9JNNrtA&(a(4Fc`C1iT3WlS~89$Js81Kv_j z7u{2%+1;)(Kja@MDv}ZjaD0dl)O@jQD(?vzW4VMomugCNS9~ftEyWsHVK7*%5p_2| zQsJ_aQ7@Bv+Fc%R&Wub%g1)fA(;=@jN8I^LNGOz29*07Th9jjCPrw+T(2~VwgT{(6 zBit06K@w9nztc15QF3Wts!|Qb9Nu_X$QO---<5V1%dLrYpcp6@vVpNkDiWRvH;3F& ze|S%O#lZGoZ@>3;nAdExGx^^azWlTHxxIC%fsmn^{(p8NR`pG=ebX}HEYOwWK8yVYQ{7JYDRL?T9*39vT zI!-p{axhQwf};g9mSrG3T1Er@oO;v4Ap8j?{%BnKPp8Gt$3rEu1&;PIwR842wvXKH zVJpI8OmgpoH*N2<vf##uwEW<8ozSgTXvAP zJP_Fa#VKyRD7dWXbad6lN zt?pdMw4_LGVExAS-A{g(6{MfCAU=L#@BU5)(bDSkoi5dQVtwIqupq;p>t&VoF8EA- z@0MG?*$(l6Zr>qfZQFNjA3Jbx41};+IR4h@KObusbM3{O7VpDGX#0;BFIvDQ3lpaQ z#N2fTFv0|tzZE<&$nWD%KC=Ukwm%M%QnNGT%lgpGFs%qr-p2(Cc!9yP4lYi}aMYu6 zRuX;~a1VLb*^;cdSg^QmE*wI``CdP+v%4wm?UJh3X;!NVD!kt_)Q{o#d&v_Qdt?d* eEN@v&lXDAS0})iCM85Dv*Pzd~_ul#8tN#aat|1cu literal 0 HcmV?d00001 diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index cab9f6e..ee2b71b 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -9,10 +9,13 @@ use NumberFormatter; use Prism\Bedrock\Enums\BedrockSchema; use Prism\Prism\Enums\FinishReason; +use Prism\Prism\Enums\StreamEventType; use Prism\Prism\Facades\Tool; use Prism\Prism\Prism; use Prism\Prism\Streaming\Events\StreamEndEvent; +use Prism\Prism\Streaming\Events\StreamEvent; use Prism\Prism\Streaming\Events\TextDeltaEvent; +use Prism\Prism\Streaming\Events\ThinkingEvent; use Prism\Prism\Streaming\Events\ToolCallEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; use Prism\Prism\ValueObjects\Messages\AssistantMessage; @@ -123,7 +126,7 @@ ->withTools($tools) ->asStream(); - $toolCalls = []; + $toolCallFound = false; $toolResults = []; $events = []; $text = ''; @@ -162,3 +165,40 @@ && isset($body['toolConfig']); }); }); + +it('can handle thinking', function (): void { + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-thinking-1'); + + $response = Prism::text() + ->using('bedrock', 'apac.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); + + $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(5); + + expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); + +})->only(); diff --git a/tests/TestCase.php b/tests/TestCase.php index f5969a3..419abad 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,7 +21,7 @@ protected function defineEnvironment($app) $config->set('prism.providers.bedrock', [ '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', 'ap-southeast-2'), + '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), ]); From 75d723cabf54dcb19ea6b1e2bda5793cac79bf67 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Thu, 16 Oct 2025 15:03:32 +1000 Subject: [PATCH 03/15] add thinkg start/end test --- tests/Schemas/Converse/ConverseStreamHandlerTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index ee2b71b..f500792 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -16,6 +16,7 @@ use Prism\Prism\Streaming\Events\StreamEvent; use Prism\Prism\Streaming\Events\TextDeltaEvent; use Prism\Prism\Streaming\Events\ThinkingEvent; +use Prism\Prism\Streaming\Events\ThinkingStartEvent; use Prism\Prism\Streaming\Events\ToolCallEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; use Prism\Prism\ValueObjects\Messages\AssistantMessage; @@ -188,6 +189,9 @@ $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 ); @@ -201,4 +205,6 @@ expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); + expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingComplete)->sole()) + ->toBeInstanceOf(ThinkingCompleteEvent::class); })->only(); From 9d26af9a52c6014a9b6b7dcc7162bea6cc4a4b07 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 08:32:57 +1000 Subject: [PATCH 04/15] add reasoning start and complete events --- .../Converse/ConverseStreamHandler.php | 39 ++++++++++++++----- .../Converse/ConverseStreamHandlerTest.php | 3 +- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/Schemas/Converse/ConverseStreamHandler.php b/src/Schemas/Converse/ConverseStreamHandler.php index c50fc71..5385df5 100644 --- a/src/Schemas/Converse/ConverseStreamHandler.php +++ b/src/Schemas/Converse/ConverseStreamHandler.php @@ -6,7 +6,6 @@ use Aws\Api\Parser\NonSeekableStreamDecodingEventStreamIterator; use Generator; use Illuminate\Http\Client\PendingRequest; -use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; use Prism\Bedrock\Bedrock; use Prism\Bedrock\Schemas\Converse\Maps\FinishReasonMap; @@ -22,7 +21,9 @@ use Prism\Prism\Streaming\Events\TextCompleteEvent; use Prism\Prism\Streaming\Events\TextDeltaEvent; use Prism\Prism\Streaming\Events\TextStartEvent; +use Prism\Prism\Streaming\Events\ThinkingCompleteEvent; use Prism\Prism\Streaming\Events\ThinkingEvent; +use Prism\Prism\Streaming\Events\ThinkingStartEvent; use Prism\Prism\Streaming\Events\ToolCallEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; use Prism\Prism\Text\Request; @@ -93,9 +94,12 @@ protected function processStream(Response $response, Request $request, int $dept } foreach ($decoder as $event) { - $event = $this->processEvent($event); - if ($event instanceof \Prism\Prism\Streaming\Events\StreamEvent) { - yield $event; + $streamEvent = $this->processEvent($event); + + if ($streamEvent instanceof Generator) { + yield from $streamEvent; + } elseif ($streamEvent instanceof StreamEvent) { + yield $streamEvent; } } @@ -209,7 +213,7 @@ protected function isValidJson(string $string): bool } } - protected function processEvent(array $event): ?StreamEvent + protected function processEvent(array $event): null|StreamEvent|Generator { $json = json_decode((string) $event['payload'], true); @@ -254,7 +258,7 @@ protected function handleContentBlockStart(array $event): ?StreamEvent ); } - protected function handleContentBlockDelta(array $event): ?StreamEvent + protected function handleContentBlockDelta(array $event): null|StreamEvent|Generator { $this->state->withBlockIndex($event['contentBlockIndex']); $delta = $event['delta']; @@ -277,6 +281,11 @@ protected function handleContentBlockStop(array $event): ?StreamEvent messageId: $this->state->messageId() ), 'tool_use' => $this->handleToolUseComplete(), + 'thinking' => new ThinkingCompleteEvent( + id: EventID::generate(), + timestamp: time(), + reasoningId: $this->state->reasoningId() + ), default => null, }; @@ -373,7 +382,7 @@ protected function handleCitationDelta(array $citation): ?CitationEvent throw new \RuntimeException('Citations not yet supported in Bedrock Converse'); } - protected function handleReasoningContentDelta(array $reasoningContent): ?ThinkingEvent + protected function handleReasoningContentDelta(array $reasoningContent): Generator { $thinking = $reasoningContent['text'] ?? ''; @@ -381,11 +390,21 @@ protected function handleReasoningContentDelta(array $reasoningContent): ?Thinki return null; } - $this->state->appendThinking($thinking); + $this->state->withBlockType('thinking'); + + if ($this->state->reasoningId() === '' || $this->state->reasoningId() === '0') { + $this->state->withReasoningId(EventID::generate()); - $this->state->withReasoningId(EventID::generate()); + yield new ThinkingStartEvent( + id: EventID::generate(), + timestamp: time(), + reasoningId: $this->state->reasoningId() + ); + } + + $this->state->appendThinking($thinking); - return new ThinkingEvent( + yield new ThinkingEvent( id: EventID::generate(), timestamp: time(), delta: $thinking, diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index f500792..42e7786 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -15,6 +15,7 @@ use Prism\Prism\Streaming\Events\StreamEndEvent; use Prism\Prism\Streaming\Events\StreamEvent; use Prism\Prism\Streaming\Events\TextDeltaEvent; +use Prism\Prism\Streaming\Events\ThinkingCompleteEvent; use Prism\Prism\Streaming\Events\ThinkingEvent; use Prism\Prism\Streaming\Events\ThinkingStartEvent; use Prism\Prism\Streaming\Events\ToolCallEvent; @@ -207,4 +208,4 @@ expect($events->where(fn ($event): bool => $event->type() === StreamEventType::ThinkingComplete)->sole()) ->toBeInstanceOf(ThinkingCompleteEvent::class); -})->only(); +}); From 057db9c66858f72928c02150555ce8d9508ab551 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 10:50:42 +1000 Subject: [PATCH 05/15] Can receive citation data --- .../Converse/ConverseStreamHandler.php | 27 +++++- src/Schemas/Converse/Maps/CitationsMapper.php | 45 ++++++++++ src/Schemas/Converse/Maps/DocumentMapper.php | 4 +- src/Schemas/Converse/Maps/MessageMap.php | 9 +- ...thinking-1-1.sse => stream-thinking-1.sse} | Bin .../converse/stream-with-citations-1.sse | Bin 0 -> 6997 bytes .../Converse/ConverseStreamHandlerTest.php | 62 ++++++++++++- .../Converse/Maps/CitationsMapperTest.php | 83 ++++++++++++++++++ .../Schemas/Converse/Maps/MessageMapTest.php | 30 +++++++ tests/TestCase.php | 2 +- 10 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 src/Schemas/Converse/Maps/CitationsMapper.php rename tests/Fixtures/converse/{stream-thinking-1-1.sse => stream-thinking-1.sse} (100%) create mode 100644 tests/Fixtures/converse/stream-with-citations-1.sse create mode 100644 tests/Schemas/Converse/Maps/CitationsMapperTest.php diff --git a/src/Schemas/Converse/ConverseStreamHandler.php b/src/Schemas/Converse/ConverseStreamHandler.php index 5385df5..7cbb8cc 100644 --- a/src/Schemas/Converse/ConverseStreamHandler.php +++ b/src/Schemas/Converse/ConverseStreamHandler.php @@ -8,6 +8,7 @@ use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; use Prism\Bedrock\Bedrock; +use Prism\Bedrock\Schemas\Converse\Maps\CitationsMapper; use Prism\Bedrock\Schemas\Converse\Maps\FinishReasonMap; use Prism\Bedrock\ValueObjects\ConverseStreamState; use Prism\Prism\Concerns\CallsTools; @@ -27,6 +28,7 @@ use Prism\Prism\Streaming\Events\ToolCallEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; use Prism\Prism\Text\Request; +use Prism\Prism\ValueObjects\MessagePartWithCitations; use Prism\Prism\ValueObjects\Messages\AssistantMessage; use Prism\Prism\ValueObjects\Messages\ToolResultMessage; use Prism\Prism\ValueObjects\ToolCall; @@ -323,7 +325,8 @@ protected function handleMetadata(array $event): StreamEndEvent 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 ); } @@ -377,9 +380,27 @@ protected function handleTextDelta(string $text): ?TextDeltaEvent /** * @param array $delta */ - protected function handleCitationDelta(array $citation): ?CitationEvent + protected function handleCitationDelta(array $citationData): CitationEvent { - throw new \RuntimeException('Citations not yet supported in Bedrock Converse'); + // 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() + ); } protected function handleReasoningContentDelta(array $reasoningContent): Generator diff --git a/src/Schemas/Converse/Maps/CitationsMapper.php b/src/Schemas/Converse/Maps/CitationsMapper.php new file mode 100644 index 0000000..86bb7f8 --- /dev/null +++ b/src/Schemas/Converse/Maps/CitationsMapper.php @@ -0,0 +1,45 @@ + $sourceContent['text'] ?? '', + $citationData + )); + } + + 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, + }; + } +} diff --git a/src/Schemas/Converse/Maps/DocumentMapper.php b/src/Schemas/Converse/Maps/DocumentMapper.php index a19d790..9ae01d2 100644 --- a/src/Schemas/Converse/Maps/DocumentMapper.php +++ b/src/Schemas/Converse/Maps/DocumentMapper.php @@ -16,7 +16,8 @@ class DocumentMapper extends ProviderMediaMapper */ public function __construct( public readonly Media $media, - public ?array $cacheControl = null + public ?array $cacheControl = null, + public ?array $citationsConfig = null, ) {} /** @@ -29,6 +30,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..b48dad2 100644 --- a/src/Schemas/Converse/Maps/MessageMap.php +++ b/src/Schemas/Converse/Maps/MessageMap.php @@ -110,7 +110,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, ]), ]; @@ -164,10 +164,13 @@ protected static function mapImageParts(array $parts): array * @param Document[] $parts * @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/tests/Fixtures/converse/stream-thinking-1-1.sse b/tests/Fixtures/converse/stream-thinking-1.sse similarity index 100% rename from tests/Fixtures/converse/stream-thinking-1-1.sse rename to tests/Fixtures/converse/stream-thinking-1.sse 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 0000000000000000000000000000000000000000..2e1a7723445915121ee123363e95e49a2c0bac7f GIT binary patch literal 6997 zcmd6sZD<@t7{{-oO-wCT@ueRsEJLLzmzTR-o3J97^fYfx(wW_jiO9+ zdi~2yQaG%svP4y)7j~FL-zd)i`~1_l#fR!eVI(5yfUMv^dr@X*x;^y`jlQPlme##b7P;KT$cYT1VLpOsswS0mir6#VQ6C7t z3|GrTeTAo!Mc*|qvs@!`rx$BfTD9`UBUR}N?m=Ra6DfR5B=#bTdnhO?lVxHks@{&6 zGYeS6B7bao1(?P;m%Opb*&#f$u`tAl<5hGsZ;hrPqvlpn01U?GwfG zHWMjMVR`Y09gMEa2c;jt)ja!e*;umLO^atkBPHSgd0MSHOb|Qj@P6rVtmBiXrL?8^ zM6&R;4G7nwfRI%hPX|0WAo>!2FU+7lkW2V?3-{M!3hN3I-A)7%Za_@A7=)5ZEJMaS z=aLQ*+Y$ligGnjrW&|4xQ`FL^8j>g$x-4w+gd~uFps4}ujL%?8SG%!c!kwfm0;DA) zN`nxvf@%tWJlhewdan9a;2eOfxxaR= zmdv`Qi2=fEWz#Ocej;{wp9Z8d>1i{kkuxn%UZcSl8g8 zUZ9%bWcQ4&zt3heooVq9)=R8*P0HLp z#K!LxY5?VPjx(HFd&F*>GDQlR+k}7=B91+iaP?KKelD;Jz|{=gx-1^W?SRV!602ZL%SHny2Ug|(91`N*nP3nm{{V2nEwp1O2Se_Mq5#T=KR(e|}o9wnS5`?^vIT2#z z7xS)w_#qC^A8Wm8H$Z0j8nLlsViRb-#C5;<*HFZ6-KG`BM&>{KG9@)Tb)^4(K>Czp zMN4OXuo+7xp|C|^Gp|b_tO#C=!-6je>rIdfvZTo=z=(Y~72|c<%@Plm29<{ig+_+E z8z>CKNACuJQ`~^qg-fG08z7{v>xe~_=lBNeJ+5%};m4}&R+v`1q)m#MV)ZPj<;8o; zvDvORyA`LGr1>eDWzNgKgb&S{pXSeXZ(zhJu#L=1e?S`@RPMt(_LDg4p(OfG$ULy`v-AMrL zW-06A-8GXEBbp^0VrQn#yai;ZxpAbrE7xY@pdpyvc|q*^zMps}=y|xB+rIg`CRyd0 zc7h=I6P+N$)*GH%K*Vvl>W`O>PbLf5kT|%>qD`H6Tl%>T9Ls>~;RYKzd zwG5c!W2Gk_gjV#eplkPq*lj3$7C+qLL#aCKKz*bWL)sDDE)R6wh(#BD<|=}NAE>pVb?hTVYnp!$-bo3Sw8W!s M545f)K03GgKR0=2TmS$7 literal 0 HcmV?d00001 diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index 42e7786..9ab43b2 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -5,6 +5,7 @@ namespace Tests\Schemas\Converse; use Illuminate\Http\Client\Request; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use NumberFormatter; use Prism\Bedrock\Enums\BedrockSchema; @@ -12,6 +13,7 @@ use Prism\Prism\Enums\StreamEventType; use Prism\Prism\Facades\Tool; use Prism\Prism\Prism; +use Prism\Prism\Streaming\Events\CitationEvent; use Prism\Prism\Streaming\Events\StreamEndEvent; use Prism\Prism\Streaming\Events\StreamEvent; use Prism\Prism\Streaming\Events\TextDeltaEvent; @@ -20,6 +22,9 @@ use Prism\Prism\Streaming\Events\ThinkingStartEvent; use Prism\Prism\Streaming\Events\ToolCallEvent; use Prism\Prism\Streaming\Events\ToolResultEvent; +use Prism\Prism\ValueObjects\Citation; +use Prism\Prism\ValueObjects\Media\Document; +use Prism\Prism\ValueObjects\MessagePartWithCitations; use Prism\Prism\ValueObjects\Messages\AssistantMessage; use Prism\Prism\ValueObjects\Messages\SystemMessage; use Prism\Prism\ValueObjects\Messages\UserMessage; @@ -169,7 +174,7 @@ }); it('can handle thinking', function (): void { - FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-thinking-1'); + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-thinking'); $response = Prism::text() ->using('bedrock', 'apac.anthropic.claude-sonnet-4-20250514-v1:0') @@ -209,3 +214,58 @@ 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 { + RequestException::dontTruncate(); + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-citations'); + + $response = Prism::text() + ->using('bedrock', 'apac.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); + }); +}); diff --git a/tests/Schemas/Converse/Maps/CitationsMapperTest.php b/tests/Schemas/Converse/Maps/CitationsMapperTest.php new file mode 100644 index 0000000..39dec91 --- /dev/null +++ b/tests/Schemas/Converse/Maps/CitationsMapperTest.php @@ -0,0 +1,83 @@ + [ + '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); +}); 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 419abad..a67700b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -23,7 +23,7 @@ protected function defineEnvironment($app) '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), + 'use_default_credential_provider' => env('PRISM_BEDROCK_USE_DEFAULT_CREDENTIAL_PROVIDER', true), ]); }); } From 4614fc78f34fae58a346793a679945a1481bca9f Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 11:44:50 +1000 Subject: [PATCH 06/15] map citations to converse request --- src/Schemas/Converse/Maps/CitationsMapper.php | 59 ++++- src/Schemas/Converse/Maps/MessageMap.php | 10 + .../Converse/Maps/CitationsMapperTest.php | 223 ++++++++++++------ 3 files changed, 224 insertions(+), 68 deletions(-) diff --git a/src/Schemas/Converse/Maps/CitationsMapper.php b/src/Schemas/Converse/Maps/CitationsMapper.php index 86bb7f8..dfcdd19 100644 --- a/src/Schemas/Converse/Maps/CitationsMapper.php +++ b/src/Schemas/Converse/Maps/CitationsMapper.php @@ -5,9 +5,27 @@ use Prism\Prism\Enums\Citations\CitationSourcePositionType; use Prism\Prism\Enums\Citations\CitationSourceType; use Prism\Prism\ValueObjects\Citation; +use Prism\Prism\ValueObjects\MessagePartWithCitations; class CitationsMapper { + 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: $contentBlock['text'] ?? '', + citations: $citations, + ); + } + public static function mapCitationFromConverse(array $citationData): Citation { $location = $citationData['location'] ?? []; @@ -16,7 +34,7 @@ public static function mapCitationFromConverse(array $citationData): Citation return new Citation( sourceType: CitationSourceType::Document, - source: $indices['documentIndex'] ?? null, + source: $indices['documentIndex'] ?? 0, sourceText: self::mapSourceText($citationData['sourceContent'] ?? []), sourceTitle: $citationData['title'] ?? '', sourcePositionType: self::mapSourcePositionType($location), @@ -24,6 +42,19 @@ public static function mapCitationFromConverse(array $citationData): Citation sourceEndIndex: $indices['end'] ?? null, ); } + 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), + ], + ]; + } protected static function mapSourceText(array $citationData): ?string { @@ -42,4 +73,30 @@ protected static function mapSourcePositionType(array $location): ?CitationSourc default => null, }; } + + private static function mapCitationToConverse(Citation $citation): array + { + $locationKey = match ($citation->sourcePositionType) { + CitationSourcePositionType::Character => 'documentChar', + CitationSourcePositionType::Chunk => 'documentChunk', + CitationSourcePositionType::Page => 'documentPage', + default => null, + }; + + $location = $locationKey ? [ + $locationKey => array_filter([ + '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/MessageMap.php b/src/Schemas/Converse/Maps/MessageMap.php index b48dad2..a1ed8bd 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; @@ -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,14 @@ protected static function mapToolCalls(array $parts): array ], $parts); } + protected static function mapCitations(array $parts): array + { + return array_map( + fn (MessagePartWithCitations $part): array => CitationsMapper::mapToConverse($part), + $parts + ); + } + /** * @param Image[] $parts * @return array diff --git a/tests/Schemas/Converse/Maps/CitationsMapperTest.php b/tests/Schemas/Converse/Maps/CitationsMapperTest.php index 39dec91..d057266 100644 --- a/tests/Schemas/Converse/Maps/CitationsMapperTest.php +++ b/tests/Schemas/Converse/Maps/CitationsMapperTest.php @@ -4,80 +4,169 @@ use Prism\Prism\Enums\Citations\CitationSourcePositionType; use Prism\Prism\Enums\Citations\CitationSourceType; -it('can map citations from converse api', function (): void { - $citation = CitationsMapper::mapCitationFromConverse([ - 'location' => [ - 'documentPage' => [ - 'documentIndex' => 3, - 'end' => 17, - 'start' => 5, +describe('from converse api', function (): void { + it('can map citations from converse api', function (): void { + $citation = CitationsMapper::mapCitationFromConverse([ + 'location' => [ + 'documentPage' => [ + 'documentIndex' => 3, + 'end' => 17, + 'start' => 5, + ], ], - ], - 'sourceContent' => [ - [ - 'text' => 'The answer to the ultimate question of life, the universe, and everything is "42".', + '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); -}); + '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 character location', function (): void { - $citation = CitationsMapper::mapCitationFromConverse([ - 'location' => [ - 'documentChar' => [ - 'documentIndex' => 7, - 'end' => 42, - 'start' => 13, + 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".', + '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); + '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); + }); }); -it('can map citations with chunk location', function (): void { - $citation = CitationsMapper::mapCitationFromConverse([ - 'location' => [ - 'documentChunk' => [ - 'documentIndex' => 2, - 'end' => 99, - 'start' => 77, +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', + ], + ], + ], + ]; + + $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', + ], + ], ], - ], - 'sourceContent' => [ - [ - 'text' => 'The answer to the ultimate question of life, the universe, and everything 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', + ]], ], - ], - '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); + ]; + + $messagePartWithCitations = CitationsMapper::mapFromConverse($originalConverseData); + $roundTripResult = CitationsMapper::mapToConverse($messagePartWithCitations); + + expect($roundTripResult)->toBe($originalConverseData); + }); }); From a9b62147808041af6ac2f14a67e5e216e5e404b7 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 14:05:25 +1000 Subject: [PATCH 07/15] Send citation data to model --- src/Schemas/Converse/Maps/CitationsMapper.php | 10 ++- tests/Fixtures/FixtureResponse.php | 1 + .../stream-with-previous-citations-1.sse | Bin 0 -> 7959 bytes .../Converse/ConverseStreamHandlerTest.php | 79 ++++++++++++++++++ .../Converse/Maps/CitationsMapperTest.php | 9 ++ 5 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/Fixtures/converse/stream-with-previous-citations-1.sse diff --git a/src/Schemas/Converse/Maps/CitationsMapper.php b/src/Schemas/Converse/Maps/CitationsMapper.php index dfcdd19..28d6134 100644 --- a/src/Schemas/Converse/Maps/CitationsMapper.php +++ b/src/Schemas/Converse/Maps/CitationsMapper.php @@ -21,7 +21,7 @@ public static function mapFromConverse(array $contentBlock): ?MessagePartWithCit ); return new MessagePartWithCitations( - outputText: $contentBlock['text'] ?? '', + outputText: implode('', array_map(fn (array $text) => $text['text'] ?? '', $contentBlock['citationsContent']['content'] ?? [])), citations: $citations, ); } @@ -42,6 +42,7 @@ public static function mapCitationFromConverse(array $citationData): Citation sourceEndIndex: $indices['end'] ?? null, ); } + public static function mapToConverse(MessagePartWithCitations $part): array { $citations = array_map( @@ -52,6 +53,9 @@ public static function mapToConverse(MessagePartWithCitations $part): array return [ 'citationsContent' => [ 'citations' => array_filter($citations), + 'content' => [ + ['text' => $part->outputText], + ], ], ]; } @@ -84,11 +88,11 @@ private static function mapCitationToConverse(Citation $citation): array }; $location = $locationKey ? [ - $locationKey => array_filter([ + $locationKey => [ 'documentIndex' => $citation->source, 'start' => $citation->sourceStartIndex, 'end' => $citation->sourceEndIndex, - ]), + ], ] : []; return array_filter([ diff --git a/tests/Fixtures/FixtureResponse.php b/tests/Fixtures/FixtureResponse.php index 6d3f094..c333bf3 100644 --- a/tests/Fixtures/FixtureResponse.php +++ b/tests/Fixtures/FixtureResponse.php @@ -151,6 +151,7 @@ protected static function recordStreamResponses(string $requestPath, string $nam ]; $response = $client->request($request->method(), $request->url(), $options); + $stream = $response->getBody(); // Open file for writing 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 0000000000000000000000000000000000000000..117ce859572d2e5bb0c84ced2c10d6badaca17b4 GIT binary patch literal 7959 zcmd5>ZEO@p7(O*9ACdqn5kxaiG$kH=HicxIbEAV3=^6Qe~UN^kPq9HF*NYH7J3mKYe&I1{Zm2a#yh zG&CX%&K_K7b2c#0{O`?A_es8LF^=K@CN-K)L$zUJVVxOaJX&wr!fA-b6N?HaO)i}B zXi;&=)W@bxFP$-SR#|zlqOxlCoVjCs{@lE=M-{@nT)Q69*P!LE{3Dm0;=ZI!`RG jX zRQ4)5=i)a`ahVR%2tzidC}iF{`ae5fXJQFU--?6tJ)Y}cRR;Ot$e#x? zvG#0~_|l?8GelA*RK!3?rv7d*&P>o4b+6^RHP9&2alr4z5cU=XnomNU+1#--I=M-V zaA0043WH#8!t5Beklny!cOMG4 zWus;#Q@hr+J?v&|VR`b>E4RTeoKidS?x|fXT;7RxeWIHrljoUy>&`KiKys%d`uW@e z%Uvd_{5&gOSe~7+wHQ#p0ju)93H=vjBJWZ013l?noMBnCF8&%I{YdR|e9_T0nP_`f z{y?+ZCJA0t;|S!i5VwTGK$XmdQlWOY9?N6HijD&CEeiaWoooBK48HXsZIkO@Ib>$# z7l87BLi*I^{Gi)N!=wQ;Ow%BDRp)@tsm(?T6t#GRp|`)?Eg316!|!NZ1w?izGS6SA zpXW9i!V)OtSl}mCyN=R~97}fW@{a&o`ApT;^V?%?1E=w*Nx;jKhpvT^c~M*-Qo|t# zV&OzbY*Lj0mi3EDR|2Ik6|KE}r$6I1EyxdSNDs0?-?py7a>=b1&H$5M<6UW|J@3H3 zu&nuQ!X*Iywlc%Msm2Aj%@9-BydhA8Lb8%m&8k@t43lOREOVdSMFH-23Nq_k(cm^@ zD3>X*(lK_iBF6G*&!~E!by`uA{d_;UO$}7G1|%0*G(;`RG^%vBkUO;|=_X$+12s!O z0G?`9{T;v$yUn#gClPxj3XuqRIwXf>pP^+#0QO-8{qgu+@4F4%){LZaLWY)aM0?QX}`Gy0-J&NTG=hrQ9nPr||71I{f z)&xrEEQ)0;e?9l?bAaBJHc~rjyN(FtkykB?v*C0_hZD=4>(_1olDn0e3TJ;?;j)=h z4H9p5e6VcnKjI|-|FI(Q$m(Td-6o)FtE81Q;Se(m7FcfBl+@KKo)98}C>s^jKGZ3@Z`h6Gr?Gb%h3*fc7yON{^P zgxg-H8s(fpArCFiYs(v!Ctmzx8({xh;Xgfl&nCC=t2`RcOjVtMt5F>pEEz@F6w6KH z8!Qiw-+vMKoK&2aE_)fd%t=qRD!g!_8d@0HMkH!xS8%p1*;+?YSZ=)MPyjf7rFd$$ z=ilTqPXs-2K{Jk~m?6SYUS}bGYUqaL+I#<<3T%3f01{m{RAx#{l-53g9(-qef2~7A zEdN=4t{h0d2dgsv`jHWtX6wshwo#&U1z8Xq8D25Z)*+s%`is0XA5v)!05 zr9*=us}0ktGD={O3j!BPYnY YS<(C;d_-_T%+#Z~ei$;X{(W5dKjRg@82|tP literal 0 HcmV?d00001 diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index 9ab43b2..da9dcbe 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -9,6 +9,8 @@ use Illuminate\Support\Facades\Http; use NumberFormatter; use Prism\Bedrock\Enums\BedrockSchema; +use Prism\Prism\Enums\Citations\CitationSourcePositionType; +use Prism\Prism\Enums\Citations\CitationSourceType; use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\StreamEventType; use Prism\Prism\Facades\Tool; @@ -268,4 +270,81 @@ expect($lastEvent->citations[0]->citations[0])->toBeInstanceOf(Citation::class); expect($lastEvent->finishReason)->toBe(FinishReason::Stop); }); + + it('can send citation data to model', function (): void { + RequestException::dontTruncate(); + 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', 'apac.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/Maps/CitationsMapperTest.php b/tests/Schemas/Converse/Maps/CitationsMapperTest.php index d057266..895f908 100644 --- a/tests/Schemas/Converse/Maps/CitationsMapperTest.php +++ b/tests/Schemas/Converse/Maps/CitationsMapperTest.php @@ -105,6 +105,9 @@ 'title' => 'The Answer To Life', ], ], + 'content' => [ + ['text' => 'The answer is "42".'], + ], ], ]; @@ -134,6 +137,9 @@ 'title' => 'The Answer To Life', ], ], + 'content' => [ + ['text' => 'The answer is "42".'], + ], ], ]; @@ -161,6 +167,9 @@ ], 'title' => 'The Answer To Life', ]], + 'content' => [ + ['text' => 'The answer is "42".'], + ], ], ]; From 9e7df1f3f305c957293146a013667f11a43ca3c9 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 14:34:38 +1000 Subject: [PATCH 08/15] cleanup from previous merge --- tests/Fixtures/converse/stream-thinking-1.sse | Bin 8977 -> 0 bytes .../Anthropic/AnthropicStructuredHandlerTest.php | 2 +- .../Anthropic/AnthropicTextHandlerTest.php | 4 ++-- tests/Schemas/Anthropic/Maps/MessageMapTest.php | 2 +- .../Converse/ConverseStructuredHandlerTest.php | 2 +- .../Schemas/Converse/ConverseTextHandlerTest.php | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 tests/Fixtures/converse/stream-thinking-1.sse diff --git a/tests/Fixtures/converse/stream-thinking-1.sse b/tests/Fixtures/converse/stream-thinking-1.sse deleted file mode 100644 index 82d98e0edce487fc03bbc8c85bb3f6dd7ff84427..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8977 zcmd6tdu&_P9mg{sr4v-^)@e$mX*wKfD=caKiW?_eHT;h4*iK?QjtvqzzW3Vq+V_=v zj~_Sm3ij@pKmt^RVA>F!DjJDt48aB)qN+5yt&B~3wS@sgTUkZ5!Pb?P{W0x#lC+7F zG-+VS{=W(REqk2$eL|Q8hJn%kaXVFMRvzxmVqWbv%qoiQ|QGMU<*FSy458 z+AwLof%~zc$pZVmX-Xy~D#i94(k2z;`~p6C&%QIEc1a&y5D}0yt`-txO5U@srEiL< zywJkK&VJ0ZcjDpOuptnDlGI9Y9-BR*LW_dhtSBI{t`HU5cK@aLuQbNE{LotYIS*i;^i?N8=uHzbCdb0Lec4Rdj`DNevW4M!*D#$ zhUA^rrf?Sy36CmLLs)|r+drE9(g`5=C!=`eWbc(-NP!6AWPup{NYqq_Eh4Jf6pZ=! zt^vo^))ncA?dGFH$`$8X}Rts-93oy)93E}7oeVDxWBsbZ*O-a zE?aQX&W(mLm2S&QY+G9ne;+Wk&m-N4N>tpE3dKFhjF^XHRt@+79`W zbsXD|C*S=%Xz%9s8I=UYONPL)g2p3JkoEG4)&$!xe*KkwK+?@=f}}OJ0NCCdd-og= z{DsY>q&0JW7tSTtB^rXMYpS_=vag*n*nV@{qflY5_#s=&Qz6aKC*_i6$TwY>^u?==SX7wtdW$}e zkB?Qf3=Is)lOy@;WGj&jlv_D*U?i7mX|WkynsP^mG;TC9=1V5Mq?pb6vW`^N9F$T? z8VV?q+v_$4C5I8I^hHJ2fMgDgrMYai9JNNrtA&(a(4Fc`C1iT3WlS~89$Js81Kv_j z7u{2%+1;)(Kja@MDv}ZjaD0dl)O@jQD(?vzW4VMomugCNS9~ftEyWsHVK7*%5p_2| zQsJ_aQ7@Bv+Fc%R&Wub%g1)fA(;=@jN8I^LNGOz29*07Th9jjCPrw+T(2~VwgT{(6 zBit06K@w9nztc15QF3Wts!|Qb9Nu_X$QO---<5V1%dLrYpcp6@vVpNkDiWRvH;3F& ze|S%O#lZGoZ@>3;nAdExGx^^azWlTHxxIC%fsmn^{(p8NR`pG=ebX}HEYOwWK8yVYQ{7JYDRL?T9*39vT zI!-p{axhQwf};g9mSrG3T1Er@oO;v4Ap8j?{%BnKPp8Gt$3rEu1&;PIwR842wvXKH zVJpI8OmgpoH*N2<vf##uwEW<8ozSgTXvAP zJP_Fa#VKyRD7dWXbad6lN zt?pdMw4_LGVExAS-A{g(6{MfCAU=L#@BU5)(bDSkoi5dQVtwIqupq;p>t&VoF8EA- z@0MG?*$(l6Zr>qfZQFNjA3Jbx41};+IR4h@KObusbM3{O7VpDGX#0;BFIvDQ3lpaQ z#N2fTFv0|tzZE<&$nWD%KC=Ukwm%M%QnNGT%lgpGFs%qr-p2(Cc!9yP4lYi}aMYu6 zRuX;~a1VLb*^;cdSg^QmE*wI``CdP+v%4wm?UJh3X;!NVD!kt_)Q{o#d&v_Qdt?d* eEN@v&lXDAS0})iCM85Dv*Pzd~_ul#8tN#aat|1cu diff --git a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php index daa993e..9a96d01 100644 --- a/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicStructuredHandlerTest.php @@ -68,7 +68,7 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation => expect($request->data())->toMatchArray([ 'temperature' => 0, ])); }); diff --git a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php index 66ffa76..a0abf3e 100644 --- a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Http; use Prism\Prism\Facades\Tool; use Prism\Prism\Prism; -use Prism\Prism\ValueObjects\Media\Image; +use Prism\Prism\ValueObjects\Messages\Support\Image; use Prism\Prism\ValueObjects\Messages\SystemMessage; use Prism\Prism\ValueObjects\Messages\UserMessage; use Tests\Fixtures\FixtureResponse; @@ -203,7 +203,7 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation => expect($request->data())->toMatchArray([ 'temperature' => 0, ])); }); diff --git a/tests/Schemas/Anthropic/Maps/MessageMapTest.php b/tests/Schemas/Anthropic/Maps/MessageMapTest.php index 8be9954..50cfff0 100644 --- a/tests/Schemas/Anthropic/Maps/MessageMapTest.php +++ b/tests/Schemas/Anthropic/Maps/MessageMapTest.php @@ -7,8 +7,8 @@ use Prism\Bedrock\Schemas\Anthropic\Maps\MessageMap; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Providers\Anthropic\Enums\AnthropicCacheType; -use Prism\Prism\ValueObjects\Media\Image; use Prism\Prism\ValueObjects\Messages\AssistantMessage; +use Prism\Prism\ValueObjects\Messages\Support\Image; use Prism\Prism\ValueObjects\Messages\SystemMessage; use Prism\Prism\ValueObjects\Messages\ToolResultMessage; use Prism\Prism\ValueObjects\Messages\UserMessage; diff --git a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php index c4ef76b..deef284 100644 --- a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php @@ -122,7 +122,7 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation => expect($request->data())->toMatchArray([ 'inferenceConfig' => [ 'maxTokens' => 2048, 'temperature' => 0, diff --git a/tests/Schemas/Converse/ConverseTextHandlerTest.php b/tests/Schemas/Converse/ConverseTextHandlerTest.php index 55de33b..3a30194 100644 --- a/tests/Schemas/Converse/ConverseTextHandlerTest.php +++ b/tests/Schemas/Converse/ConverseTextHandlerTest.php @@ -11,8 +11,8 @@ use Prism\Prism\Prism; use Prism\Prism\Testing\TextStepFake; use Prism\Prism\Text\ResponseBuilder; -use Prism\Prism\ValueObjects\Media\Document; -use Prism\Prism\ValueObjects\Media\Image; +use Prism\Prism\ValueObjects\Messages\Support\Document; +use Prism\Prism\ValueObjects\Messages\Support\Image; use Prism\Prism\ValueObjects\Messages\UserMessage; use Tests\Fixtures\FixtureResponse; @@ -283,7 +283,7 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Expectation => expect($request->data())->toMatchArray([ + Http::assertSent(fn (Request $request): \Pest\Mixins\Expectation => expect($request->data())->toMatchArray([ 'inferenceConfig' => [ 'temperature' => 0, 'maxTokens' => 2048, From 1146a1b56e1b344ea8f9a9ba5b07c4091a8eaf44 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 14:58:36 +1000 Subject: [PATCH 09/15] Format and types --- .../Converse/ConverseStreamHandler.php | 62 +++++++++--- src/Schemas/Converse/Maps/CitationsMapper.php | 20 +++- src/Schemas/Converse/Maps/DocumentMapper.php | 3 +- src/Schemas/Converse/Maps/MessageMap.php | 5 + .../converse/stream-with-reasoning-1.sse | Bin 0 -> 4513 bytes .../AnthropicStructuredHandlerTest.php | 77 ++++++++++++++- .../Anthropic/AnthropicTextHandlerTest.php | 12 ++- .../Schemas/Anthropic/Maps/MessageMapTest.php | 2 +- .../Converse/ConverseStreamHandlerTest.php | 4 +- .../ConverseStructuredHandlerTest.php | 89 ++++++++++++++++-- .../Converse/ConverseTextHandlerTest.php | 20 ++-- 11 files changed, 257 insertions(+), 37 deletions(-) create mode 100644 tests/Fixtures/converse/stream-with-reasoning-1.sse diff --git a/src/Schemas/Converse/ConverseStreamHandler.php b/src/Schemas/Converse/ConverseStreamHandler.php index 7cbb8cc..9237163 100644 --- a/src/Schemas/Converse/ConverseStreamHandler.php +++ b/src/Schemas/Converse/ConverseStreamHandler.php @@ -80,7 +80,10 @@ protected function sendRequest(Request $request): Response ); } - protected function processStream(Response $response, Request $request, int $depth = 0) + /** + * @return Generator + */ + protected function processStream(Response $response, Request $request, int $depth = 0): Generator { $this->state->reset(); @@ -110,6 +113,10 @@ protected function processStream(Response $response, Request $request, int $dept } } + /** + * @param array $toolCalls + * @return Generator + */ protected function handleToolCalls(Request $request, array $toolCalls, int $depth): Generator { $toolResults = []; @@ -215,6 +222,9 @@ protected function isValidJson(string $string): bool } } + /** + * @param array $event + */ protected function processEvent(array $event): null|StreamEvent|Generator { $json = json_decode((string) $event['payload'], true); @@ -231,9 +241,13 @@ protected function processEvent(array $event): null|StreamEvent|Generator 'modelStreamErrorException', 'serviceUnavailableException', 'validationException' => $this->handleError($json), + default => null, }; } + /** + * @param array $event + */ protected function handleContentBlockStart(array $event): ?StreamEvent { $blockType = (bool) data_get($event, 'start.toolUse') @@ -260,6 +274,9 @@ protected function handleContentBlockStart(array $event): ?StreamEvent ); } + /** + * @param array $event + */ protected function handleContentBlockDelta(array $event): null|StreamEvent|Generator { $this->state->withBlockIndex($event['contentBlockIndex']); @@ -274,6 +291,9 @@ protected function handleContentBlockDelta(array $event): null|StreamEvent|Gener }; } + /** + * @param array $event + */ protected function handleContentBlockStop(array $event): ?StreamEvent { $result = match ($this->state->currentBlockType()) { @@ -296,6 +316,9 @@ protected function handleContentBlockStop(array $event): ?StreamEvent return $result; } + /** + * @param array $event + */ protected function handleMessageStart(array $event): StreamStartEvent { $this->state @@ -309,17 +332,23 @@ protected function handleMessageStart(array $event): StreamStartEvent ); } + /** + * @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(), + 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), @@ -335,8 +364,9 @@ protected function handleMetadata(array $event): StreamEndEvent */ protected function handleToolUseStart(array $contentBlock): null { - if ($this->state->currentBlockType() !== null) { - $this->state->addToolCall($this->state->currentBlockIndex(), [ + $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' => '', @@ -346,6 +376,10 @@ protected function handleToolUseStart(array $contentBlock): null return null; } + /** + * @param array $event + * @return never + */ protected function handleError(array $event) { if ($event[':headers']['event-type'] === 'throttlingException') { @@ -358,9 +392,6 @@ protected function handleError(array $event) )); } - /** - * @param array $delta - */ protected function handleTextDelta(string $text): ?TextDeltaEvent { if ($text === '') { @@ -378,7 +409,7 @@ protected function handleTextDelta(string $text): ?TextDeltaEvent } /** - * @param array $delta + * @param array $citationData */ protected function handleCitationDelta(array $citationData): CitationEvent { @@ -403,12 +434,16 @@ protected function handleCitationDelta(array $citationData): CitationEvent ); } + /** + * @param array $reasoningContent + * @return Generator + */ protected function handleReasoningContentDelta(array $reasoningContent): Generator { $thinking = $reasoningContent['text'] ?? ''; if ($thinking === '') { - return null; + return; } $this->state->withBlockType('thinking'); @@ -434,7 +469,7 @@ protected function handleReasoningContentDelta(array $reasoningContent): Generat } /** - * @param array $event + * @param array $toolUse */ protected function handleToolUseDelta(array $toolUse): null { @@ -451,7 +486,12 @@ protected function handleToolUseDelta(array $toolUse): null protected function handleToolUseComplete(): ?ToolCallEvent { - $toolCall = $this->state->toolCalls()[$this->state->currentBlockIndex()]; + $blockIndex = $this->state->currentBlockIndex(); + if ($blockIndex === null) { + return null; + } + + $toolCall = $this->state->toolCalls()[$blockIndex]; $input = $toolCall['input']; // Parse the JSON input diff --git a/src/Schemas/Converse/Maps/CitationsMapper.php b/src/Schemas/Converse/Maps/CitationsMapper.php index 28d6134..9049bd8 100644 --- a/src/Schemas/Converse/Maps/CitationsMapper.php +++ b/src/Schemas/Converse/Maps/CitationsMapper.php @@ -9,6 +9,9 @@ class CitationsMapper { + /** + * @param array $contentBlock + */ public static function mapFromConverse(array $contentBlock): ?MessagePartWithCitations { if (! isset($contentBlock['citationsContent']['citations'])) { @@ -26,6 +29,9 @@ public static function mapFromConverse(array $contentBlock): ?MessagePartWithCit ); } + /** + * @param array $citationData + */ public static function mapCitationFromConverse(array $citationData): Citation { $location = $citationData['location'] ?? []; @@ -43,6 +49,9 @@ public static function mapCitationFromConverse(array $citationData): Citation ); } + /** + * @return array + */ public static function mapToConverse(MessagePartWithCitations $part): array { $citations = array_map( @@ -60,6 +69,9 @@ public static function mapToConverse(MessagePartWithCitations $part): array ]; } + /** + * @param array> $citationData + */ protected static function mapSourceText(array $citationData): ?string { return implode("\n", array_map( @@ -68,6 +80,9 @@ protected static function mapSourceText(array $citationData): ?string )); } + /** + * @param array $location + */ protected static function mapSourcePositionType(array $location): ?CitationSourcePositionType { return match (array_keys($location)[0] ?? null) { @@ -78,7 +93,10 @@ protected static function mapSourcePositionType(array $location): ?CitationSourc }; } - private static function mapCitationToConverse(Citation $citation): array + /** + * @return array + */ + protected static function mapCitationToConverse(Citation $citation): array { $locationKey = match ($citation->sourcePositionType) { CitationSourcePositionType::Character => 'documentChar', diff --git a/src/Schemas/Converse/Maps/DocumentMapper.php b/src/Schemas/Converse/Maps/DocumentMapper.php index 9ae01d2..68d22ac 100644 --- a/src/Schemas/Converse/Maps/DocumentMapper.php +++ b/src/Schemas/Converse/Maps/DocumentMapper.php @@ -12,7 +12,8 @@ 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, diff --git a/src/Schemas/Converse/Maps/MessageMap.php b/src/Schemas/Converse/Maps/MessageMap.php index a1ed8bd..7dd89e3 100644 --- a/src/Schemas/Converse/Maps/MessageMap.php +++ b/src/Schemas/Converse/Maps/MessageMap.php @@ -150,6 +150,10 @@ protected static function mapToolCalls(array $parts): array ], $parts); } + /** + * @param array $parts + * @return array> + */ protected static function mapCitations(array $parts): array { return array_map( @@ -172,6 +176,7 @@ protected static function mapImageParts(array $parts): array /** * @param Document[] $parts + * @param array $providerOptions * @return array> */ protected static function mapDocumentParts(array $parts, array $providerOptions = []): array 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 0000000000000000000000000000000000000000..3e6670ba7aaea3206dd601a4fff05b54f5776d07 GIT binary patch literal 4513 zcmd6rTZkJ~7{}9AQD_S-_@IL2bg)!SY?94xnrvB0ZktRt*-Mg5Hd~~e%$#H5flr>QYc=*7ZH(G^qalZ4HS)C%P!=QWRf}a z{l0U~_y3>c^ZCyBe7@3q+a|W{94B3(u_u{hko$bQR6;3kkut*;+dVGm8vFalJ=ic5 zS-?!zwP2g-+V1hyzpvhX?{ZfsID;GKa6=GDvn9#xj-qP1VOo^g-Cp0}0-U8Q#QO`S zGG$m}oO^c1udhQy-$SdNdhRS5jB4*{97#nNI%%RXe8-`!?}^t$(&xrQ0Z#P#Q)b1A~J14y&*@Kb|5nw!M}M~erLVYn59*K;OMGteXK6WJyx%kb? zM>~F&ZpD0HBq#)10VEN{Xxhq#(K)xeFY*B(U-z(!&eHy^h;0#S=o%#mH*}k!o}|MJ z!0Msl@HzL{-lISIeB1jT;{(~fN463pm0KESw&fM!j5(i5by|EXnMh_6yg8RF>SeP# zCXdE>eKKU$CmRKVm5$X8*7_qF&(DN2v6?jMUI7PBY9FW5H_HI2=m!)Vh?hMl$6_sJFDx4rVIVYOyxjPzuRH!mp)rrCKG;D}^Q# z_>xGSiL!Sx5(!pL0L^`Lb8w zzV=qZ}z$gTg+9wsYauocqm({R)iU@QS+=L(nRu&w1Db%xM29N=yVkQj< zH<{(!FP%IOXg55(H$GXiwgR4@i_0h;s)W1V*g?td5{oWxn z1m|*BFZ}^Vdb@|d|H8Q&n~Z+YBA#Q0ibEL@;UknFikMe|5Y<(}Brg%vAcEYK1;pPW zceS?Wo;ml0?-rf``cJ(Kyz}wC>zkYbB!HeQ7b_UnmF3EG5j#i!9hGzYFPjeo$4k(v z(ZH^^23hW^W<%K>)a(uz{HJytcpqYU8rs~%W9RFG@$9^HMz8_structured['coat_required'])->toBeBool(); }); +it('uses custom jsonModeMessage when provided via providerOptions', function (): void { + FixtureResponse::fakeResponseSequence('invoke', 'anthropic/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The tigers game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $customMessage = 'Please return a JSON response using this custom format instruction'; + + Prism::structured() + ->withSchema($schema) + ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') + ->withProviderOptions([ + 'jsonModeMessage' => $customMessage, + ]) + ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + Http::assertSent(function (Request $request) use ($customMessage): bool { + $messages = $request->data()['messages'] ?? []; + $lastMessage = end($messages); + + return isset($lastMessage['content'][0]['text']) && + str_contains((string) $lastMessage['content'][0]['text'], $customMessage); + }); +}); + +it('uses default jsonModeMessage when no custom message is provided', function (): void { + FixtureResponse::fakeResponseSequence('invoke', 'anthropic/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The tigers game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $defaultMessage = 'Respond with ONLY JSON (i.e. not in backticks or a code block, with NO CONTENT outside the JSON) that matches the following schema:'; + + Prism::structured() + ->withSchema($schema) + ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') + ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + Http::assertSent(function (Request $request) use ($defaultMessage): bool { + $messages = $request->data()['messages'] ?? []; + $lastMessage = end($messages); + + return isset($lastMessage['content'][0]['text']) && + str_contains((string) $lastMessage['content'][0]['text'], $defaultMessage); + }); +}); + it('does not remove 0 values from payloads', function (): void { FixtureResponse::fakeResponseSequence('invoke', 'anthropic/structured'); @@ -68,7 +135,11 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Mixins\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 a0abf3e..3f03fe4 100644 --- a/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php +++ b/tests/Schemas/Anthropic/AnthropicTextHandlerTest.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Http; use Prism\Prism\Facades\Tool; use Prism\Prism\Prism; -use Prism\Prism\ValueObjects\Messages\Support\Image; +use Prism\Prism\ValueObjects\Media\Image; use Prism\Prism\ValueObjects\Messages\SystemMessage; use Prism\Prism\ValueObjects\Messages\UserMessage; use Tests\Fixtures\FixtureResponse; @@ -203,7 +203,11 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Mixins\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/Maps/MessageMapTest.php b/tests/Schemas/Anthropic/Maps/MessageMapTest.php index 50cfff0..8be9954 100644 --- a/tests/Schemas/Anthropic/Maps/MessageMapTest.php +++ b/tests/Schemas/Anthropic/Maps/MessageMapTest.php @@ -7,8 +7,8 @@ use Prism\Bedrock\Schemas\Anthropic\Maps\MessageMap; use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Providers\Anthropic\Enums\AnthropicCacheType; +use Prism\Prism\ValueObjects\Media\Image; use Prism\Prism\ValueObjects\Messages\AssistantMessage; -use Prism\Prism\ValueObjects\Messages\Support\Image; use Prism\Prism\ValueObjects\Messages\SystemMessage; use Prism\Prism\ValueObjects\Messages\ToolResultMessage; use Prism\Prism\ValueObjects\Messages\UserMessage; diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index da9dcbe..add8a56 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -176,7 +176,7 @@ }); it('can handle thinking', function (): void { - FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-thinking'); + FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-reasoning'); $response = Prism::text() ->using('bedrock', 'apac.anthropic.claude-sonnet-4-20250514-v1:0') @@ -209,7 +209,7 @@ expect($event)->toBeInstanceOf(ThinkingEvent::class); }); - expect($thinkingDeltas->count())->toBeGreaterThan(5); + expect($thinkingDeltas->count())->toBeGreaterThan(2); expect($thinkingDeltas->first()->delta)->not->toBeEmpty(); diff --git a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php index deef284..63b7337 100644 --- a/tests/Schemas/Converse/ConverseStructuredHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStructuredHandlerTest.php @@ -97,6 +97,77 @@ $fake->assertRequest(fn (array $requests): mixed => expect($requests[0]->providerOptions())->toBe($providerOptions)); }); +it('uses custom jsonModeMessage when provided via providerOptions', function (): void { + FixtureResponse::fakeResponseSequence('converse', 'converse/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The tigers game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $customMessage = 'Please return a JSON response using this custom format instruction'; + + Prism::structured() + ->withSchema($schema) + ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') + ->withProviderOptions([ + 'apiSchema' => BedrockSchema::Converse, + 'jsonModeMessage' => $customMessage, + ]) + ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + Http::assertSent(function (Request $request) use ($customMessage): bool { + $messages = $request->data()['messages'] ?? []; + $lastMessage = end($messages); + + return isset($lastMessage['content'][0]['text']) && + str_contains((string) $lastMessage['content'][0]['text'], $customMessage); + }); +}); + +it('uses default jsonModeMessage when no custom message is provided', function (): void { + FixtureResponse::fakeResponseSequence('converse', 'converse/structured'); + + $schema = new ObjectSchema( + 'output', + 'the output object', + [ + new StringSchema('weather', 'The weather forecast'), + new StringSchema('game_time', 'The tigers game time'), + new BooleanSchema('coat_required', 'whether a coat is required'), + ], + ['weather', 'game_time', 'coat_required'] + ); + + $defaultMessage = 'Respond with ONLY JSON (i.e. not in backticks or a code block, with NO CONTENT outside the JSON) that matches the following schema:'; + + Prism::structured() + ->withSchema($schema) + ->using('bedrock', 'anthropic.claude-3-5-haiku-20241022-v1:0') + ->withProviderOptions([ + 'apiSchema' => BedrockSchema::Converse, + ]) + ->withSystemPrompt('The tigers game is at 3pm and the temperature will be 70º') + ->withPrompt('What time is the tigers game today and should I wear a coat?') + ->asStructured(); + + Http::assertSent(function (Request $request) use ($defaultMessage): bool { + $messages = $request->data()['messages'] ?? []; + $lastMessage = end($messages); + + return isset($lastMessage['content'][0]['text']) && + str_contains((string) $lastMessage['content'][0]['text'], $defaultMessage); + }); +}); + it('does not remove 0 values from payloads', function (): void { FixtureResponse::fakeResponseSequence('converse', 'converse/structured'); @@ -122,10 +193,16 @@ ->usingTemperature(0) ->asStructured(); - Http::assertSent(fn (Request $request): \Pest\Mixins\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 3a30194..cdcfc9d 100644 --- a/tests/Schemas/Converse/ConverseTextHandlerTest.php +++ b/tests/Schemas/Converse/ConverseTextHandlerTest.php @@ -11,8 +11,8 @@ use Prism\Prism\Prism; use Prism\Prism\Testing\TextStepFake; use Prism\Prism\Text\ResponseBuilder; -use Prism\Prism\ValueObjects\Messages\Support\Document; -use Prism\Prism\ValueObjects\Messages\Support\Image; +use Prism\Prism\ValueObjects\Media\Document; +use Prism\Prism\ValueObjects\Media\Image; use Prism\Prism\ValueObjects\Messages\UserMessage; use Tests\Fixtures\FixtureResponse; @@ -283,10 +283,14 @@ ->usingTemperature(0) ->asText(); - Http::assertSent(fn (Request $request): \Pest\Mixins\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; + }); }); From caf422a8281374ec81d4aa1ed4801b02e14c053c Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 15:10:56 +1000 Subject: [PATCH 10/15] update readme.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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. From d34cb91135da2a7c638f6708a8a4fe2fccdafdc1 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 15:16:12 +1000 Subject: [PATCH 11/15] refactor union match statement --- src/Schemas/Converse/ConverseStreamHandler.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Schemas/Converse/ConverseStreamHandler.php b/src/Schemas/Converse/ConverseStreamHandler.php index 9237163..5e72e0a 100644 --- a/src/Schemas/Converse/ConverseStreamHandler.php +++ b/src/Schemas/Converse/ConverseStreamHandler.php @@ -282,11 +282,11 @@ protected function handleContentBlockDelta(array $event): null|StreamEvent|Gener $this->state->withBlockIndex($event['contentBlockIndex']); $delta = $event['delta']; - return match (true) { - array_key_exists('text', $delta) => $this->handleTextDelta($delta['text']), - array_key_exists('citation', $delta) => $this->handleCitationDelta($delta['citation']), - array_key_exists('reasoningContent', $delta) => $this->handleReasoningContentDelta($delta['reasoningContent']), - array_key_exists('toolUse', $delta) => $this->handleToolUseDelta($delta['toolUse']), + 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, }; } From 9c495d9aa72505c0377e78b8a52801d6fbfef698 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 15:27:45 +1000 Subject: [PATCH 12/15] revert default test config, use us inference profile --- tests/Schemas/Converse/ConverseStreamHandlerTest.php | 6 +++--- tests/TestCase.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index add8a56..07dec30 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -179,7 +179,7 @@ FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-reasoning'); $response = Prism::text() - ->using('bedrock', 'apac.anthropic.claude-sonnet-4-20250514-v1:0') + ->using('bedrock', 'us.anthropic.claude-sonnet-4-20250514-v1:0') ->withProviderOptions([ 'apiSchema' => BedrockSchema::Converse, 'additionalModelRequestFields' => [ @@ -223,7 +223,7 @@ FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-citations'); $response = Prism::text() - ->using('bedrock', 'apac.anthropic.claude-sonnet-4-20250514-v1:0') + ->using('bedrock', 'us.anthropic.claude-sonnet-4-20250514-v1:0') ->withMessages([ (new UserMessage( content: 'What is the answer to life?', @@ -298,7 +298,7 @@ ); $response = Prism::text() - ->using('bedrock', 'apac.anthropic.claude-sonnet-4-20250514-v1:0') + ->using('bedrock', 'us.anthropic.claude-sonnet-4-20250514-v1:0') ->withMessages([ (new UserMessage( content: 'What is the answer to life?', diff --git a/tests/TestCase.php b/tests/TestCase.php index a67700b..419abad 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -23,7 +23,7 @@ protected function defineEnvironment($app) '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', true), + 'use_default_credential_provider' => env('PRISM_BEDROCK_USE_DEFAULT_CREDENTIAL_PROVIDER', false), ]); }); } From 608e6aaf9068ed67216c87ce942c2c4ce961f3f8 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 15:33:17 +1000 Subject: [PATCH 13/15] fix url matching --- tests/Schemas/Converse/ConverseStreamHandlerTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index 07dec30..ddc61e6 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -169,8 +169,7 @@ Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); - return $request->url() === 'https://bedrock-runtime.ap-southeast-2.amazonaws.com/model/'. - 'us.amazon.nova-micro-v1:0/converse-stream' + return str_ends_with($request->url(), 'us.amazon.nova-micro-v1:0/converse-stream') && isset($body['toolConfig']); }); }); From 3213f713d1159f4308099f52bc7d87bbd90eed61 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Fri, 17 Oct 2025 15:35:38 +1000 Subject: [PATCH 14/15] remove debug line --- tests/Schemas/Converse/ConverseStreamHandlerTest.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/Schemas/Converse/ConverseStreamHandlerTest.php b/tests/Schemas/Converse/ConverseStreamHandlerTest.php index ddc61e6..8df97cb 100644 --- a/tests/Schemas/Converse/ConverseStreamHandlerTest.php +++ b/tests/Schemas/Converse/ConverseStreamHandlerTest.php @@ -5,7 +5,6 @@ namespace Tests\Schemas\Converse; use Illuminate\Http\Client\Request; -use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use NumberFormatter; use Prism\Bedrock\Enums\BedrockSchema; @@ -218,7 +217,6 @@ describe('citations', function (): void { it('emits CitationEvent and includes citations in StreamEndEvent', function (): void { - RequestException::dontTruncate(); FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-citations'); $response = Prism::text() @@ -271,7 +269,6 @@ }); it('can send citation data to model', function (): void { - RequestException::dontTruncate(); FixtureResponse::fakeStreamResponses('converse-stream', 'converse/stream-with-previous-citations'); $messageWithCitation = new AssistantMessage( From d7e31fb6d238dbb386db6e8e64dbcba01b414fb7 Mon Sep 17 00:00:00 2001 From: Rhys Emmerson Date: Tue, 16 Dec 2025 15:42:19 +1000 Subject: [PATCH 15/15] Update composer.json Relax prism contraint --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 31d7aad..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.92.0" + "prism-php/prism": ">=0.92.0" }, "config": { "allow-plugins": {