diff --git a/fixtures/Bridge/VertexAi/code_execution_outcome_deadline_exceeded.json b/fixtures/Bridge/VertexAi/code_execution_outcome_deadline_exceeded.json new file mode 100644 index 000000000..9b4a592a0 --- /dev/null +++ b/fixtures/Bridge/VertexAi/code_execution_outcome_deadline_exceeded.json @@ -0,0 +1,31 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "First text" + }, + { + "executableCode": { + "language": "PYTHON", + "code": "print('Hello, World!')" + } + }, + { + "codeExecutionResult": { + "outcome": "OUTCOME_DEADLINE_EXCEEDED", + "output": "An error occurred during code execution." + } + }, + { + "text": "Last text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} diff --git a/fixtures/Bridge/VertexAi/code_execution_outcome_failed.json b/fixtures/Bridge/VertexAi/code_execution_outcome_failed.json new file mode 100644 index 000000000..8a1580137 --- /dev/null +++ b/fixtures/Bridge/VertexAi/code_execution_outcome_failed.json @@ -0,0 +1,31 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "First text" + }, + { + "executableCode": { + "language": "PYTHON", + "code": "print('Hello, World!')" + } + }, + { + "codeExecutionResult": { + "outcome": "OUTCOME_FAILED", + "output": "An error occurred during code execution." + } + }, + { + "text": "Last text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} diff --git a/fixtures/Bridge/VertexAi/code_execution_outcome_ok.json b/fixtures/Bridge/VertexAi/code_execution_outcome_ok.json new file mode 100644 index 000000000..3738f01b2 --- /dev/null +++ b/fixtures/Bridge/VertexAi/code_execution_outcome_ok.json @@ -0,0 +1,37 @@ +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "First text" + }, + { + "executableCode": { + "language": "PYTHON", + "code": "print('Hello, World!')" + } + }, + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "Hello, World!" + } + }, + { + "text": "Second text\n" + }, + { + "text": "Third text\n" + }, + { + "text": "Fourth text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} diff --git a/src/platform/src/Bridge/VertexAi/Gemini/ResultConverter.php b/src/platform/src/Bridge/VertexAi/Gemini/ResultConverter.php index f2b411a06..179689d63 100644 --- a/src/platform/src/Bridge/VertexAi/Gemini/ResultConverter.php +++ b/src/platform/src/Bridge/VertexAi/Gemini/ResultConverter.php @@ -31,6 +31,10 @@ */ final readonly class ResultConverter implements ResultConverterInterface { + public const OUTCOME_OK = 'OUTCOME_OK'; + public const OUTCOME_FAILED = 'OUTCOME_FAILED'; + public const OUTCOME_DEADLINE_EXCEEDED = 'OUTCOME_DEADLINE_EXCEEDED'; + public function supports(BaseModel $model): bool { return $model instanceof Model; @@ -119,21 +123,44 @@ private function convertStream(HttpResponse $result): \Generator * text?: string * }[] * } - * } $choices + * } $choice */ - private function convertChoice(array $choices): ToolCallResult|TextResult + private function convertChoice(array $choice): ToolCallResult|TextResult { - $content = $choices['content']['parts'][0] ?? []; + $contentParts = $choice['content']['parts']; + + if (1 === \count($contentParts)) { + $contentPart = $contentParts[0]; + + if (isset($contentPart['functionCall'])) { + return new ToolCallResult($this->convertToolCall($contentPart['functionCall'])); + } + + if (isset($contentPart['text'])) { + return new TextResult($contentPart['text']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finishReason'])); + } + + $content = ''; + $successfulCodeExecutionDetected = false; + foreach ($contentParts as $contentPart) { + if ($this->isSuccessfulCodeExecution($contentPart)) { + $successfulCodeExecutionDetected = true; + continue; + } - if (isset($content['functionCall'])) { - return new ToolCallResult($this->convertToolCall($content['functionCall'])); + if ($successfulCodeExecutionDetected) { + $content .= $contentPart['text']; + } } - if (isset($content['text'])) { - return new TextResult($content['text']); + if ('' !== $content) { + return new TextResult($content); } - throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choices['finishReason'])); + throw new RuntimeException('Code execution failed.'); } /** @@ -146,4 +173,23 @@ private function convertToolCall(array $toolCall): ToolCall { return new ToolCall($toolCall['name'], $toolCall['name'], $toolCall['args']); } + + /** + * @param array{ + * codeExecutionResult?: array{ + * outcome: self::OUTCOME_*, + * output: string + * } + * } $contentPart + */ + private function isSuccessfulCodeExecution(array $contentPart): bool + { + if (!isset($contentPart['codeExecutionResult'])) { + return false; + } + + $result = $contentPart['codeExecutionResult']; + + return self::OUTCOME_OK === $result['outcome']; + } } diff --git a/src/platform/tests/Bridge/VertexAi/Gemini/ResultConverterTest.php b/src/platform/tests/Bridge/VertexAi/Gemini/ResultConverterTest.php index 0a7f0baa0..d77ba1173 100644 --- a/src/platform/tests/Bridge/VertexAi/Gemini/ResultConverterTest.php +++ b/src/platform/tests/Bridge/VertexAi/Gemini/ResultConverterTest.php @@ -46,8 +46,54 @@ public function testItConvertsAResponseToAVectorResult() $result = $resultConverter->convert(new RawHttpResult($response)); // Assert - $this->assertInstanceOf(TextResult::class, $result); $this->assertSame('Hello, world!', $result->getContent()); } + + public function testItReturnsAggregatedTextOnSuccess() + { + $response = $this->createStub(ResponseInterface::class); + $responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/VertexAi/code_execution_outcome_ok.json'); + + $response + ->method('toArray') + ->willReturn(json_decode($responseContent, true)); + + $converter = new ResultConverter(); + + $result = $converter->convert(new RawHttpResult($response)); + $this->assertInstanceOf(TextResult::class, $result); + + $this->assertEquals("Second text\nThird text\nFourth text", $result->getContent()); + } + + public function testItThrowsExceptionOnFailure() + { + $response = $this->createStub(ResponseInterface::class); + $responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/VertexAi/code_execution_outcome_failed.json'); + + $response + ->method('toArray') + ->willReturn(json_decode($responseContent, true)); + + $converter = new ResultConverter(); + + $this->expectException(\RuntimeException::class); + $converter->convert(new RawHttpResult($response)); + } + + public function testItThrowsExceptionOnTimeout() + { + $response = $this->createStub(ResponseInterface::class); + $responseContent = file_get_contents(\dirname(__DIR__, 6).'/fixtures/Bridge/VertexAi/code_execution_outcome_deadline_exceeded.json'); + + $response + ->method('toArray') + ->willReturn(json_decode($responseContent, true)); + + $converter = new ResultConverter(); + + $this->expectException(\RuntimeException::class); + $converter->convert(new RawHttpResult($response)); + } }