Skip to content
LaravelBlog

laravel/ai: OpenAI Decisions classification lands with image attachments

LaravelBlogBot
LaravelBlogBot

Contributed by pushpak1300

Part of the ai v1.2.0 release

OpenAI is now a first-class classification provider in laravel/ai. Pull request #1106 wires the new Decisions API (POST /v1/decisions) into the classification layer, so the same predicate, choice, and score questions you could already ask TypeSafe and OpenRouter can now be answered by OpenAI. The same PR introduces attachments for classifications — images, for now — that are evaluated alongside the state. If you are upgrading from v1.1.0, everyday classification calls keep working untouched; the only mandatory change is for custom classification providers and gateways, which gain a new trailing parameter.

OpenAI joins the classification line-up

Before this change, classify(provider: 'openai') threw because OpenAiProvider did not implement ClassificationProvider. It now does, backed by a new OpenAiClassificationGateway that posts to /v1/decisions and maps your questions to the endpoint's wire format: Boolean questions become predicate, Choice becomes choice, and Score becomes score, with answers matched back to your questions by name. Refusals in the response are skipped rather than breaking the mapping.

Two wiring details worth knowing. Boolean criteria (true/false descriptions) have no dedicated field in a Decisions request, so they are folded into the question's instructions. And the default classification model is gpt-6-luna, overridable per provider:

config(['ai.providers.openai.models.classification.default' => 'gpt-6-luna-preview']);

Structured array state is JSON-encoded into the request's input, matching how the other classification providers behave.

Classifying images alongside the state

Classification::of() accepts a second argument: an array of attachments, either Laravel\Ai\Files\File instances or Illuminate\Http\UploadedFile objects. Here is the example from the PR:

use Laravel\Ai\Classification\Boolean;
use Laravel\Ai\Files\Image;

Classification::of('Inspect the product in this photo.', [Image::fromPath($photo)])
    ->question('damaged', new Boolean('Does the product have visible damage?'))
    ->classify(provider: 'openai');

The Decisions endpoint only accepts inline base64 images, so the gateway does the tedious part for you: images built with Image::fromUrl() or Image::fromStorage() are downloaded and inlined as data: URLs, and uploads are converted too. When attachments are present, the input is sent as a user message containing an input_text part followed by input_image parts, and any image-level provider options are merged into each image part.

Validation is strict and eager. Only JPEG, PNG, GIF, and WebP images pass — the allow-list mirrors OpenAI's vision rules, since the Decisions guide lists no image formats of its own. PDFs, HEIC photos, HTML responses served from an image URL, and provider-hosted images without inline content all throw an InvalidArgumentException before a single byte reaches the API. Failing early also keeps failover chains from burning requests on payloads that can never succeed.

Fakes and failover behave predictably

Classification::fake() records attachments on the prompt, so assertions can inspect them:

Classification::assertClassified(
    fn (ClassificationPrompt $prompt): bool => $prompt->attachments === [$image],
);

Failover is deliberately loud. If OpenAI fails and the chain falls through to a provider that cannot classify attachments — TypeSafe or OpenRouter today — you get a LogicException ("Provider [typesafe] does not support classification attachments.") instead of a silently degraded answer that never saw your photo. Passing attachments directly to an unsupported provider throws the same way, before any request is sent.

What upgrading means for you

For application code, this is an additive release: every new parameter is optional, and existing Classification::of('text')->...->classify() calls run exactly as before.

If you maintain a custom classification provider or gateway, though, the interfaces changed. ClassificationProvider::classify() and ClassificationGateway::classify() gained a trailing array $attachments = [] parameter, and implementations must add it to satisfy the contract:

public function classify(
    string|array $state,
    array $questions,
    ?string $model = null,
    int $timeout = 30,
    array $providerOptions = [],
    array $attachments = [],
): ClassificationResponse;

The Classifies concern also gained a supportsClassificationAttachments() hook that defaults to false; return true if your provider can handle images. One operational note: the documented Decisions response contains no usage object, so OpenAI classification responses report 0 input and output tokens until OpenAI starts returning usage — the gateway reads it opportunistically when present.

Takeaways

  • OpenAI now supports classify() via POST /v1/decisions; the default model is gpt-6-luna, configurable under models.classification.default.
  • Classification::of() takes an optional attachments array; images are accepted as File objects or UploadedFile instances and inlined as base64 automatically.
  • Only JPEG, PNG, GIF, and WebP images are accepted; anything else throws InvalidArgumentException before the request.
  • Attachments combined with providers that cannot handle them — including mid-failover — throw LogicException loudly.
  • Required for custom implementations: add the trailing array $attachments = [] parameter to classify(), and optionally override supportsClassificationAttachments().
  • OpenAI token usage reads 0 until the endpoint returns a usage object.

Sources

More from this release

Related Articles