diff --git a/config/prism.php b/config/prism.php index 046b6781f..7da65ef8b 100644 --- a/config/prism.php +++ b/config/prism.php @@ -60,5 +60,9 @@ 'x_title' => env('OPENROUTER_SITE_X_TITLE', null), ], ], + 'z' => [ + 'url' => env('Z_URL', 'https://api.z.ai/api/coding/paas/v4'), + 'api_key' => env('Z_API_KEY', ''), + ], ], ]; diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4b9109e2c..84d541f6f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -215,6 +215,10 @@ export default defineConfig({ text: "XAI", link: "/providers/xai", }, + { + text: "Z AI", + link: "/providers/z", + }, ], }, { diff --git a/docs/providers/z.md b/docs/providers/z.md new file mode 100644 index 000000000..3ed99fafb --- /dev/null +++ b/docs/providers/z.md @@ -0,0 +1,248 @@ +# Z AI +## Configuration + +```php +'z' => [ + 'url' => env('Z_URL', 'https://api.z.ai/api/coding/paas/v4'), + 'api_key' => env('Z_API_KEY', ''), +] +``` + +## Text Generation + +Generate text responses with Z AI models: + +```php +$response = Prism::text() + ->using('z', 'glm-4.6') + ->withPrompt('Write a short story about a robot learning to love') + ->asText(); + +echo $response->text; +``` + +## Multi-modal Support + +Z AI provides comprehensive multi-modal capabilities through the `glm-4.6v` model, allowing you to work with images, documents, and videos in your AI requests. + +### Images + +Z AI supports image analysis through URLs using the `glm-4.6v` model: + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\ValueObjects\Media\Image; +use Prism\Prism\ValueObjects\Messages\UserMessage; + +$response = Prism::text() + ->using('z', 'glm-4.6v') + ->withMessages([ + new UserMessage( + 'What is in this image?', + additionalContent: [ + Image::fromUrl('https://example.com/image.png'), + ] + ), + ]) + ->asText(); +``` + +### Documents + +Process documents directly from URLs: + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\ValueObjects\Media\Document; +use Prism\Prism\ValueObjects\Messages\UserMessage; + +$response = Prism::text() + ->using('z', 'glm-4.6v') + ->withMessages([ + new UserMessage( + 'What does this document say about?', + additionalContent: [ + Document::fromUrl('https://example.com/document.pdf'), + ] + ), + ]) + ->asText(); +``` + +### Videos + +Z AI can analyze video content from URLs: + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\ValueObjects\Media\Video; +use Prism\Prism\ValueObjects\Messages\UserMessage; + +$response = Prism::text() + ->using('z', 'glm-4.6v') + ->withMessages([ + new UserMessage( + 'What does this video show?', + additionalContent: [ + Video::fromUrl('https://example.com/video.mp4'), + ] + ), + ]) + ->asText(); +``` + +### Combining Multiple Media Types + +You can combine images, documents, and videos in a single request: + +```php +$response = Prism::text() + ->using('z', 'glm-4.6v') + ->withMessages([ + new UserMessage( + 'Analyze this image, document, and video together', + additionalContent: [ + Image::fromUrl('https://example.com/image.png'), + Document::fromUrl('https://example.com/document.txt'), + Video::fromUrl('https://example.com/video.mp4'), + ] + ), + ]) + ->asText(); +``` + +## Tools and Function Calling + +Z AI supports function calling, allowing the model to execute your custom tools during conversation. + +### Basic Tool Usage + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\Tool; + +$weatherTool = Tool::as('get_weather') + ->for('Get current weather for a location') + ->withStringParameter('city', 'The city and state') + ->using(fn (string $city): string => "Weather in {$city}: 72°F, sunny"); + +$response = Prism::text() + ->using('z', 'glm-4.6') + ->withPrompt('What is the weather in San Francisco?') + ->withTools([$weatherTool]) + ->asText(); +``` + +### Multiple Tools + +Z AI can use multiple tools in a single request: + +```php +$tools = [ + Tool::as('get_weather') + ->for('Get current weather for a location') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 45° and cold'), + + Tool::as('search_games') + ->for('Search for current game times in a city') + ->withStringParameter('city', 'The city that you want the game times for') + ->using(fn (string $city): string => 'The tigers game is at 3pm in detroit'), +]; + +$response = Prism::text() + ->using('z', 'glm-4.6') + ->withTools($tools) + ->withMaxSteps(4) + ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat?') + ->asText(); +``` + +### Tool Choice + +Control when tools are called: + +```php +use Prism\Prism\Enums\ToolChoice; + +// Require at least one tool to be called +$response = Prism::text() + ->using('z', 'glm-4.6') + ->withPrompt('Search for information') + ->withTools([$searchTool, $weatherTool]) + ->withToolChoice(ToolChoice::Any) + ->asText(); + +// Require a specific tool to be called +$response = Prism::text() + ->using('z', 'glm-4.6') + ->withPrompt('Get the weather') + ->withTools([$searchTool, $weatherTool]) + ->withToolChoice(ToolChoice::from('get_weather')) + ->asText(); + +// Let the model decide (default) +$response = Prism::text() + ->using('z', 'glm-4.6') + ->withPrompt('What do you think?') + ->withTools([$tools]) + ->withToolChoice(ToolChoice::Auto) + ->asText(); +``` + +For complete tool documentation, see [Tools & Function Calling](/core-concepts/tools-function-calling). + +## Structured Output + +Z AI supports structured output through schema-based JSON generation, ensuring responses match your defined structure. + +### Basic Structured Output + +```php +use Prism\Prism\Facades\Prism; +use Prism\Prism\Schema\ObjectSchema; +use Prism\Prism\Schema\StringSchema; +use Prism\Prism\Schema\EnumSchema; +use Prism\Prism\Schema\BooleanSchema; + +$schema = new ObjectSchema( + 'interview_response', + 'Structured response from AI interviewer', + [ + new StringSchema('message', 'The interviewer response message'), + new EnumSchema( + 'action', + 'The next action to take', + ['ask_question', 'ask_followup', 'complete_interview'] + ), + new BooleanSchema('is_question', 'Whether this contains a question'), + ], + ['message', 'action', 'is_question'] +); + +$response = Prism::structured() + ->using('z', 'glm-4.6') + ->withSchema($schema) + ->withPrompt('Conduct an interview') + ->asStructured(); + +// Access structured data +dump($response->structured); +// [ +// 'message' => '...', +// 'action' => 'ask_question', +// 'is_question' => true +// ] +``` + +For complete structured output documentation, see [Structured Output](/core-concepts/structured-output). + +## Limitations + +### Media Types + +- Does not support `Image::fromPath` or `Image::fromBase64` - only `Image::fromUrl` +- Does not support `Document::fromPath` or `Document::fromBase64` - only `Document::fromUrl` +- Does not support `Video::fromPath` or `Video::fromBase64` - only `Video::fromUrl` + +All media must be provided as publicly accessible URLs that Z AI can fetch and process. diff --git a/src/Enums/Provider.php b/src/Enums/Provider.php index 21c7a17f6..fcace03b9 100644 --- a/src/Enums/Provider.php +++ b/src/Enums/Provider.php @@ -17,4 +17,5 @@ enum Provider: string case Gemini = 'gemini'; case VoyageAI = 'voyageai'; case ElevenLabs = 'elevenlabs'; + case Z = 'z'; } diff --git a/src/PrismManager.php b/src/PrismManager.php index 035a21f99..de84230aa 100644 --- a/src/PrismManager.php +++ b/src/PrismManager.php @@ -20,6 +20,7 @@ use Prism\Prism\Providers\Provider; use Prism\Prism\Providers\VoyageAI\VoyageAI; use Prism\Prism\Providers\XAI\XAI; +use Prism\Prism\Providers\Z\Z; use RuntimeException; class PrismManager @@ -225,4 +226,15 @@ protected function createElevenlabsProvider(array $config): ElevenLabs url: $config['url'] ?? 'https://api.elevenlabs.io/v1/', ); } + + /** + * @param array $config + */ + protected function createZProvider(array $config): Z + { + return new Z( + apiKey: $config['api_key'], + baseUrl: $config['url'], + ); + } } diff --git a/src/Providers/Z/Concerns/MapsFinishReason.php b/src/Providers/Z/Concerns/MapsFinishReason.php new file mode 100644 index 000000000..52d1bf7dc --- /dev/null +++ b/src/Providers/Z/Concerns/MapsFinishReason.php @@ -0,0 +1,19 @@ + $data + */ + protected function mapFinishReason(array $data): FinishReason + { + return FinishReasonMap::map(data_get($data, 'choices.0.finish_reason', '')); + } +} diff --git a/src/Providers/Z/Enums/DocumentType.php b/src/Providers/Z/Enums/DocumentType.php new file mode 100644 index 000000000..46e1e1164 --- /dev/null +++ b/src/Providers/Z/Enums/DocumentType.php @@ -0,0 +1,12 @@ +responseBuilder = new ResponseBuilder; + } + + public function handle(Request $request): StructuredResponse + { + $response = $this->sendRequest($request); + + $data = $response->json(); + + $content = data_get($data, 'choices.0.message.content'); + + $responseMessage = new AssistantMessage($content); + + $request->addMessage($responseMessage); + + $this->addStep($data, $request); + + return $this->responseBuilder->toResponse(); + } + + protected function sendRequest(Request $request): ClientResponse + { + $structured = new StructuredMap($request->messages(), $request->systemPrompts(), $request->schema()); + + $payload = array_merge([ + 'model' => $request->model(), + 'messages' => $structured(), + 'response_format' => [ + 'type' => 'json_object', + ], + 'thinking' => [ + 'type' => 'disabled', + ], + ], Arr::whereNotNull([ + 'temperature' => $request->temperature(), + 'top_p' => $request->topP(), + ])); + + /** @var ClientResponse $response */ + $response = $this->client->post('chat/completions', $payload); + + return $response; + } + + /** + * @param array> $toolCalls + * @return array + */ + protected function mapToolCalls(array $toolCalls): array + { + return array_map(fn (array $toolCall): ToolCall => new ToolCall( + id: data_get($toolCall, 'id'), + name: data_get($toolCall, 'function.name'), + arguments: data_get($toolCall, 'function.arguments'), + ), $toolCalls); + } + + /** + * @param array $data + */ + protected function addStep(array $data, Request $request): void + { + $this->responseBuilder->addStep(new Step( + text: data_get($data, 'choices.0.message.content') ?? '', + finishReason: $this->mapFinishReason($data), + usage: new Usage( + promptTokens: data_get($data, 'usage.prompt_tokens', 0), + completionTokens: data_get($data, 'usage.completion_tokens', 0), + ), + meta: new Meta( + id: data_get($data, 'id'), + model: data_get($data, 'model'), + ), + messages: $request->messages(), + systemPrompts: $request->systemPrompts(), + additionalContent: [], + structured: [], + )); + } +} diff --git a/src/Providers/Z/Handlers/Text.php b/src/Providers/Z/Handlers/Text.php new file mode 100644 index 000000000..57edff348 --- /dev/null +++ b/src/Providers/Z/Handlers/Text.php @@ -0,0 +1,173 @@ +responseBuilder = new ResponseBuilder; + } + + /** + * @throws \Prism\Prism\Exceptions\PrismException + */ + public function handle(Request $request): TextResponse + { + $response = $this->sendRequest($request); + + $data = $response->json(); + + $responseMessage = new AssistantMessage( + data_get($data, 'choices.0.message.content') ?? '', + $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', [])), + ); + + $request->addMessage($responseMessage); + + $finishReason = $this->mapFinishReason($data); + + return match ($finishReason) { + FinishReason::ToolCalls => $this->handleToolCalls($data, $request), + FinishReason::Stop, FinishReason::Length => $this->handleStop($data, $request), + default => throw new PrismException('Z: unknown finish reason'), + }; + } + + /** + * @param array $data + * + * @throws \Prism\Prism\Exceptions\PrismException + */ + protected function handleToolCalls(array $data, Request $request): TextResponse + { + $toolCalls = $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', [])); + + if ($toolCalls === []) { + throw new PrismException('Z: finish reason is tool_calls but no tool calls found in response'); + } + + $toolResults = $this->callTools($request->tools(), $toolCalls); + + $request->addMessage(new ToolResultMessage($toolResults)); + + $this->addStep($data, $request, $toolResults); + + if ($this->shouldContinue($request)) { + return $this->handle($request); + } + + return $this->responseBuilder->toResponse(); + } + + /** + * @param array $data + */ + protected function handleStop(array $data, Request $request): TextResponse + { + $this->addStep($data, $request); + + return $this->responseBuilder->toResponse(); + } + + protected function shouldContinue(Request $request): bool + { + return $this->responseBuilder->steps->count() < $request->maxSteps(); + } + + protected function sendRequest(Request $request): ClientResponse + { + $payload = array_merge([ + 'model' => $request->model(), + 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(), + ], Arr::whereNotNull([ + 'max_tokens' => $request->maxTokens(), + 'temperature' => $request->temperature(), + 'top_p' => $request->topP(), + 'tools' => ToolMap::map($request->tools()), + 'tool_choice' => ToolChoiceMap::map($request->toolChoice()), + ])); + + /** @var ClientResponse $response */ + $response = $this->client->post('/chat/completions', $payload); + + return $response; + } + + /** + * @param array $message + */ + protected function handleRefusal(array $message): void + { + if (data_get($message, 'refusal') !== null) { + throw new PrismException(sprintf('Z Refusal: %s', $message['refusal'])); + } + } + + /** + * @param array> $toolCalls + * @return array + */ + protected function mapToolCalls(array $toolCalls): array + { + return array_map(fn (array $toolCall): ToolCall => new ToolCall( + id: data_get($toolCall, 'id'), + name: data_get($toolCall, 'function.name'), + arguments: data_get($toolCall, 'function.arguments'), + ), $toolCalls); + } + + /** + * @param array $data + * @param array $toolResults + */ + protected function addStep(array $data, Request $request, array $toolResults = []): void + { + $this->responseBuilder->addStep(new Step( + text: data_get($data, 'choices.0.message.content') ?? '', + finishReason: $this->mapFinishReason($data), + toolCalls: $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', [])), + toolResults: $toolResults, + providerToolCalls: [], + usage: new Usage( + data_get($data, 'usage.prompt_tokens', 0), + data_get($data, 'usage.completion_tokens', 0), + ), + meta: new Meta( + id: data_get($data, 'id'), + model: data_get($data, 'model'), + ), + messages: $request->messages(), + systemPrompts: $request->systemPrompts(), + additionalContent: [], + )); + } +} diff --git a/src/Providers/Z/Maps/DocumentMapper.php b/src/Providers/Z/Maps/DocumentMapper.php new file mode 100644 index 000000000..2ab162709 --- /dev/null +++ b/src/Providers/Z/Maps/DocumentMapper.php @@ -0,0 +1,45 @@ + + */ + public function toPayload(): array + { + return [ + 'type' => $this->type->value, + $this->type->value => [ + 'url' => $this->media->url(), + ], + ]; + } + + protected function provider(): string|Provider + { + return Provider::Z; + } + + protected function validateMedia(): bool + { + return $this->media->isUrl(); + } +} diff --git a/src/Providers/Z/Maps/FinishReasonMap.php b/src/Providers/Z/Maps/FinishReasonMap.php new file mode 100644 index 000000000..dfb8228f4 --- /dev/null +++ b/src/Providers/Z/Maps/FinishReasonMap.php @@ -0,0 +1,20 @@ + FinishReason::Stop, + 'tool_calls' => FinishReason::ToolCalls, + 'length' => FinishReason::Length, + default => FinishReason::Unknown, + }; + } +} diff --git a/src/Providers/Z/Maps/MessageMap.php b/src/Providers/Z/Maps/MessageMap.php new file mode 100644 index 000000000..564c61628 --- /dev/null +++ b/src/Providers/Z/Maps/MessageMap.php @@ -0,0 +1,103 @@ + */ + protected array $mappedMessages = []; + + /** + * @param array $messages + * @param SystemMessage[] $systemPrompts + */ + public function __construct( + protected array $messages, + protected array $systemPrompts + ) { + $this->messages = array_merge( + $this->systemPrompts, + $this->messages + ); + } + + /** + * @return array + */ + public function __invoke(): array + { + array_map( + $this->mapMessage(...), + $this->messages + ); + + return $this->mappedMessages; + } + + protected function mapMessage(Message $message): void + { + match ($message::class) { + UserMessage::class => $this->mapUserMessage($message), + AssistantMessage::class => $this->mapAssistantMessage($message), + ToolResultMessage::class => $this->mapToolResultMessage($message), + SystemMessage::class => $this->mapSystemMessage($message), + default => throw new \InvalidArgumentException('Unsupported message type: '.$message::class), + }; + } + + protected function mapSystemMessage(SystemMessage $message): void + { + $this->mappedMessages[] = [ + 'role' => 'system', + 'content' => $message->content, + ]; + } + + protected function mapToolResultMessage(ToolResultMessage $message): void + { + foreach ($message->toolResults as $toolResult) { + $this->mappedMessages[] = [ + 'role' => 'tool', + 'tool_call_id' => $toolResult->toolCallId, + 'content' => $toolResult->result, + ]; + } + } + + protected function mapUserMessage(UserMessage $message): void + { + $images = array_map(fn (Media $media): array => (new DocumentMapper($media, DocumentType::ImageUrl))->toPayload(), $message->images()); + $documents = array_map(fn (Document $document): array => (new DocumentMapper($document, DocumentType::FileUrl))->toPayload(), $message->documents()); + $videos = array_map(fn (Media $media): array => (new DocumentMapper($media, DocumentType::VideoUrl))->toPayload(), $message->videos()); + + $this->mappedMessages[] = [ + 'role' => 'user', + 'content' => [ + ...$images, + ...$documents, + ...$videos, + ['type' => 'text', 'text' => $message->text()], + ], + ]; + } + + protected function mapAssistantMessage(AssistantMessage $message): void + { + + $this->mappedMessages[] = array_filter([ + 'role' => 'assistant', + 'content' => $message->content, + ]); + } +} diff --git a/src/Providers/Z/Maps/StructuredMap.php b/src/Providers/Z/Maps/StructuredMap.php new file mode 100644 index 000000000..20e5e93e6 --- /dev/null +++ b/src/Providers/Z/Maps/StructuredMap.php @@ -0,0 +1,21 @@ +messages[] = new SystemMessage( + content: 'Response Format in JSON following:'.ZAIJSONEncoder::jsonEncode($this->schema) + ); + } +} diff --git a/src/Providers/Z/Maps/ToolChoiceMap.php b/src/Providers/Z/Maps/ToolChoiceMap.php new file mode 100644 index 000000000..912c3078b --- /dev/null +++ b/src/Providers/Z/Maps/ToolChoiceMap.php @@ -0,0 +1,39 @@ +|string|null + */ + public static function map(string|ToolChoice|null $toolChoice): string|array|null + { + if (is_null($toolChoice)) { + return null; + } + + if (is_string($toolChoice)) { + return [ + 'type' => 'function', + 'function' => [ + 'name' => $toolChoice, + ], + ]; + } + + if (! in_array($toolChoice, [ToolChoice::Auto, ToolChoice::Any])) { + throw new InvalidArgumentException('Invalid tool choice'); + } + + return match ($toolChoice) { + ToolChoice::Auto => 'auto', + ToolChoice::Any => 'required', + }; + } +} diff --git a/src/Providers/Z/Maps/ToolMap.php b/src/Providers/Z/Maps/ToolMap.php new file mode 100644 index 000000000..c2799b1f8 --- /dev/null +++ b/src/Providers/Z/Maps/ToolMap.php @@ -0,0 +1,30 @@ + + */ + public static function Map(array $tools): array + { + return array_map(fn (Tool $tool): array => [ + 'type' => 'function', + 'function' => [ + 'name' => $tool->name(), + 'description' => $tool->description(), + 'parameters' => [ + 'type' => 'object', + 'properties' => $tool->parametersAsArray(), + 'required' => $tool->requiredParameters(), + ], + ], + ], $tools); + } +} diff --git a/src/Providers/Z/Support/ZAIJSONEncoder.php b/src/Providers/Z/Support/ZAIJSONEncoder.php new file mode 100644 index 000000000..a57aa5d96 --- /dev/null +++ b/src/Providers/Z/Support/ZAIJSONEncoder.php @@ -0,0 +1,143 @@ + + */ + public static function encodeSchema(Schema $schema): array + { + if ($schema instanceof ObjectSchema) { + return self::encodeObjectSchema($schema); + } + + return self::encodePropertySchema($schema); + } + + public static function jsonEncode(Schema $schema, bool $prettyPrint = true): string + { + $encoded = self::encodeSchema($schema); + + $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + if ($prettyPrint) { + $flags |= JSON_PRETTY_PRINT; + } + + $result = json_encode($encoded, $flags); + if ($result === false) { + throw new \RuntimeException('Failed to encode schema to JSON: '.json_last_error_msg()); + } + + return $result; + } + + /** + * @return array + */ + protected static function encodeObjectSchema(ObjectSchema $schema): array + { + $jsonSchema = [ + 'type' => 'object', + 'properties' => [], + ]; + + if (isset($schema->description)) { + $jsonSchema['description'] = $schema->description; + } + + foreach ($schema->properties as $property) { + // Use name() method which is defined in Schema interface + $propertyName = method_exists($property, 'name') ? $property->name() : $property->name ?? 'unknown'; + $jsonSchema['properties'][$propertyName] = self::encodePropertySchema($property); + } + + if ($schema->requiredFields !== []) { + $jsonSchema['required'] = $schema->requiredFields; + } + + if (! $schema->allowAdditionalProperties) { + $jsonSchema['additionalProperties'] = false; + } + + // Handle nullable for objects + if (isset($schema->nullable) && $schema->nullable) { + $jsonSchema['type'] = [$jsonSchema['type'], 'null']; + } + + return $jsonSchema; + } + + /** + * @param Schema $property + * @return array + */ + protected static function encodePropertySchema($property): array + { + $schema = []; + + if ($property instanceof StringSchema) { + $schema['type'] = 'string'; + if (isset($property->description)) { + $schema['description'] = $property->description; + } + } elseif ($property instanceof BooleanSchema) { + $schema['type'] = 'boolean'; + if (isset($property->description)) { + $schema['description'] = $property->description; + } + } elseif ($property instanceof NumberSchema) { + $schema['type'] = 'number'; + if (isset($property->description)) { + $schema['description'] = $property->description; + } + if (isset($property->minimum)) { + $schema['minimum'] = $property->minimum; + } + if (isset($property->maximum)) { + $schema['maximum'] = $property->maximum; + } + } elseif ($property instanceof EnumSchema) { + $schema['type'] = 'string'; + $schema['enum'] = $property->options; + if (isset($property->description)) { + $schema['description'] = $property->description; + } + } elseif ($property instanceof ObjectSchema) { + $schema = self::encodeObjectSchema($property); + } elseif ($property instanceof ArraySchema) { + $schema['type'] = 'array'; + if (isset($property->description)) { + $schema['description'] = $property->description; + } + + if (isset($property->items)) { + $schema['items'] = self::encodePropertySchema($property->items); + } + + if (isset($property->minItems)) { + $schema['minItems'] = $property->minItems; + } + if (isset($property->maxItems)) { + $schema['maxItems'] = $property->maxItems; + } + } + + if (property_exists($property, 'nullable') && $property->nullable !== null && $property->nullable) { + $schema['type'] = [$schema['type'] ?? 'string', 'null']; + } + + return $schema; + } +} diff --git a/src/Providers/Z/Z.php b/src/Providers/Z/Z.php new file mode 100644 index 000000000..88c96b214 --- /dev/null +++ b/src/Providers/Z/Z.php @@ -0,0 +1,59 @@ +client($request->clientOptions(), $request->clientRetry()) + ); + + return $handler->handle($request); + } + + #[\Override] + public function structured(StructuredRequest $request): StructuredResponse + { + $handler = new Handlers\Structured( + $this->client($request->clientOptions(), $request->clientRetry()) + ); + + return $handler->handle($request); + } + + /** + * @param array $options + * @param array $retry + */ + protected function client(array $options = [], array $retry = [], ?string $baseUrl = null): PendingRequest + { + return $this->baseClient() + ->when($this->apiKey, fn ($client) => $client->withToken($this->apiKey)) + ->withOptions($options) + ->when($retry !== [], fn ($client) => $client->retry(...$retry)) + ->baseUrl($baseUrl ?? $this->baseUrl); + } +} diff --git a/tests/Fixtures/z/generate-text-with-a-prompt-1.json b/tests/Fixtures/z/generate-text-with-a-prompt-1.json new file mode 100644 index 000000000..22543d481 --- /dev/null +++ b/tests/Fixtures/z/generate-text-with-a-prompt-1.json @@ -0,0 +1,21 @@ +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "z-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you today?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } +} diff --git a/tests/Fixtures/z/generate-text-with-multiple-tools-1.json b/tests/Fixtures/z/generate-text-with-multiple-tools-1.json new file mode 100644 index 000000000..1071227f9 --- /dev/null +++ b/tests/Fixtures/z/generate-text-with-multiple-tools-1.json @@ -0,0 +1,46 @@ +{ + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": "\nI'll help you find the Tigers game time in Detroit and check the weather to see if you should wear a coat. Let me look up both pieces of information for you.\n", + "reasoning_content": "\nThe user is asking for two things:\n1. What time the tigers game is today in Detroit\n2. Whether they should wear a coat (which relates to weather)\n\nI have two tools available:\n- get_weather: to check weather for a city\n- search_games: to search for current game times in a city\n\nFor the first question about the Tigers game time, I should use search_games with \"Detroit\" as the city.\n\nFor the second question about wearing a coat, I should use get_weather with \"Detroit\" as the city to check the weather conditions.\n\nBoth tools require the \"city\" parameter, and the user has specified \"Detroit\" clearly, so I have all the required parameters.", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"city\":\"Detroit\"}", + "name": "search_games" + }, + "id": "call_-8062812703858712887", + "index": 0, + "type": "function" + }, + { + "function": { + "arguments": "{\"city\":\"Detroit\"}", + "name": "get_weather" + }, + "id": "call_-8062812703858712886", + "index": 1, + "type": "function" + } + ] + } + } + ], + "created": 1765888363, + "id": "2025121620323812c137d944f34776", + "model": "z-model", + "object": "chat.completion", + "request_id": "2025121620323812c137d944f34776", + "usage": { + "completion_tokens": 210, + "prompt_tokens": 272, + "prompt_tokens_details": { + "cached_tokens": 271 + }, + "total_tokens": 482 + } +} \ No newline at end of file diff --git a/tests/Fixtures/z/generate-text-with-multiple-tools-2.json b/tests/Fixtures/z/generate-text-with-multiple-tools-2.json new file mode 100644 index 000000000..fec35643b --- /dev/null +++ b/tests/Fixtures/z/generate-text-with-multiple-tools-2.json @@ -0,0 +1,25 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "\nBased on the information I gathered:\n\n**Tigers Game Time:** The Tigers game today in Detroit is at 3:00 PM.\n\n**Weather and Coat Recommendation:** The weather will be 45° and cold. Yes, you should definitely wear a coat to the game! At 45 degrees, it will be quite chilly, especially if you'll be sitting outdoors for several hours. You might want to consider wearing a warm coat, and possibly dressing in layers with a hat and gloves for extra comfort during the game.", + "role": "assistant" + } + } + ], + "created": 1765888366, + "id": "20251216203244b8311d53051b4c17", + "model": "z-model", + "object": "chat.completion", + "request_id": "20251216203244b8311d53051b4c17", + "usage": { + "completion_tokens": 109, + "prompt_tokens": 344, + "prompt_tokens_details": { + "cached_tokens": 273 + }, + "total_tokens": 453 + } +} \ No newline at end of file diff --git a/tests/Fixtures/z/generate-text-with-required-tool-call-1.json b/tests/Fixtures/z/generate-text-with-required-tool-call-1.json new file mode 100644 index 000000000..ce0e8f3be --- /dev/null +++ b/tests/Fixtures/z/generate-text-with-required-tool-call-1.json @@ -0,0 +1,37 @@ +{ + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "content": "", + "reasoning_content": "\nThe user has just said \"Do something\" which is very vague and doesn't provide any specific instructions or requests. I have access to two tools:\n1. weather - for getting weather conditions for a city\n2. search - for searching current events or data\n\nSince the user hasn't specified what they want me to do, I should ask them for clarification about what they would like me to help them with. I shouldn't make assumptions or use the tools without understanding what they want.", + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"query\":\"current events news today\"}", + "name": "search" + }, + "id": "call_-8062758106233394160", + "index": 0, + "type": "function" + } + ] + } + } + ], + "created": 1765889375, + "id": "2025121620493361e6fbd932f44eec", + "model": "z-model", + "object": "chat.completion", + "request_id": "2025121620493361e6fbd932f44eec", + "usage": { + "completion_tokens": 117, + "prompt_tokens": 238, + "prompt_tokens_details": { + "cached_tokens": 43 + }, + "total_tokens": 355 + } +} \ No newline at end of file diff --git a/tests/Fixtures/z/generate-text-with-system-prompt-1.json b/tests/Fixtures/z/generate-text-with-system-prompt-1.json new file mode 100644 index 000000000..f30908125 --- /dev/null +++ b/tests/Fixtures/z/generate-text-with-system-prompt-1.json @@ -0,0 +1,26 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "\nI'm an AI assistant created to help with a wide range of tasks and questions. I can assist with things like:\n\n- Answering questions and providing information\n- Helping with research and analysis\n- Writing and editing content\n- Brainstorming ideas\n- Explaining complex topics\n- And much more\n\nI'm designed to be helpful, harmless, and honest in our interactions. I don't have personal experiences or emotions, but I'm here to assist you with whatever you need help with. \n\nIs there something specific I can help you with today?", + "reasoning_content": "\nThe user is asking me who I am. This is a simple introductory question about my identity. I should provide a clear and helpful response about what I am - an AI assistant. There's no need to use the weather function for this question.", + "role": "assistant" + } + } + ], + "created": 1765885934, + "id": "202512161952121dd7efde49d14dc9", + "model": "z-model", + "object": "chat.completion", + "request_id": "202512161952121dd7efde49d14dc9", + "usage": { + "completion_tokens": 166, + "prompt_tokens": 190, + "prompt_tokens_details": { + "cached_tokens": 189 + }, + "total_tokens": 356 + } +} \ No newline at end of file diff --git a/tests/Fixtures/z/structured-basic-response-1.json b/tests/Fixtures/z/structured-basic-response-1.json new file mode 100644 index 000000000..bb99340c7 --- /dev/null +++ b/tests/Fixtures/z/structured-basic-response-1.json @@ -0,0 +1,25 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "{\"message\":\"That's a fantastic real-world application! Building a customer service chatbot with Laravel sounds like a great project that could really help streamline customer interactions. Let's shift gears a bit and talk about database optimization, which is crucial for backend performance. Have you had experience with database indexing, and can you share a situation where you needed to optimize database queries in a project?\",\"action\":\"ask_question\",\"status\":\"question_asked\",\"is_question\":true,\"question_type\":\"database_optimization\",\"move_to_next_question\":true}", + "role": "assistant" + } + } + ], + "created": 1765785136, + "id": "chatcmpl-123", + "model": "z-model", + "object": "chat.completion", + "request_id": "chatcmpl-123", + "usage": { + "completion_tokens": 129, + "prompt_tokens": 1309, + "prompt_tokens_details": { + "cached_tokens": 1227 + }, + "total_tokens": 1438 + } +} \ No newline at end of file diff --git a/tests/Fixtures/z/text-file-from-url-1.json b/tests/Fixtures/z/text-file-from-url-1.json new file mode 100644 index 000000000..1dad0f5e9 --- /dev/null +++ b/tests/Fixtures/z/text-file-from-url-1.json @@ -0,0 +1,26 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "\ndemo2.txt 文件主要介绍了“GLM PPT”这一工具。内容显示,GLM PPT 是面向职场人与创作者的新一代智能工具,基于 GLM 大模型深度驱动,区别于传统工程化拼接方案,能实现从自然语言指令到可交互幻灯片的一键生成,深度融合内容生成与设计规范,可快速交付专业级作品,降低设计门槛,提升内容生产效率。", + "reasoning_content": "用户现在需要分析demo2.txt的内容。首先看文件里的内容:标题是“GLM PPT”,然后描述GLM PPT是面向职场人和创作者的新一代智能工具,基于GLM大模型深度驱动,区别于传统工程化拼接方案,实现从自然语言指令到可交互幻灯片的一键生成,深度融合内容生成与设计规范,可快速交付专业级作品,降低设计门槛,提升内容生产效率。所以需要总结这些信息,说明文件是介绍GLM PPT这个工具的,包括它的定位、技术基础、功能特点(一键生成幻灯片、融合内容与设计、降低门槛、提升效率)等。现在组织语言,把文件内容的核心点提炼出来。", + "role": "assistant" + } + } + ], + "created": 1766314047, + "id": "202512211847249ebe90d9e96f4bd6", + "model": "z-model.v", + "object": "chat.completion", + "request_id": "202512211847249ebe90d9e96f4bd6", + "usage": { + "completion_tokens": 246, + "prompt_tokens": 1175, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 1421 + } +} \ No newline at end of file diff --git a/tests/Fixtures/z/text-image-from-url-1.json b/tests/Fixtures/z/text-image-from-url-1.json new file mode 100644 index 000000000..b3944d005 --- /dev/null +++ b/tests/Fixtures/z/text-image-from-url-1.json @@ -0,0 +1,26 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "\nThis image is a **stylized black - and - white illustration of a diamond (gemstone)**. It features the characteristic geometric shape of a diamond, with triangular facets (both on the upper and lower portions) and bold black outlines that define its structure. This type of graphic is often used to represent concepts like luxury, value, jewelry, or preciousness, and it has a clean, minimalist design typical of iconography or simple symbolic art. Diamond", + "reasoning_content": "Okay, let's see. The image is a black and white outline of a diamond, like a gemstone. It's a simple geometric shape with triangular facets, typical of a diamond's cut. So it's a diamond icon or symbol, maybe used for representing jewelry, value, or something precious. The design is stylized with straight lines forming the diamond's shape, including the top and bottom parts with the triangular divisions. Yep, that's a diamond illustration, probably a vector or simple graphic.", + "role": "assistant" + } + } + ], + "created": 1766220958, + "id": "20251220165552f3cf03baa3604788", + "model": "z-model.v", + "object": "chat.completion", + "request_id": "20251220165552f3cf03baa3604788", + "usage": { + "completion_tokens": 199, + "prompt_tokens": 853, + "prompt_tokens_details": { + "cached_tokens": 5 + }, + "total_tokens": 1052 + } +} \ No newline at end of file diff --git a/tests/Fixtures/z/text-video-from-url-1.json b/tests/Fixtures/z/text-video-from-url-1.json new file mode 100644 index 000000000..46985211b --- /dev/null +++ b/tests/Fixtures/z/text-video-from-url-1.json @@ -0,0 +1,26 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "\nThe video shows a sequence of actions on a web browser, specifically:\n\n1. A user opening the Google search engine.\n2. The user typing \"智谱\" (Zhipu) into the search bar and performing the search.\n3. The Google search results for \"智谱\" appearing on the screen.\n4. The user clicking on the first search result, which leads to the official website of Zhipu AI (www.zhipuai.cn).\n5. The final scene is the homepage of the Zhipu AI website, showcasing its \"Z.ai GLM Large Model Open Platform\".\n\nEssentially, the video demonstrates a user searching for information about the Chinese AI company Zhipu and navigating to its official website.", + "reasoning_content": "Based on the sequence of frames in the video, here is a breakdown of what it shows:\n\n1. **Initial Scene:** The video starts by showing a web browser (Google Chrome) open to the Google search homepage.\n2. **Search Action:** The user types the Chinese word \"智谱\" (which translates to \"Zhipu\") into the Google search bar.\n3. **Search Results:** After clicking the \"Google Search\" button, the search results page loads. The results are related to \"智谱\" (Zhipu), showing links to websites like `www.zhipuai.cn`, `chatglm.cn`, and others. These sites are related to a Chinese AI company and its products, such as the ChatGLM large language model.\n4. **Navigation:** The user then clicks on the first search result, which leads to the website `www.zhipuai.cn`.\n5. **Final Scene:** The video concludes with the homepage of the Zhipu AI (智谱AI) website fully loaded. The page displays the \"Z.ai GLM Large Model Open Platform\".\n\nIn summary, the video demonstrates a user searching for \"智谱\" on Google and then visiting the official website of Zhipu AI.", + "role": "assistant" + } + } + ], + "created": 1766314394, + "id": "202512211852392576b2f2c49942a8", + "model": "z-model.v", + "object": "chat.completion", + "request_id": "202512211852392576b2f2c49942a8", + "usage": { + "completion_tokens": 409, + "prompt_tokens": 72255, + "prompt_tokens_details": { + "cached_tokens": 4 + }, + "total_tokens": 72664 + } +} \ No newline at end of file diff --git a/tests/Providers/Z/MessageMapTest.php b/tests/Providers/Z/MessageMapTest.php new file mode 100644 index 000000000..2fe20c6e1 --- /dev/null +++ b/tests/Providers/Z/MessageMapTest.php @@ -0,0 +1,57 @@ +toHaveCount(4) + ->and($mapped[0])->toBe([ + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ]) + ->and($mapped[1])->toBe([ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello, how are you?', + ], + ], + ]) + ->and($mapped[2])->toBe([ + 'role' => 'assistant', + 'content' => 'I am doing well, thank you!', + ]) + ->and($mapped[3])->toBe([ + 'role' => 'tool', + 'tool_call_id' => 'tool_123', + 'content' => 'result_data', + ]); +}); diff --git a/tests/Providers/Z/ZStructuredTest.php b/tests/Providers/Z/ZStructuredTest.php new file mode 100644 index 000000000..7a4f4f98c --- /dev/null +++ b/tests/Providers/Z/ZStructuredTest.php @@ -0,0 +1,84 @@ +set('prism.providers.z.api_key', env('Z_API_KEY', 'zai-123')); +}); + +it('Z provider handles structured request', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/structured-basic-response'); + + $schema = new ObjectSchema( + name: 'InterviewResponse', + description: 'Structured response from AI interviewer', + properties: [ + new StringSchema( + name: 'message', + description: 'The AI interviewer response message', + nullable: false + ), + new EnumSchema( + name: 'action', + description: 'The next action to take in the interview', + options: ['ask_question', 'ask_followup', 'ask_clarification', 'complete_interview'], + nullable: false + ), + new EnumSchema( + name: 'status', + description: 'Current interview status', + options: ['waiting_for_answer', 'question_asked', 'followup_asked', 'completed'], + nullable: false + ), + new BooleanSchema( + name: 'is_question', + description: 'Whether this response contains a question', + nullable: false + ), + new StringSchema( + name: 'question_type', + description: 'Type of question being asked', + nullable: true + ), + new BooleanSchema( + name: 'move_to_next_question', + description: 'Whether to move to the next question after this response', + nullable: false + ), + ], + requiredFields: ['message', 'action', 'status', 'is_question', 'move_to_next_question'] + ); + + $response = Prism::structured() + ->using(Provider::Z, 'z-model') + ->withSchema($schema) + ->asStructured(); + + $text = <<<'JSON_STRUCTURED' + {"message":"That's a fantastic real-world application! Building a customer service chatbot with Laravel sounds like a great project that could really help streamline customer interactions. Let's shift gears a bit and talk about database optimization, which is crucial for backend performance. Have you had experience with database indexing, and can you share a situation where you needed to optimize database queries in a project?","action":"ask_question","status":"question_asked","is_question":true,"question_type":"database_optimization","move_to_next_question":true} + JSON_STRUCTURED; + + expect($response->text)->toBe($text) + ->and($response->structured)->toBe([ + 'message' => "That's a fantastic real-world application! Building a customer service chatbot with Laravel sounds like a great project that could really help streamline customer interactions. Let's shift gears a bit and talk about database optimization, which is crucial for backend performance. Have you had experience with database indexing, and can you share a situation where you needed to optimize database queries in a project?", + 'action' => 'ask_question', + 'status' => 'question_asked', + 'is_question' => true, + 'question_type' => 'database_optimization', + 'move_to_next_question' => true, + ]) + ->and($response->usage->promptTokens)->toBe(1309) + ->and($response->usage->completionTokens)->toBe(129) + ->and($response->meta->id)->toBe('chatcmpl-123') + ->and($response->meta->model)->toBe('z-model'); +}); diff --git a/tests/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php new file mode 100644 index 000000000..85e083811 --- /dev/null +++ b/tests/Providers/Z/ZTextTest.php @@ -0,0 +1,265 @@ +set('prism.providers.z.api_key', env('Z_API_KEY', 'zai-123')); +}); + +describe('Text generation for Z', function (): void { + it('can generate text with a prompt', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-a-prompt'); + + $response = Prism::text() + ->using(Provider::Z, 'z-model') + ->withPrompt('Hello!') + ->asText(); + + expect($response->text)->toBe('Hello! How can I help you today?') + ->and($response->finishReason)->toBe(FinishReason::Stop) + ->and($response->usage->promptTokens)->toBe(9) + ->and($response->usage->completionTokens)->toBe(12) + ->and($response->meta->id)->toBe('chatcmpl-123') + ->and($response->meta->model)->toBe('z-model'); + }); + + it('can generate text with a system prompt', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-system-prompt'); + + $response = Prism::text() + ->using(Provider::Z, 'z-model') + ->withSystemPrompt('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!') + ->withPrompt('Who are you?') + ->asText(); + + expect($response)->toBeInstanceOf(TextResponse::class) + ->and($response->usage->promptTokens)->toBe(190) + ->and($response->usage->completionTokens)->toBe(166) + ->and($response->meta->id)->toBe('202512161952121dd7efde49d14dc9') + ->and($response->meta->model)->toBe('z-model') + ->and($response->text)->toBe( + "\nI'm an AI assistant created to help with a wide range of tasks and questions. I can assist with things like:\n\n- Answering questions and providing information\n- Helping with research and analysis\n- Writing and editing content\n- Brainstorming ideas\n- Explaining complex topics\n- And much more\n\nI'm designed to be helpful, harmless, and honest in our interactions. I don't have personal experiences or emotions, but I'm here to assist you with whatever you need help with. \n\nIs there something specific I can help you with today?" + ) + ->and($response->finishReason)->toBe(FinishReason::Stop) + ->and($response->steps)->toHaveCount(1) + ->and($response->steps[0]->text)->toBe($response->text) + ->and($response->steps[0]->finishReason)->toBe(FinishReason::Stop); + }); + + it('can generate text using multiple tools and multiple steps', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-multiple-tools'); + + $tools = [ + Tool::as('get_weather') + ->for('use this tool when you need to get wather for the city') + ->withStringParameter('city', 'The city that you want the weather for') + ->using(fn (string $city): string => 'The weather will be 45° and cold'), + Tool::as('search_games') + ->for('useful for searching curret games times in the city') + ->withStringParameter('city', 'The city that you want the game times for') + ->using(fn (string $city): string => 'The tigers game is at 3pm in detroit'), + ]; + + $response = Prism::text() + ->using(Provider::Z, 'z-model') + ->withTools($tools) + ->withMaxSteps(4) + ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat? please check all the details from tools') + ->asText(); + + expect($response)->toBeInstanceOf(TextResponse::class); + + $firstStep = $response->steps[0]; + expect($firstStep->toolCalls)->toHaveCount(2) + ->and($firstStep->toolCalls[0]->name)->toBe('search_games') + ->and($firstStep->toolCalls[0]->arguments())->toBe([ + 'city' => 'Detroit', + ]) + ->and($firstStep->toolCalls[1]->name)->toBe('get_weather') + ->and($firstStep->toolCalls[1]->arguments())->toBe([ + 'city' => 'Detroit', + ]) + ->and($response->usage->promptTokens)->toBe(616) + ->and($response->usage->completionTokens)->toBe(319) + ->and($response->meta->id)->toBe('20251216203244b8311d53051b4c17') + ->and($response->meta->model)->toBe('z-model') + ->and($response->text)->toBe( + "\nBased on the information I gathered:\n\n**Tigers Game Time:** The Tigers game today in Detroit is at 3:00 PM.\n\n**Weather and Coat Recommendation:** The weather will be 45° and cold. Yes, you should definitely wear a coat to the game! At 45 degrees, it will be quite chilly, especially if you'll be sitting outdoors for several hours. You might want to consider wearing a warm coat, and possibly dressing in layers with a hat and gloves for extra comfort during the game." + ); + }); +}); + +describe('Image support with Z', function (): void { + it('can send images from url', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/text-image-from-url'); + + $image = 'https://prismphp.com/storage/diamond.png'; + + $response = Prism::text() + ->using(Provider::Z, 'z-model.v') + ->withMessages([ + new UserMessage( + 'What is this image', + additionalContent: [ + Image::fromUrl($image), + ], + ), + ]) + ->asText(); + + expect($response)->toBeInstanceOf(TextResponse::class); + + Http::assertSent(function (Request $request) use ($image): true { + $message = $request->data()['messages'][0]['content']; + + expect($message[0]) + ->toBe([ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $image, + ], + ]) + ->and($message[1]) + ->toBe([ + 'type' => 'text', + 'text' => 'What is this image', + ]); + + return true; + }); + }); + + it('can send file from url', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/text-file-from-url'); + + $file = 'https://cdn.bigmodel.cn/static/demo/demo2.txt'; + + $response = Prism::text() + ->using(Provider::Z, 'z-model.v') + ->withMessages([ + new UserMessage( + 'What are the files show about?', + additionalContent: [ + Document::fromUrl($file), + ], + ), + ]) + ->asText(); + + expect($response)->toBeInstanceOf(TextResponse::class); + + Http::assertSent(function (Request $request) use ($file): true { + $message = $request->data()['messages'][0]['content']; + + expect($message[0]) + ->toBe([ + 'type' => 'file_url', + 'file_url' => [ + 'url' => $file, + ], + ]) + ->and($message[1]) + ->toBe([ + 'type' => 'text', + 'text' => 'What are the files show about?', + ]); + + return true; + }); + }); + + it('can send video from url', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/text-video-from-url'); + + $videoUrl = 'https://cdn.bigmodel.cn/agent-demos/lark/113123.mov'; + + $response = Prism::text() + ->using(Provider::Z, 'z-model.v') + ->withMessages([ + new UserMessage( + 'What are the video show about?', + additionalContent: [ + Video::fromUrl($videoUrl), + ], + ), + ]) + ->asText(); + + expect($response)->toBeInstanceOf(TextResponse::class); + + Http::assertSent(function (Request $request) use ($videoUrl): true { + $message = $request->data()['messages'][0]['content']; + + expect($message[0]) + ->toBe([ + 'type' => 'video_url', + 'video_url' => [ + 'url' => $videoUrl, + ], + ]) + ->and($message[1]) + ->toBe([ + 'type' => 'text', + 'text' => 'What are the video show about?', + ]); + + return true; + }); + }); +}); + +it('handles specific tool choice', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-required-tool-call'); + + $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 current 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(Provider::Z, 'z-model') + ->withPrompt('Do something') + ->withTools($tools) + ->withToolChoice(ToolChoice::Any) + ->asText(); + + expect($response)->toBeInstanceOf(TextResponse::class) + ->and($response->steps[0]->toolCalls[0]->name)->toBeIn(['weather', 'search']); +}); + +it('throws a PrismRateLimitedException for a 429 response code', function (): void { + Http::fake([ + '*' => Http::response( + status: 429, + ), + ])->preventStrayRequests(); + + Prism::text() + ->using(Provider::Z, 'z-model') + ->withPrompt('Who are you?') + ->asText(); + +})->throws(PrismRateLimitedException::class);