DEV Community

Cover image for Adding a New LLM Provider to Prism PHP: How I Shipped Z.AI Support
kevariable
kevariable

Posted on

Adding a New LLM Provider to Prism PHP: How I Shipped Z.AI Support

Prism is the Laravel package for talking to LLMs with one fluent API. It ships with OpenAI, Anthropic, Gemini, Groq, Mistral, xAI, Perplexity and more. It did not ship with Z.AI, the provider behind the GLM models, so I opened a PR to add it.

That PR is now merged: prism-php/prism#794. 28 files, ~1.5k lines, text generation, tool calling, multimodal input and structured output.

This post is that PR broken down into the parts you actually need if you want to add your own provider.

The mental model

Prism separates three things:

  1. The provider class, which owns the HTTP client and nothing else.
  2. Handlers, one per capability (text, structured, embeddings, ...). A handler builds a payload, sends it and turns the raw JSON into Prism value objects.
  3. Maps, tiny stateless classes that translate one Prism concept into one provider concept. Messages, tools, tool choice, finish reasons, media.

Every provider in the repo follows this shape. If you copy the shape, review goes fast. If you invent your own, it does not.

Step 1: register the provider

Three small edits get Prism to resolve Provider::Z.

The enum:

enum Provider: string
{
    case Z = 'z';
}
Enter fullscreen mode Exit fullscreen mode

The config, in config/prism.php:

'z' => [
    'url' => env('Z_URL', 'https://api.z.ai/api/coding/paas/v4'),
    'api_key' => env('Z_API_KEY', ''),
],
Enter fullscreen mode Exit fullscreen mode

And a factory method on PrismManager. The manager resolves create{Name}Provider by convention, so the method name is the whole wiring:

protected function createZProvider(array $config): Z
{
    return new Z(
        apiKey: $config['api_key'],
        url: $config['url'],
    );
}
Enter fullscreen mode Exit fullscreen mode

Step 2: the provider class

Z extends Prism's abstract Provider and only decides which handler runs. Note #[\SensitiveParameter] on the API key so it never leaks into a stack trace:

class Z extends Provider
{
    use InitializesClient;

    public function __construct(
        #[\SensitiveParameter] public readonly string $apiKey,
        public readonly string $url,
    ) {}

    #[\Override]
    public function text(TextRequest $request): Response
    {
        $handler = new Handlers\Text(
            $this->client($request->clientOptions(), $request->clientRetry())
        );

        return $handler->handle($request);
    }

    #[\Override]
    public function structured(StructuredRequest $request): StructuredResponse
    {
        $handler = new Handlers\Structured(
            $this->client($request->clientOptions(), $request->clientRetry())
        );

        return $handler->handle($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

One thing I changed during review: I dropped final and made client() and the encoder methods protected. People extend providers to point at gateways and proxies. Sealing the class blocks that for no benefit.

Step 3: mapping messages

This is where providers actually differ. Z.AI speaks an OpenAI shaped chat API, but the media parts are its own thing.

MessageMap merges system prompts in front of the conversation, then dispatches on message class:

protected function mapMessage(Message $message): void
{
    match ($message::class) {
        UserMessage::class => $this->mapUserMessage($message),
        AssistantMessage::class => $this->mapAssistantMessage($message),
        ToolResultMessage::class => $this->mapToolResultMessage($message),
        SystemMessage::class => $this->mapSystemMessage($message),
        default => throw new \InvalidArgumentException('Unsupported message type: '.$message::class),
    };
}
Enter fullscreen mode Exit fullscreen mode

The interesting one is the user message, because Z.AI takes images, files and videos as separate typed content parts rather than one generic attachment:

protected function mapUserMessage(UserMessage $message): void
{
    $images = array_map(fn (Media $media): array => (new DocumentMapper($media, DocumentType::ImageUrl))->toPayload(), $message->images());
    $documents = array_map(fn (Document $document): array => (new DocumentMapper($document, DocumentType::FileUrl))->toPayload(), $message->documents());
    $videos = array_map(fn (Media $media): array => (new DocumentMapper($media, DocumentType::VideoUrl))->toPayload(), $message->videos());

    $this->mappedMessages[] = [
        'role' => 'user',
        'content' => [
            ...$images,
            ...$documents,
            ...$videos,
            ['type' => 'text', 'text' => $message->text()],
        ],
    ];
}
Enter fullscreen mode Exit fullscreen mode

The three media types collapse into one mapper plus an enum, because the payload shape is identical apart from the key:

enum DocumentType: string
{
    case FileUrl = 'file_url';
    case VideoUrl = 'video_url';
    case ImageUrl = 'image_url';
}

public function toPayload(): array
{
    return [
        'type' => $this->type->value,
        $this->type->value => [
            'url' => $this->media->url(),
        ],
    ];
}
Enter fullscreen mode Exit fullscreen mode

validateMedia() returns $this->media->isUrl(), which makes the constraint explicit: this endpoint accepts URLs, not base64 blobs. Prism throws a clear exception instead of the API returning a vague 400.

Step 4: tools

Two more maps. ToolMap converts a Prism Tool into a function definition, and ToolChoiceMap handles the three forcing modes:

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',
    };
}
Enter fullscreen mode Exit fullscreen mode

A string means "call this specific tool". ToolChoice::Any becomes required. Anything else is rejected loudly rather than silently sent and ignored by the API.

Step 5: the text handler and the agent loop

The handler sends the request, then branches on the finish reason. This is the whole multi step tool loop:

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'),
    };
}
Enter fullscreen mode Exit fullscreen mode

handleToolCalls executes the tools, appends a ToolResultMessage, records a step, then recurses while steps->count() < $request->maxSteps(). The step cap lives in the request, so a runaway agent stops on the user's terms rather than the provider's.

Two defensive touches worth stealing:

  • If the finish reason says tool_calls but the array is empty, throw. A malformed response should not turn into a silent empty answer.
  • An unmapped finish reason throws too, instead of falling through to "stop". I originally reused xAI's finish reason map by accident, and one of the review commits was fixing exactly that.

Payload building uses Arr::whereNotNull so optional parameters simply disappear when unset:

$payload = array_merge([
    'model' => $request->model(),
    'messages' => (new MessageMap($request->messages(), $request->systemPrompts()))(),
], Arr::whereNotNull([
    'max_tokens' => $request->maxTokens(),
    'temperature' => $request->temperature(),
    'top_p' => $request->topP(),
    'tools' => ToolMap::map($request->tools()),
    'tool_choice' => ToolChoiceMap::map($request->toolChoice()),
]));
Enter fullscreen mode Exit fullscreen mode

Step 6: structured output without native schema support

This was the one real design decision in the PR.

OpenAI has strict JSON schema mode. The Z.AI coding endpoint has response_format: json_object, which guarantees valid JSON but not your JSON. So the schema goes in as a final system message, and StructuredMap just extends MessageMap to append it:

class StructuredMap extends MessageMap
{
    public function __construct(array $messages, array $systemPrompts, private readonly Schema $schema)
    {
        parent::__construct($messages, $systemPrompts);

        $this->messages[] = new SystemMessage(
            content: 'Response Format in JSON following:'.json_encode($this->schema->toArray(), JSON_PRETTY_PRINT)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The request also sends thinking: ['type' => 'disabled'], because reasoning traces leaking into a JSON payload is exactly the failure mode you do not want here.

Subclassing the message map rather than copying it means multimodal structured requests keep working for free.

Step 7: tests with recorded fixtures

Prism tests providers against recorded HTTP responses, not live APIs. FixtureResponse::fakeResponseSequence replays a numbered sequence, which is what makes multi step tool tests possible:

it('can generate text using multiple tools and multiple steps', function (): void {
    FixtureResponse::fakeResponseSequence('chat/completions', 'z/generate-text-with-multiple-tools');

    $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?')
        ->asText();

    expect($response->steps[0]->toolCalls)->toHaveCount(2)
        ->and($response->steps[0]->toolCalls[0]->name)->toBe('search_games');
});
Enter fullscreen mode Exit fullscreen mode

Nine fixtures in total, covering a plain prompt, a system prompt, parallel tool calls, a forced tool call, a 429 rate limit, image, file and video URLs, plus structured output. One of my later commits is literally patch(z): remove token, so: scrub your recorded fixtures before committing them.

Using it

$response = Prism::text()
    ->using('z', 'glm-4.6')
    ->withPrompt('Write a short story about a robot learning to love')
    ->asText();
Enter fullscreen mode Exit fullscreen mode

Multimodal, with glm-4.6v:

$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();
Enter fullscreen mode Exit fullscreen mode

Set Z_API_KEY and you are done.

What I would tell past me

Read a neighbouring provider first. I got the structure right by following the existing providers, and the review comments were about details, not architecture.

Do not copy a map without reading it. The xAI finish reason map compiled fine and was wrong.

Docs are part of the PR. A provider page under docs/providers/ plus a sidebar entry in the VitePress config. Maintainers should not have to ask.

Expect the merge to take a while. I opened this in December and it merged in March, with the maintainer adding formatting and refactor commits on top. That is normal for a repo where every provider is a long term maintenance commitment.

The full diff is on GitHub: prism-php/prism#794.

Top comments (0)