From 046fda4b030f6699b4048b22a81462646c87e6d8 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Mon, 15 Dec 2025 13:35:02 +0700 Subject: [PATCH 01/19] feat(provider): add Z AI provider integration - Add Z provider for text and structured completions - Implement message mapping for Z AI API format - Add JSON encoder for structured output schemas - Register Z provider in PrismManager - Include comprehensive test coverage for the provider --- src/PrismManager.php | 12 ++ src/Providers/Z/Concerns/MapsFinishReason.php | 19 ++ src/Providers/Z/Handlers/Structured.php | 107 +++++++++++ src/Providers/Z/Handlers/Text.php | 168 ++++++++++++++++++ src/Providers/Z/Maps/MessageMap.php | 90 ++++++++++ src/Providers/Z/Maps/StructuredMap.php | 42 +++++ src/Providers/Z/Support/ZAIJSONEncoder.php | 90 ++++++++++ src/Providers/Z/Z.php | 84 +++++++++ tests/Providers/Z/MessageMapTest.php | 51 ++++++ tests/Providers/Z/ZStructuredTest.php | 66 +++++++ tests/Providers/Z/ZTextTest.php | 116 ++++++++++++ 11 files changed, 845 insertions(+) create mode 100644 src/Providers/Z/Concerns/MapsFinishReason.php create mode 100644 src/Providers/Z/Handlers/Structured.php create mode 100644 src/Providers/Z/Handlers/Text.php create mode 100644 src/Providers/Z/Maps/MessageMap.php create mode 100644 src/Providers/Z/Maps/StructuredMap.php create mode 100644 src/Providers/Z/Support/ZAIJSONEncoder.php create mode 100644 src/Providers/Z/Z.php create mode 100644 tests/Providers/Z/MessageMapTest.php create mode 100644 tests/Providers/Z/ZStructuredTest.php create mode 100644 tests/Providers/Z/ZTextTest.php 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..620dd77aa --- /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/Handlers/Structured.php b/src/Providers/Z/Handlers/Structured.php new file mode 100644 index 000000000..e01623a24 --- /dev/null +++ b/src/Providers/Z/Handlers/Structured.php @@ -0,0 +1,107 @@ +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(), + ])); + + return $this->client->post('/chat/completions', $payload); + } + + /** + * @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..681bc3407 --- /dev/null +++ b/src/Providers/Z/Handlers/Text.php @@ -0,0 +1,168 @@ +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()))(), + 'max_tokens' => $request->maxTokens() ?? 2048, + ], Arr::whereNotNull([ + 'temperature' => $request->temperature(), + 'top_p' => $request->topP(), + 'thinking' => [ + 'type' => 'disabled', + ], + ])); + + return $this->client->post('/chat/completions', $payload); + } + + /** + * @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, + 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/MessageMap.php b/src/Providers/Z/Maps/MessageMap.php new file mode 100644 index 000000000..0e4ee961d --- /dev/null +++ b/src/Providers/Z/Maps/MessageMap.php @@ -0,0 +1,90 @@ + */ + 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), + }; + } + + 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 + { + $this->mappedMessages[] = [ + 'role' => 'user', + 'content' => $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..29770b34f --- /dev/null +++ b/src/Providers/Z/Maps/StructuredMap.php @@ -0,0 +1,42 @@ +schema; + + $structured = ZAIJSONEncoder::jsonEncode($scheme); + + $this->mappedMessages[] = [ + 'role' => 'system', + 'content' => <<content + PROMPT, + ]; + } +} diff --git a/src/Providers/Z/Support/ZAIJSONEncoder.php b/src/Providers/Z/Support/ZAIJSONEncoder.php new file mode 100644 index 000000000..9ebea4b01 --- /dev/null +++ b/src/Providers/Z/Support/ZAIJSONEncoder.php @@ -0,0 +1,90 @@ + 'object', + 'properties' => [], + ]; + + foreach ($schema->properties as $property) { + $jsonSchema['properties'][$property->name] = self::encodePropertySchema($property); + } + + if ($schema->requiredFields !== []) { + $jsonSchema['required'] = $schema->requiredFields; + } + + if (! $schema->allowAdditionalProperties) { + $jsonSchema['additionalProperties'] = false; + } + + return $jsonSchema; + } + + private static function encodePropertySchema($property): array + { + $schema = []; + + if ($property instanceof StringSchema) { + $schema['type'] = 'string'; + } elseif ($property instanceof BooleanSchema) { + $schema['type'] = 'boolean'; + } elseif ($property instanceof NumberSchema) { + $schema['type'] = 'number'; + 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; + } elseif ($property instanceof ArraySchema) { + $schema['type'] = 'array'; + if (isset($property->items)) { + $schema['items'] = self::encodePropertySchema($property->items); + } + } + + if (isset($property->nullable) && $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..62376f0f0 --- /dev/null +++ b/src/Providers/Z/Z.php @@ -0,0 +1,84 @@ +client($request->clientOptions(), $request->clientRetry()) + ); + + try { + return $handler->handle($request); + } catch (RequestException $e) { + $this->handleRequestException($request->model(), $e); + } + } + + #[\Override] + public function structured(StructuredRequest $request): StructuredResponse + { + $handler = new Handlers\Structured( + $this->client($request->clientOptions(), $request->clientRetry()) + ); + + try { + return $handler->handle($request); + } catch (RequestException $e) { + $this->handleRequestException($request->model(), $e); + } + } + + public function handleRequestException(string $model, RequestException $e): never + { + $response = $e->response; + $body = $response->json() ?? []; + $status = $response->status(); + + $message = $body['error']['message'] + ?? $body['message'] + ?? 'Unknown error from Z AI API'; + + throw PrismException::providerResponseError( + vsprintf('Z AI Error [%s]: %s', [$status, $message]) + ); + } + + /** + * @param array $options + * @param array $retry + */ + private 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/Providers/Z/MessageMapTest.php b/tests/Providers/Z/MessageMapTest.php new file mode 100644 index 000000000..a0ebc9cb5 --- /dev/null +++ b/tests/Providers/Z/MessageMapTest.php @@ -0,0 +1,51 @@ +toHaveCount(4); + expect($mapped[0])->toBe([ + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ]); + expect($mapped[1])->toBe([ + 'role' => 'user', + 'content' => 'Hello, how are you?', + ]); + expect($mapped[2])->toBe([ + 'role' => 'assistant', + 'content' => 'I am doing well, thank you!', + ]); + expect($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..1c10b522b --- /dev/null +++ b/tests/Providers/Z/ZStructuredTest.php @@ -0,0 +1,66 @@ + Http::response([ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion', + 'created' => 1677652288, + 'model' => 'z-model', + 'choices' => [ + [ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => '{"name": "John", "age": 30}', + ], + 'finish_reason' => 'stop', + ], + ], + 'usage' => [ + 'prompt_tokens' => 9, + 'completion_tokens' => 12, + 'total_tokens' => 21, + ], + ]), + ]); + + $provider = new Z('test-api-key', 'https://api.z.ai/v1'); + $schema = new ObjectSchema( + 'person', + 'A person object', + [ + new StringSchema('name', 'The person\'s name'), + ], + ['name'] + ); + $request = new TestStructuredRequest(schema: $schema); + + $response = $provider->structured($request); + + expect($response->text)->toBe('{"name": "John", "age": 30}'); + expect($response->structured)->toBe(['name' => 'John', 'age' => 30]); + expect($response->usage->promptTokens)->toBe(9); + expect($response->usage->completionTokens)->toBe(12); + expect($response->meta->id)->toBe('chatcmpl-123'); + expect($response->meta->model)->toBe('z-model'); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['model'] === 'test-model' && + $data['response_format']['type'] === 'json_object' && + $data['thinking']['type'] === 'disabled'; + }); +}); diff --git a/tests/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php new file mode 100644 index 000000000..6f347aaee --- /dev/null +++ b/tests/Providers/Z/ZTextTest.php @@ -0,0 +1,116 @@ + Http::response([ + '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, + ], + ]), + ]); + + $provider = new Z('test-api-key', 'https://api.z.ai/v1'); + $request = new TestRequest; + + $response = $provider->text($request); + + expect($response->text)->toBe('Hello! How can I help you today?'); + expect($response->finishReason)->toBe(FinishReason::Stop); + expect($response->usage->promptTokens)->toBe(9); + expect($response->usage->completionTokens)->toBe(12); + expect($response->meta->id)->toBe('chatcmpl-123'); + expect($response->meta->model)->toBe('z-model'); + + Http::assertSent(function (Request $request): bool { + $data = $request->data(); + + return $data['model'] === 'test-model' && + $data['max_tokens'] === 2048 && + $data['thinking']['type'] === 'disabled'; + }); +}); + +test('Z provider handles tool calls', function (): void { + Http::fake([ + '*/chat/completions' => Http::response([ + 'id' => 'chatcmpl-123', + 'object' => 'chat.completion', + 'created' => 1677652288, + 'model' => 'z-model', + 'choices' => [ + [ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'test_function', + 'arguments' => '{"param": "value"}', + ], + ], + ], + ], + 'finish_reason' => 'tool_calls', + ], + ], + 'usage' => [ + 'prompt_tokens' => 9, + 'completion_tokens' => 12, + 'total_tokens' => 21, + ], + ]), + ]); + + $provider = new Z('test-api-key', 'https://api.z.ai/v1'); + $request = new TestRequest( + tools: [ + [ + 'name' => 'test_function', + 'description' => 'A test function', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'param' => ['type' => 'string'], + ], + ], + ], + ] + ); + + $response = $provider->text($request); + + expect($response->steps)->toHaveCount(1); + expect($response->steps[0]->toolCalls)->toHaveCount(1); + expect($response->steps[0]->toolCalls[0]->name)->toBe('test_function'); + expect($response->steps[0]->finishReason)->toBe(FinishReason::ToolCalls); +}); From 07079774ee1ec93ecc95f5cabeadc8a9a0197e0e Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Mon, 15 Dec 2025 13:42:57 +0700 Subject: [PATCH 02/19] fix(z): add missing providerToolCalls property to Step in Text handler --- src/Providers/Z/Handlers/Text.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Providers/Z/Handlers/Text.php b/src/Providers/Z/Handlers/Text.php index 681bc3407..cdb0cc1ab 100644 --- a/src/Providers/Z/Handlers/Text.php +++ b/src/Providers/Z/Handlers/Text.php @@ -152,6 +152,7 @@ protected function addStep(array $data, Request $request, array $toolResults = [ 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), From b60954c619cff00a0efa35043189c6af6c27c763 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Mon, 15 Dec 2025 13:47:03 +0700 Subject: [PATCH 03/19] fix(config): add Z config to prism.php --- config/prism.php | 4 ++++ 1 file changed, 4 insertions(+) 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', ''), + ], ], ]; From 7f51a506f365e634a0d15acd33f0c4ea942b0655 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Mon, 15 Dec 2025 14:42:00 +0700 Subject: [PATCH 04/19] feat(z): improve Z provider implementation and testing - Add Z provider to Provider enum - Simplify Z provider by removing custom exception handling - Fix API endpoint path in Structured handler (remove leading slash) - Change Z class from final to extensible - Make encoder methods protected for better extensibility - Make client method protected for better extensibility --- src/Enums/Provider.php | 1 + src/Providers/Z/Handlers/Structured.php | 2 +- src/Providers/Z/Support/ZAIJSONEncoder.php | 5 ++- src/Providers/Z/Z.php | 33 ++------------- .../z/generate-text-with-a-prompt-1.json | 23 +++++++++++ .../z/structured-basic-response-1.json | 41 +++++++++++++++++++ tests/Providers/Z/MessageMapTest.php | 41 ++++++++++--------- tests/Providers/Z/ZStructuredTest.php | 14 +++---- tests/Providers/Z/ZTextTest.php | 40 ++++++++---------- 9 files changed, 119 insertions(+), 81 deletions(-) create mode 100644 tests/Fixtures/z/generate-text-with-a-prompt-1.json create mode 100644 tests/Fixtures/z/structured-basic-response-1.json 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/Providers/Z/Handlers/Structured.php b/src/Providers/Z/Handlers/Structured.php index e01623a24..355b2a0b4 100644 --- a/src/Providers/Z/Handlers/Structured.php +++ b/src/Providers/Z/Handlers/Structured.php @@ -66,7 +66,7 @@ protected function sendRequest(Request $request): ClientResponse 'top_p' => $request->topP(), ])); - return $this->client->post('/chat/completions', $payload); + return $this->client->post('chat/completions', $payload); } /** diff --git a/src/Providers/Z/Support/ZAIJSONEncoder.php b/src/Providers/Z/Support/ZAIJSONEncoder.php index 9ebea4b01..9245b7dd5 100644 --- a/src/Providers/Z/Support/ZAIJSONEncoder.php +++ b/src/Providers/Z/Support/ZAIJSONEncoder.php @@ -21,6 +21,7 @@ public static function encodeSchema($schema): array return self::encodePropertySchema($schema); } + public static function jsonEncode($schema, bool $prettyPrint = true): string { $encoded = self::encodeSchema($schema); @@ -33,7 +34,7 @@ public static function jsonEncode($schema, bool $prettyPrint = true): string return json_encode($encoded, $flags); } - private static function encodeObjectSchema(ObjectSchema $schema): array + protected static function encodeObjectSchema(ObjectSchema $schema): array { $jsonSchema = [ 'type' => 'object', @@ -55,7 +56,7 @@ private static function encodeObjectSchema(ObjectSchema $schema): array return $jsonSchema; } - private static function encodePropertySchema($property): array + protected static function encodePropertySchema($property): array { $schema = []; diff --git a/src/Providers/Z/Z.php b/src/Providers/Z/Z.php index 62376f0f0..88c96b214 100644 --- a/src/Providers/Z/Z.php +++ b/src/Providers/Z/Z.php @@ -5,16 +5,14 @@ namespace Prism\Prism\Providers\Z; use Illuminate\Http\Client\PendingRequest; -use Illuminate\Http\Client\RequestException; use Prism\Prism\Concerns\InitializesClient; -use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Providers\Provider; use Prism\Prism\Structured\Request as StructuredRequest; use Prism\Prism\Structured\Response as StructuredResponse; use Prism\Prism\Text\Request as TextRequest; use Prism\Prism\Text\Response; -final class Z extends Provider +class Z extends Provider { use InitializesClient; @@ -33,11 +31,7 @@ public function text(TextRequest $request): Response $this->client($request->clientOptions(), $request->clientRetry()) ); - try { - return $handler->handle($request); - } catch (RequestException $e) { - $this->handleRequestException($request->model(), $e); - } + return $handler->handle($request); } #[\Override] @@ -47,33 +41,14 @@ public function structured(StructuredRequest $request): StructuredResponse $this->client($request->clientOptions(), $request->clientRetry()) ); - try { - return $handler->handle($request); - } catch (RequestException $e) { - $this->handleRequestException($request->model(), $e); - } - } - - public function handleRequestException(string $model, RequestException $e): never - { - $response = $e->response; - $body = $response->json() ?? []; - $status = $response->status(); - - $message = $body['error']['message'] - ?? $body['message'] - ?? 'Unknown error from Z AI API'; - - throw PrismException::providerResponseError( - vsprintf('Z AI Error [%s]: %s', [$status, $message]) - ); + return $handler->handle($request); } /** * @param array $options * @param array $retry */ - private function client(array $options = [], array $retry = [], ?string $baseUrl = null): PendingRequest + protected function client(array $options = [], array $retry = [], ?string $baseUrl = null): PendingRequest { return $this->baseClient() ->when($this->apiKey, fn ($client) => $client->withToken($this->apiKey)) 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..d0e05b607 --- /dev/null +++ b/tests/Fixtures/z/generate-text-with-a-prompt-1.json @@ -0,0 +1,23 @@ +{ + "id": "febc7de9-9991-4b08-942a-c7082174225a", + "object": "chat.completion", + "created": 1731128928, + "model": "grok-beta", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I am Grok, an AI developed by xAI. I'm here to provide helpful and truthful answers to your questions, often with a dash of outside perspective on humanity. What's on your mind?", + "refusal": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 42, + "total_tokens": 52 + }, + "system_fingerprint": "fp_98261732b5" +} 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..071a89a27 --- /dev/null +++ b/tests/Fixtures/z/structured-basic-response-1.json @@ -0,0 +1,41 @@ +{ + "id": "chatcmpl-AZaPUjMBtN3XS1RKGvkI7ZfnNPpJU", + "object": "chat.completion", + "created": 1752364699, + "model": "grok-4", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"title\":\"Inception\",\"rating\":\"4.5\",\"summary\":\"A mind-bending masterpiece by Christopher Nolan that explores dreams within dreams, featuring stunning visuals, a complex plot, and exceptional performances, though it can be a bit convoluted at times.\"}", + "parsed": { + "title": "Inception", + "rating": "4.5", + "summary": "A mind-bending masterpiece by Christopher Nolan that explores dreams within dreams, featuring stunning visuals, a complex plot, and exceptional performances, though it can be a bit convoluted at times." + }, + "refusal": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 49, + "completion_tokens": 32, + "total_tokens": 264, + "prompt_tokens_details": { + "text_tokens": 49, + "audio_tokens": 0, + "image_tokens": 0, + "cached_tokens": 7 + }, + "completion_tokens_details": { + "reasoning_tokens": 183, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "num_sources_used": 0 + }, + "system_fingerprint": "fp_acdceb26fb" +} \ No newline at end of file diff --git a/tests/Providers/Z/MessageMapTest.php b/tests/Providers/Z/MessageMapTest.php index a0ebc9cb5..d730c1331 100644 --- a/tests/Providers/Z/MessageMapTest.php +++ b/tests/Providers/Z/MessageMapTest.php @@ -18,8 +18,9 @@ new ToolResultMessage([ new ToolResult( 'tool_123', - 'result_data', - 'tool_name' + 'tool_name', + [], + 'result_data' ), ]), ]; @@ -30,22 +31,22 @@ $messageMap = new MessageMap($messages, $systemPrompts); $mapped = $messageMap(); - expect($mapped)->toHaveCount(4); - expect($mapped[0])->toBe([ - 'role' => 'system', - 'content' => 'You are a helpful assistant.', - ]); - expect($mapped[1])->toBe([ - 'role' => 'user', - 'content' => 'Hello, how are you?', - ]); - expect($mapped[2])->toBe([ - 'role' => 'assistant', - 'content' => 'I am doing well, thank you!', - ]); - expect($mapped[3])->toBe([ - 'role' => 'tool', - 'tool_call_id' => 'tool_123', - 'content' => 'result_data', - ]); + expect($mapped)->toHaveCount(4) + ->and($mapped[0])->toBe([ + 'role' => 'system', + 'content' => 'You are a helpful assistant.', + ]) + ->and($mapped[1])->toBe([ + 'role' => 'user', + 'content' => '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 index 1c10b522b..4e0d0ed98 100644 --- a/tests/Providers/Z/ZStructuredTest.php +++ b/tests/Providers/Z/ZStructuredTest.php @@ -9,7 +9,7 @@ use Prism\Prism\Providers\Z\Z; use Prism\Prism\Schema\ObjectSchema; use Prism\Prism\Schema\StringSchema; -use Tests\TestStructuredRequest; +use Tests\TestDoubles\TestStructuredRequest; test('Z provider handles structured request', function (): void { Http::fake([ @@ -49,12 +49,12 @@ $response = $provider->structured($request); - expect($response->text)->toBe('{"name": "John", "age": 30}'); - expect($response->structured)->toBe(['name' => 'John', 'age' => 30]); - expect($response->usage->promptTokens)->toBe(9); - expect($response->usage->completionTokens)->toBe(12); - expect($response->meta->id)->toBe('chatcmpl-123'); - expect($response->meta->model)->toBe('z-model'); + expect($response->text)->toBe('{"name": "John", "age": 30}') + ->and($response->structured)->toBe(['name' => 'John', 'age' => 30]) + ->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'); Http::assertSent(function (Request $request): bool { $data = $request->data(); diff --git a/tests/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 6f347aaee..86d5a042b 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -7,8 +7,9 @@ use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\FinishReason; +use Prism\Prism\Facades\Tool; use Prism\Prism\Providers\Z\Z; -use Tests\TestRequest; +use Tests\TestDoubles\TestRequest; test('Z provider handles text request', function (): void { Http::fake([ @@ -40,12 +41,12 @@ $response = $provider->text($request); - expect($response->text)->toBe('Hello! How can I help you today?'); - expect($response->finishReason)->toBe(FinishReason::Stop); - expect($response->usage->promptTokens)->toBe(9); - expect($response->usage->completionTokens)->toBe(12); - expect($response->meta->id)->toBe('chatcmpl-123'); - expect($response->meta->model)->toBe('z-model'); + 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'); Http::assertSent(function (Request $request): bool { $data = $request->data(); @@ -94,23 +95,18 @@ $provider = new Z('test-api-key', 'https://api.z.ai/v1'); $request = new TestRequest( tools: [ - [ - 'name' => 'test_function', - 'description' => 'A test function', - 'parameters' => [ - 'type' => 'object', - 'properties' => [ - 'param' => ['type' => 'string'], - ], - ], - ], - ] + Tool::as('test_function') + ->for('A test function') + ->withStringParameter('param', 'A parameter') + ->using(fn (string $param): string => "Result: {$param}"), + ], + maxSteps: 1 ); $response = $provider->text($request); - expect($response->steps)->toHaveCount(1); - expect($response->steps[0]->toolCalls)->toHaveCount(1); - expect($response->steps[0]->toolCalls[0]->name)->toBe('test_function'); - expect($response->steps[0]->finishReason)->toBe(FinishReason::ToolCalls); + expect($response->steps)->toHaveCount(1) + ->and($response->steps[0]->toolCalls)->toHaveCount(1) + ->and($response->steps[0]->toolCalls[0]->name)->toBe('test_function') + ->and($response->steps[0]->finishReason)->toBe(FinishReason::ToolCalls); }); From 192a5b99d958ff993cd5e9dfcfce7e56d23f7b6a Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Mon, 15 Dec 2025 14:42:08 +0700 Subject: [PATCH 05/19] test(z): refactor Z provider tests to use Prism facade - Update tests to use Prism facade instead of direct provider instantiation - Change test functions from test() to it() for better Pest compliance - Update test fixtures to use consistent mock data - Remove HTTP mocking in favor of FixtureResponse - Simplify test structure and improve maintainability - Update import paths for test doubles - Add beforeEach configuration for API key setup --- .../z/generate-text-with-a-prompt-1.json | 18 ++- .../z/structured-basic-response-1.json | 38 ++----- tests/Providers/Z/MessageMapTest.php | 2 +- tests/Providers/Z/ZStructuredTest.php | 52 +++------ tests/Providers/Z/ZTextTest.php | 106 ++---------------- 5 files changed, 43 insertions(+), 173 deletions(-) diff --git a/tests/Fixtures/z/generate-text-with-a-prompt-1.json b/tests/Fixtures/z/generate-text-with-a-prompt-1.json index d0e05b607..22543d481 100644 --- a/tests/Fixtures/z/generate-text-with-a-prompt-1.json +++ b/tests/Fixtures/z/generate-text-with-a-prompt-1.json @@ -1,23 +1,21 @@ { - "id": "febc7de9-9991-4b08-942a-c7082174225a", + "id": "chatcmpl-123", "object": "chat.completion", - "created": 1731128928, - "model": "grok-beta", + "created": 1677652288, + "model": "z-model", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "I am Grok, an AI developed by xAI. I'm here to provide helpful and truthful answers to your questions, often with a dash of outside perspective on humanity. What's on your mind?", - "refusal": null + "content": "Hello! How can I help you today?" }, "finish_reason": "stop" } ], "usage": { - "prompt_tokens": 10, - "completion_tokens": 42, - "total_tokens": 52 - }, - "system_fingerprint": "fp_98261732b5" + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } } diff --git a/tests/Fixtures/z/structured-basic-response-1.json b/tests/Fixtures/z/structured-basic-response-1.json index 071a89a27..28634ed67 100644 --- a/tests/Fixtures/z/structured-basic-response-1.json +++ b/tests/Fixtures/z/structured-basic-response-1.json @@ -1,41 +1,21 @@ { - "id": "chatcmpl-AZaPUjMBtN3XS1RKGvkI7ZfnNPpJU", + "id": "chatcmpl-123", "object": "chat.completion", - "created": 1752364699, - "model": "grok-4", + "created": 1677652288, + "model": "z-model", "choices": [ { "index": 0, "message": { "role": "assistant", - "content": "{\"title\":\"Inception\",\"rating\":\"4.5\",\"summary\":\"A mind-bending masterpiece by Christopher Nolan that explores dreams within dreams, featuring stunning visuals, a complex plot, and exceptional performances, though it can be a bit convoluted at times.\"}", - "parsed": { - "title": "Inception", - "rating": "4.5", - "summary": "A mind-bending masterpiece by Christopher Nolan that explores dreams within dreams, featuring stunning visuals, a complex plot, and exceptional performances, though it can be a bit convoluted at times." - }, - "refusal": null + "content": "{\"name\": \"John\", \"age\": 30}" }, "finish_reason": "stop" } ], "usage": { - "prompt_tokens": 49, - "completion_tokens": 32, - "total_tokens": 264, - "prompt_tokens_details": { - "text_tokens": 49, - "audio_tokens": 0, - "image_tokens": 0, - "cached_tokens": 7 - }, - "completion_tokens_details": { - "reasoning_tokens": 183, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 - }, - "num_sources_used": 0 - }, - "system_fingerprint": "fp_acdceb26fb" -} \ No newline at end of file + "prompt_tokens": 9, + "completion_tokens": 12, + "total_tokens": 21 + } +} diff --git a/tests/Providers/Z/MessageMapTest.php b/tests/Providers/Z/MessageMapTest.php index d730c1331..a60828b78 100644 --- a/tests/Providers/Z/MessageMapTest.php +++ b/tests/Providers/Z/MessageMapTest.php @@ -11,7 +11,7 @@ use Prism\Prism\ValueObjects\Messages\UserMessage; use Prism\Prism\ValueObjects\ToolResult; -test('MessageMap maps messages correctly', function (): void { +it('MessageMap maps messages correctly', function (): void { $messages = [ new UserMessage('Hello, how are you?'), new AssistantMessage('I am doing well, thank you!'), diff --git a/tests/Providers/Z/ZStructuredTest.php b/tests/Providers/Z/ZStructuredTest.php index 4e0d0ed98..8d08b235f 100644 --- a/tests/Providers/Z/ZStructuredTest.php +++ b/tests/Providers/Z/ZStructuredTest.php @@ -4,39 +4,19 @@ namespace Tests\Providers\Z; -use Illuminate\Http\Client\Request; -use Illuminate\Support\Facades\Http; -use Prism\Prism\Providers\Z\Z; +use Prism\Prism\Enums\Provider; +use Prism\Prism\Facades\Prism; use Prism\Prism\Schema\ObjectSchema; use Prism\Prism\Schema\StringSchema; -use Tests\TestDoubles\TestStructuredRequest; +use Tests\Fixtures\FixtureResponse; -test('Z provider handles structured request', function (): void { - Http::fake([ - '*/chat/completions' => Http::response([ - 'id' => 'chatcmpl-123', - 'object' => 'chat.completion', - 'created' => 1677652288, - 'model' => 'z-model', - 'choices' => [ - [ - 'index' => 0, - 'message' => [ - 'role' => 'assistant', - 'content' => '{"name": "John", "age": 30}', - ], - 'finish_reason' => 'stop', - ], - ], - 'usage' => [ - 'prompt_tokens' => 9, - 'completion_tokens' => 12, - 'total_tokens' => 21, - ], - ]), - ]); +beforeEach(function (): void { + config()->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'); - $provider = new Z('test-api-key', 'https://api.z.ai/v1'); $schema = new ObjectSchema( 'person', 'A person object', @@ -45,9 +25,11 @@ ], ['name'] ); - $request = new TestStructuredRequest(schema: $schema); - $response = $provider->structured($request); + $response = Prism::structured() + ->using(Provider::Z, 'glm-4.6') + ->withSchema($schema) + ->asStructured(); expect($response->text)->toBe('{"name": "John", "age": 30}') ->and($response->structured)->toBe(['name' => 'John', 'age' => 30]) @@ -55,12 +37,4 @@ ->and($response->usage->completionTokens)->toBe(12) ->and($response->meta->id)->toBe('chatcmpl-123') ->and($response->meta->model)->toBe('z-model'); - - Http::assertSent(function (Request $request): bool { - $data = $request->data(); - - return $data['model'] === 'test-model' && - $data['response_format']['type'] === 'json_object' && - $data['thinking']['type'] === 'disabled'; - }); }); diff --git a/tests/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 86d5a042b..d4b8bf0d2 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -4,42 +4,22 @@ namespace Tests\Providers\Z; -use Illuminate\Http\Client\Request; -use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\FinishReason; -use Prism\Prism\Facades\Tool; -use Prism\Prism\Providers\Z\Z; -use Tests\TestDoubles\TestRequest; +use Prism\Prism\Enums\Provider; +use Prism\Prism\Facades\Prism; +use Tests\Fixtures\FixtureResponse; -test('Z provider handles text request', function (): void { - Http::fake([ - '*/chat/completions' => Http::response([ - '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, - ], - ]), - ]); +beforeEach(function (): void { + config()->set('prism.providers.z.api_key', env('Z_API_KEY', 'zai-123')); +}); - $provider = new Z('test-api-key', 'https://api.z.ai/v1'); - $request = new TestRequest; +it('Z provider handles text request', function (): void { + FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-a-prompt'); - $response = $provider->text($request); + $response = Prism::text() + ->using(Provider::Z, 'glm-4.6') + ->withPrompt('Hello!') + ->asText(); expect($response->text)->toBe('Hello! How can I help you today?') ->and($response->finishReason)->toBe(FinishReason::Stop) @@ -47,66 +27,4 @@ ->and($response->usage->completionTokens)->toBe(12) ->and($response->meta->id)->toBe('chatcmpl-123') ->and($response->meta->model)->toBe('z-model'); - - Http::assertSent(function (Request $request): bool { - $data = $request->data(); - - return $data['model'] === 'test-model' && - $data['max_tokens'] === 2048 && - $data['thinking']['type'] === 'disabled'; - }); -}); - -test('Z provider handles tool calls', function (): void { - Http::fake([ - '*/chat/completions' => Http::response([ - 'id' => 'chatcmpl-123', - 'object' => 'chat.completion', - 'created' => 1677652288, - 'model' => 'z-model', - 'choices' => [ - [ - 'index' => 0, - 'message' => [ - 'role' => 'assistant', - 'content' => null, - 'tool_calls' => [ - [ - 'id' => 'call_123', - 'type' => 'function', - 'function' => [ - 'name' => 'test_function', - 'arguments' => '{"param": "value"}', - ], - ], - ], - ], - 'finish_reason' => 'tool_calls', - ], - ], - 'usage' => [ - 'prompt_tokens' => 9, - 'completion_tokens' => 12, - 'total_tokens' => 21, - ], - ]), - ]); - - $provider = new Z('test-api-key', 'https://api.z.ai/v1'); - $request = new TestRequest( - tools: [ - Tool::as('test_function') - ->for('A test function') - ->withStringParameter('param', 'A parameter') - ->using(fn (string $param): string => "Result: {$param}"), - ], - maxSteps: 1 - ); - - $response = $provider->text($request); - - expect($response->steps)->toHaveCount(1) - ->and($response->steps[0]->toolCalls)->toHaveCount(1) - ->and($response->steps[0]->toolCalls[0]->name)->toBe('test_function') - ->and($response->steps[0]->finishReason)->toBe(FinishReason::ToolCalls); }); From c773d43ce080026af84f55bb6d3f618fa982b84d Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Mon, 15 Dec 2025 15:04:01 +0700 Subject: [PATCH 06/19] refactor(test): update structured response and structured test --- .../z/structured-basic-response-1.json | 28 +++++---- tests/Providers/Z/ZStructuredTest.php | 62 ++++++++++++++++--- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/tests/Fixtures/z/structured-basic-response-1.json b/tests/Fixtures/z/structured-basic-response-1.json index 28634ed67..bb99340c7 100644 --- a/tests/Fixtures/z/structured-basic-response-1.json +++ b/tests/Fixtures/z/structured-basic-response-1.json @@ -1,21 +1,25 @@ { - "id": "chatcmpl-123", - "object": "chat.completion", - "created": 1677652288, - "model": "z-model", "choices": [ { + "finish_reason": "stop", "index": 0, "message": { - "role": "assistant", - "content": "{\"name\": \"John\", \"age\": 30}" - }, - "finish_reason": "stop" + "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": { - "prompt_tokens": 9, - "completion_tokens": 12, - "total_tokens": 21 + "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/Providers/Z/ZStructuredTest.php b/tests/Providers/Z/ZStructuredTest.php index 8d08b235f..b08998d59 100644 --- a/tests/Providers/Z/ZStructuredTest.php +++ b/tests/Providers/Z/ZStructuredTest.php @@ -6,6 +6,8 @@ use Prism\Prism\Enums\Provider; use Prism\Prism\Facades\Prism; +use Prism\Prism\Schema\BooleanSchema; +use Prism\Prism\Schema\EnumSchema; use Prism\Prism\Schema\ObjectSchema; use Prism\Prism\Schema\StringSchema; use Tests\Fixtures\FixtureResponse; @@ -18,12 +20,43 @@ FixtureResponse::fakeResponseSequence('chat/completions', 'z/structured-basic-response'); $schema = new ObjectSchema( - 'person', - 'A person object', - [ - new StringSchema('name', 'The person\'s name'), + 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 + ), ], - ['name'] + requiredFields: ['message', 'action', 'status', 'is_question', 'move_to_next_question'] ); $response = Prism::structured() @@ -31,10 +64,21 @@ ->withSchema($schema) ->asStructured(); - expect($response->text)->toBe('{"name": "John", "age": 30}') - ->and($response->structured)->toBe(['name' => 'John', 'age' => 30]) - ->and($response->usage->promptTokens)->toBe(9) - ->and($response->usage->completionTokens)->toBe(12) + $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'); }); From 4907b6860770337e8e2f605b8800bdb8a2c3332d Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Mon, 15 Dec 2025 15:09:07 +0700 Subject: [PATCH 07/19] fix(test): update model to z-model --- tests/Providers/Z/ZStructuredTest.php | 2 +- tests/Providers/Z/ZTextTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Providers/Z/ZStructuredTest.php b/tests/Providers/Z/ZStructuredTest.php index b08998d59..7a4f4f98c 100644 --- a/tests/Providers/Z/ZStructuredTest.php +++ b/tests/Providers/Z/ZStructuredTest.php @@ -60,7 +60,7 @@ ); $response = Prism::structured() - ->using(Provider::Z, 'glm-4.6') + ->using(Provider::Z, 'z-model') ->withSchema($schema) ->asStructured(); diff --git a/tests/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index d4b8bf0d2..395a4fdf4 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -17,7 +17,7 @@ FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-a-prompt'); $response = Prism::text() - ->using(Provider::Z, 'glm-4.6') + ->using(Provider::Z, 'z-model') ->withPrompt('Hello!') ->asText(); From 08afc988a1faf67f25523d57dcccc4caed682f75 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Tue, 16 Dec 2025 18:28:43 +0700 Subject: [PATCH 08/19] feat(z): add tools parameters in text handler --- src/Providers/Z/Handlers/Text.php | 11 ++++---- src/Providers/Z/Maps/ToolChoiceMap.php | 39 ++++++++++++++++++++++++++ src/Providers/Z/Maps/ToolMap.php | 30 ++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 src/Providers/Z/Maps/ToolChoiceMap.php create mode 100644 src/Providers/Z/Maps/ToolMap.php diff --git a/src/Providers/Z/Handlers/Text.php b/src/Providers/Z/Handlers/Text.php index cdb0cc1ab..75ae1f0bd 100644 --- a/src/Providers/Z/Handlers/Text.php +++ b/src/Providers/Z/Handlers/Text.php @@ -12,6 +12,8 @@ use Prism\Prism\Exceptions\PrismException; use Prism\Prism\Providers\Z\Concerns\MapsFinishReason; use Prism\Prism\Providers\Z\Maps\MessageMap; +use Prism\Prism\Providers\Z\Maps\ToolChoiceMap; +use Prism\Prism\Providers\Z\Maps\ToolMap; use Prism\Prism\Text\Request; use Prism\Prism\Text\Response as TextResponse; use Prism\Prism\Text\ResponseBuilder; @@ -106,16 +108,15 @@ protected function sendRequest(Request $request): ClientResponse $payload = array_merge([ 'model' => $request->model(), 'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(), - 'max_tokens' => $request->maxTokens() ?? 2048, ], Arr::whereNotNull([ + 'max_tokens' => $request->maxTokens(), 'temperature' => $request->temperature(), 'top_p' => $request->topP(), - 'thinking' => [ - 'type' => 'disabled', - ], + 'tools' => ToolMap::map($request->tools()), + 'tool_choice' => ToolChoiceMap::map($request->toolChoice()), ])); - return $this->client->post('/chat/completions', $payload); + return $this->client->timeout(100)->post('/chat/completions', $payload); } /** 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); + } +} From 7e47a380f6ae8e721a33cf5cf8fd5c11524679bd Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Tue, 16 Dec 2025 19:38:59 +0700 Subject: [PATCH 09/19] feat(z): add generate text and tools with a system prompt in text test --- .../generate-text-with-multiple-tools-1.json | 46 +++++++++ .../generate-text-with-multiple-tools-2.json | 25 +++++ .../z/generate-text-with-system-prompt-1.json | 26 ++++++ tests/Providers/Z/ZTextTest.php | 93 ++++++++++++++++--- 4 files changed, 178 insertions(+), 12 deletions(-) create mode 100644 tests/Fixtures/z/generate-text-with-multiple-tools-1.json create mode 100644 tests/Fixtures/z/generate-text-with-multiple-tools-2.json create mode 100644 tests/Fixtures/z/generate-text-with-system-prompt-1.json 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-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/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 395a4fdf4..996906e7e 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -7,24 +7,93 @@ use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; use Prism\Prism\Facades\Prism; +use Prism\Prism\Facades\Tool; +use Prism\Prism\Text\Response as TextResponse; use Tests\Fixtures\FixtureResponse; beforeEach(function (): void { config()->set('prism.providers.z.api_key', env('Z_API_KEY', 'zai-123')); }); -it('Z provider handles text request', function (): void { - FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-a-prompt'); +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(); + $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'); + 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." + ); + }); }); From 1dc33ae2645dc967ce1ef4d3ca0216bb4bcc83d7 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Tue, 16 Dec 2025 19:52:31 +0700 Subject: [PATCH 10/19] ref(z): add text required tools test with specific tool choice and 429 --- ...nerate-text-with-required-tool-call-1.json | 37 ++++++++++++++++ tests/Providers/Z/ZTextTest.php | 42 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 tests/Fixtures/z/generate-text-with-required-tool-call-1.json 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/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 996906e7e..9b9bfbd43 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -4,8 +4,11 @@ namespace Tests\Providers\Z; +use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; +use Prism\Prism\Enums\ToolChoice; +use Prism\Prism\Exceptions\PrismRateLimitedException; use Prism\Prism\Facades\Prism; use Prism\Prism\Facades\Tool; use Prism\Prism\Text\Response as TextResponse; @@ -97,3 +100,42 @@ ); }); }); + +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); From c04643c4355dadea40b23068e57aa7c5f78e9b69 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Tue, 16 Dec 2025 20:05:46 +0700 Subject: [PATCH 11/19] patch(z): fix Finish reason map to not use XAI --- src/Providers/Z/Concerns/MapsFinishReason.php | 2 +- src/Providers/Z/Maps/FinishReasonMap.php | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 src/Providers/Z/Maps/FinishReasonMap.php diff --git a/src/Providers/Z/Concerns/MapsFinishReason.php b/src/Providers/Z/Concerns/MapsFinishReason.php index 620dd77aa..52d1bf7dc 100644 --- a/src/Providers/Z/Concerns/MapsFinishReason.php +++ b/src/Providers/Z/Concerns/MapsFinishReason.php @@ -5,7 +5,7 @@ namespace Prism\Prism\Providers\Z\Concerns; use Prism\Prism\Enums\FinishReason; -use Prism\Prism\Providers\XAI\Maps\FinishReasonMap; +use Prism\Prism\Providers\Z\Maps\FinishReasonMap; trait MapsFinishReason { 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, + }; + } +} From 4d85a3e15edf5aacba58ddc5e35f5327e0e1e22e Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Sat, 20 Dec 2025 15:10:51 +0700 Subject: [PATCH 12/19] fix: phpstan --- src/Providers/Z/Handlers/Structured.php | 5 +- src/Providers/Z/Handlers/Text.php | 5 +- src/Providers/Z/Maps/MessageMap.php | 1 + src/Providers/Z/Maps/StructuredMap.php | 27 ++-------- src/Providers/Z/Support/ZAIJSONEncoder.php | 61 ++++++++++++++++++++-- 5 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/Providers/Z/Handlers/Structured.php b/src/Providers/Z/Handlers/Structured.php index 355b2a0b4..2140d30c0 100644 --- a/src/Providers/Z/Handlers/Structured.php +++ b/src/Providers/Z/Handlers/Structured.php @@ -66,7 +66,10 @@ protected function sendRequest(Request $request): ClientResponse 'top_p' => $request->topP(), ])); - return $this->client->post('chat/completions', $payload); + /** @var ClientResponse $response */ + $response = $this->client->post('chat/completions', $payload); + + return $response; } /** diff --git a/src/Providers/Z/Handlers/Text.php b/src/Providers/Z/Handlers/Text.php index 75ae1f0bd..57edff348 100644 --- a/src/Providers/Z/Handlers/Text.php +++ b/src/Providers/Z/Handlers/Text.php @@ -116,7 +116,10 @@ protected function sendRequest(Request $request): ClientResponse 'tool_choice' => ToolChoiceMap::map($request->toolChoice()), ])); - return $this->client->timeout(100)->post('/chat/completions', $payload); + /** @var ClientResponse $response */ + $response = $this->client->post('/chat/completions', $payload); + + return $response; } /** diff --git a/src/Providers/Z/Maps/MessageMap.php b/src/Providers/Z/Maps/MessageMap.php index 0e4ee961d..163aa198d 100644 --- a/src/Providers/Z/Maps/MessageMap.php +++ b/src/Providers/Z/Maps/MessageMap.php @@ -49,6 +49,7 @@ protected function mapMessage(Message $message): void AssistantMessage::class => $this->mapAssistantMessage($message), ToolResultMessage::class => $this->mapToolResultMessage($message), SystemMessage::class => $this->mapSystemMessage($message), + default => throw new \InvalidArgumentException('Unsupported message type: '.$message::class), }; } diff --git a/src/Providers/Z/Maps/StructuredMap.php b/src/Providers/Z/Maps/StructuredMap.php index 29770b34f..20e5e93e6 100644 --- a/src/Providers/Z/Maps/StructuredMap.php +++ b/src/Providers/Z/Maps/StructuredMap.php @@ -13,30 +13,9 @@ class StructuredMap extends MessageMap public function __construct(array $messages, array $systemPrompts, private readonly Schema $schema) { parent::__construct($messages, $systemPrompts); - } - - #[\Override] - protected function mapSystemMessage(SystemMessage $message): void - { - $scheme = $this->schema; - - $structured = ZAIJSONEncoder::jsonEncode($scheme); - - $this->mappedMessages[] = [ - 'role' => 'system', - 'content' => <<content - PROMPT, - ]; + $this->messages[] = new SystemMessage( + content: 'Response Format in JSON following:'.ZAIJSONEncoder::jsonEncode($this->schema) + ); } } diff --git a/src/Providers/Z/Support/ZAIJSONEncoder.php b/src/Providers/Z/Support/ZAIJSONEncoder.php index 9245b7dd5..b99145af0 100644 --- a/src/Providers/Z/Support/ZAIJSONEncoder.php +++ b/src/Providers/Z/Support/ZAIJSONEncoder.php @@ -4,6 +4,7 @@ namespace Prism\Prism\Providers\Z\Support; +use Prism\Prism\Contracts\Schema; use Prism\Prism\Schema\ArraySchema; use Prism\Prism\Schema\BooleanSchema; use Prism\Prism\Schema\EnumSchema; @@ -13,6 +14,10 @@ class ZAIJSONEncoder { + /** + * @param Schema $schema + * @return array + */ public static function encodeSchema($schema): array { if ($schema instanceof ObjectSchema) { @@ -22,7 +27,7 @@ public static function encodeSchema($schema): array return self::encodePropertySchema($schema); } - public static function jsonEncode($schema, bool $prettyPrint = true): string + public static function jsonEncode(Schema $schema, bool $prettyPrint = true): string { $encoded = self::encodeSchema($schema); @@ -31,9 +36,17 @@ public static function jsonEncode($schema, bool $prettyPrint = true): string $flags |= JSON_PRETTY_PRINT; } - return json_encode($encoded, $flags); + $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 = [ @@ -41,8 +54,14 @@ protected static function encodeObjectSchema(ObjectSchema $schema): array 'properties' => [], ]; + if (isset($schema->description)) { + $jsonSchema['description'] = $schema->description; + } + foreach ($schema->properties as $property) { - $jsonSchema['properties'][$property->name] = self::encodePropertySchema($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 !== []) { @@ -53,19 +72,37 @@ protected static function encodeObjectSchema(ObjectSchema $schema): array $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; } @@ -75,14 +112,30 @@ protected static function encodePropertySchema($property): array } 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 (isset($property->nullable) && $property->nullable) { + if (property_exists($property, 'nullable') && $property->nullable !== null && $property->nullable) { $schema['type'] = [$schema['type'] ?? 'string', 'null']; } From 5026c1b8781fd7634043990f0367646d01dba784 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Sun, 21 Dec 2025 17:29:44 +0700 Subject: [PATCH 13/19] feat(z): add document, video, image url support --- src/Providers/Z/Enums/DocumentType.php | 12 ++++++ src/Providers/Z/Maps/DocumentMapper.php | 45 ++++++++++++++++++++ src/Providers/Z/Maps/MessageMap.php | 14 ++++++- src/Providers/Z/Support/ZAIJSONEncoder.php | 3 +- tests/Fixtures/z/text-image-from-url-1.json | 26 ++++++++++++ tests/Providers/Z/ZTextTest.php | 46 ++++++++++++++++++++- 6 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 src/Providers/Z/Enums/DocumentType.php create mode 100644 src/Providers/Z/Maps/DocumentMapper.php create mode 100644 tests/Fixtures/z/text-image-from-url-1.json 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 @@ + + */ + 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/MessageMap.php b/src/Providers/Z/Maps/MessageMap.php index 163aa198d..564c61628 100644 --- a/src/Providers/Z/Maps/MessageMap.php +++ b/src/Providers/Z/Maps/MessageMap.php @@ -5,6 +5,9 @@ namespace Prism\Prism\Providers\Z\Maps; use Prism\Prism\Contracts\Message; +use Prism\Prism\Providers\Z\Enums\DocumentType; +use Prism\Prism\ValueObjects\Media\Document; +use Prism\Prism\ValueObjects\Media\Media; use Prism\Prism\ValueObjects\Messages\AssistantMessage; use Prism\Prism\ValueObjects\Messages\SystemMessage; use Prism\Prism\ValueObjects\Messages\ToolResultMessage; @@ -74,9 +77,18 @@ protected function mapToolResultMessage(ToolResultMessage $message): void 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' => $message->text(), + 'content' => [ + ...$images, + ...$documents, + ...$videos, + ['type' => 'text', 'text' => $message->text()], + ], ]; } diff --git a/src/Providers/Z/Support/ZAIJSONEncoder.php b/src/Providers/Z/Support/ZAIJSONEncoder.php index b99145af0..a57aa5d96 100644 --- a/src/Providers/Z/Support/ZAIJSONEncoder.php +++ b/src/Providers/Z/Support/ZAIJSONEncoder.php @@ -15,10 +15,9 @@ class ZAIJSONEncoder { /** - * @param Schema $schema * @return array */ - public static function encodeSchema($schema): array + public static function encodeSchema(Schema $schema): array { if ($schema instanceof ObjectSchema) { return self::encodeObjectSchema($schema); 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/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 9b9bfbd43..2266c0871 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -4,6 +4,7 @@ namespace Tests\Providers\Z; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Prism\Prism\Enums\FinishReason; use Prism\Prism\Enums\Provider; @@ -12,10 +13,12 @@ use Prism\Prism\Facades\Prism; use Prism\Prism\Facades\Tool; use Prism\Prism\Text\Response as TextResponse; +use Prism\Prism\ValueObjects\Media\Image; +use Prism\Prism\ValueObjects\Messages\UserMessage; use Tests\Fixtures\FixtureResponse; beforeEach(function (): void { - config()->set('prism.providers.z.api_key', env('Z_API_KEY', 'zai-123')); + config()->set('prism.providers.z.api_key', env('Z_API_KEY', 'f4ce7d9da2b5415390b92d0a05ca420e.5tM4uBhy6AfFgcek')); }); describe('Text generation for Z', function (): void { @@ -101,6 +104,47 @@ }); }); +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('handles specific tool choice', function (): void { FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-required-tool-call'); From 8ad6b2f36c8286424bce4dd0d70848d9598bed94 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Sun, 21 Dec 2025 17:31:12 +0700 Subject: [PATCH 14/19] patch(z): fix message map test --- tests/Providers/Z/MessageMapTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Providers/Z/MessageMapTest.php b/tests/Providers/Z/MessageMapTest.php index a60828b78..2fe20c6e1 100644 --- a/tests/Providers/Z/MessageMapTest.php +++ b/tests/Providers/Z/MessageMapTest.php @@ -38,7 +38,12 @@ ]) ->and($mapped[1])->toBe([ 'role' => 'user', - 'content' => 'Hello, how are you?', + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello, how are you?', + ], + ], ]) ->and($mapped[2])->toBe([ 'role' => 'assistant', From c30ad2ca04cc03e244e402b566fbb7c823486f35 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Sun, 21 Dec 2025 17:50:11 +0700 Subject: [PATCH 15/19] feat(z): add file url test --- tests/Fixtures/z/text-file-from-url-1.json | 26 ++++++++++++++ tests/Providers/Z/ZTextTest.php | 40 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/Fixtures/z/text-file-from-url-1.json 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/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 2266c0871..1efe32e6a 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -13,6 +13,7 @@ use Prism\Prism\Facades\Prism; use Prism\Prism\Facades\Tool; use Prism\Prism\Text\Response as TextResponse; +use Prism\Prism\ValueObjects\Media\Document; use Prism\Prism\ValueObjects\Media\Image; use Prism\Prism\ValueObjects\Messages\UserMessage; use Tests\Fixtures\FixtureResponse; @@ -143,6 +144,45 @@ 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('handles specific tool choice', function (): void { From abc011564b7363be1d690c14077131f1e4c4bac4 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Sun, 21 Dec 2025 17:51:16 +0700 Subject: [PATCH 16/19] patch(z): remove token --- tests/Providers/Z/ZTextTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Providers/Z/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 1efe32e6a..1bbf96cd7 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -19,7 +19,7 @@ use Tests\Fixtures\FixtureResponse; beforeEach(function (): void { - config()->set('prism.providers.z.api_key', env('Z_API_KEY', 'f4ce7d9da2b5415390b92d0a05ca420e.5tM4uBhy6AfFgcek')); + config()->set('prism.providers.z.api_key', env('Z_API_KEY', 'zai-123')); }); describe('Text generation for Z', function (): void { From 57210f9fc99c496954a7df02bdb0ef3a3f27afd0 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Sun, 21 Dec 2025 17:56:03 +0700 Subject: [PATCH 17/19] feat(z): add text video from URL test --- tests/Fixtures/z/text-video-from-url-1.json | 26 ++++++++++++++ tests/Providers/Z/ZTextTest.php | 40 +++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/Fixtures/z/text-video-from-url-1.json 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/ZTextTest.php b/tests/Providers/Z/ZTextTest.php index 1bbf96cd7..85e083811 100644 --- a/tests/Providers/Z/ZTextTest.php +++ b/tests/Providers/Z/ZTextTest.php @@ -15,6 +15,7 @@ use Prism\Prism\Text\Response as TextResponse; use Prism\Prism\ValueObjects\Media\Document; use Prism\Prism\ValueObjects\Media\Image; +use Prism\Prism\ValueObjects\Media\Video; use Prism\Prism\ValueObjects\Messages\UserMessage; use Tests\Fixtures\FixtureResponse; @@ -183,6 +184,45 @@ 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 { From 383d56eb2f2a124653752b3863434376a77a8e37 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Tue, 23 Dec 2025 01:17:29 +0700 Subject: [PATCH 18/19] feat(z): add docs --- docs/.vitepress/config.mts | 4 ++++ 1 file changed, 4 insertions(+) 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", + }, ], }, { From 058c3c1efadb6b860c28326928113176126158a4 Mon Sep 17 00:00:00 2001 From: Kevin Abrar Khansa Date: Tue, 23 Dec 2025 01:17:40 +0700 Subject: [PATCH 19/19] feat(z): add docs --- docs/providers/z.md | 248 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/providers/z.md 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.