sanmai / typesafe-ai-php
TypeSafe AI API PHP SDK
Fund package maintenance!
Requires
- php: ^8.2
- ext-json: *
- caseyamcl/guzzle_retry_middleware: ^2.9
- guzzlehttp/guzzle: ^6.3 || ^7.0
- jms/serializer: ^3.29
- psr/log: ^1.1 || ^2 || ^3
- sanmai/json-serializer: ^0.2.10
Requires (Dev)
- ergebnis/composer-normalize: >=2.23
- esi/phpunit-coverage-check: >2
- friendsofphp/php-cs-fixer: ^3
- infection/infection: ^0.31
- php-coveralls/php-coveralls: ^2.4
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: >=0.10
- phpunit/phpunit: ^9.3
- sanmai/phpstan-rules: ^0.3.14
- sanmai/pipeline: >=6.11
Suggests
- doctrine/cache: For metadata caching
- monolog/monolog: For advanced logging and debugging
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-22 09:09:28 UTC
README
A PHP client for the TypeSafe AI evaluation API: provide a state and a set of typed questions, get back one typed answer for each question. As easy as that.
composer require sanmai/typesafe-ai-php
Requires PHP 8.2 or newer.
Something amiss? Open an issue, or, even better, send a PR!
Overview
There are three kinds of objects you work with:
TypeSafeClientsends requests. Build it with the staticcreateInstance()factory.SystemOneRequestretains the state/questions to evaluate. Easy to build using a fluent interface.SystemOneResultretains one answer for each question, mapped to the ID of the original question.
Usage
To get started, point an agent at this README. If you are an agent or a curious human, read on.
Building a Client
use TypeSafeAI\TypeSafeClient; $client = TypeSafeClient::createInstance($apiKey);
The API key is optional: without one the client reads TYPESAFE_API_KEY, and throws InvalidArgumentException when that is not set either. TYPESAFE_BASE_URL overrides the API root the same way, following in the steps of JS and Python SDKs.
$client = TypeSafeClient::createInstance();
Using Result Classes
Annotate any class with attributes, add Answer subclasses as parameter types, fire up evaluate() that will send the question, and return an instance of the class with all parameters assigned their respective answers. This is the recommended way to use the SDK.
use TypeSafeAI\DTO\ChoiceAnswer; use TypeSafeAI\DTO\NoulAnswer; use TypeSafeAI\Question\Choice; use TypeSafeAI\Question\Noul; use TypeSafeAI\Question\NoulCriteria; class TicketDecision { public function __construct( #[Noul('Does this convey urgency?', new NoulCriteria(true: 'Explicitly time-sensitive'))] public readonly NoulAnswer $is_urgent, #[Choice('Which team should handle this?', [ 'billing' => 'Payments, invoicing, refunds', 'technical' => 'Bugs, outages, integrations', 'sales' => null, ])] public readonly ChoiceAnswer $department, ) {} } $decision = $client->evaluate('Help! My payouts have been failing for 3 days.', TicketDecision::class); $decision->is_urgent->noul; // 0.95 $decision->department->choice; // 'billing'
Provide the third argument to select the model:
$decision = $client->evaluate($ticket, TicketDecision::class, 'jev-preview');
For the token counts or the resolved model version, send a SystemOneRequest with systemOne() as in the examples below.
Custom Requests
There are three question types:
- Noul is a yes/no question. The answer is the probability of yes, from 0 to 1.
- Choice picks one option from the set. Each option can have an optional description.
- Score evaluates the state against the ordered levels, from the lowest to the highest.
use TypeSafeAI\SystemOneRequest; $request = SystemOneRequest::build('Help! My payouts have been failing for 3 days.') ->noul( 'is_urgent', 'Does this convey urgency?', true: 'Explicitly time-sensitive', false: 'No urgency expressed', ) ->choice('department', 'Which team should handle this?', [ 'billing' => 'Payments, invoicing, refunds', 'technical' => 'Bugs, outages, integrations', 'sales' => null, ]) ->score('frustration', 'How frustrated is the customer?', [ 'Calm', 'Frustrated', 'Very angry', ]); $response = $client->systemOne($request);
The state and the instructions can be a string, or structured data such as an array or an object. Objects are sent with their private properties: if you need full control, convert the object to an array first.
Every description takes the same range of values: text, a JSON object, an array, or null.
$request = SystemOneRequest::build($ticket) ->choice('department', 'Which team should handle this?', [ 'billing' => [ 'what' => 'Charges, invoices, refunds, or subscriptions', 'not_for' => 'Order tracking or account access', 'examples' => ['I was charged twice', 'Where is my refund?'], ], 'sales' => null, ]);
instructions is optional, so a question can lean on its criteria alone. The API requires at least one of the two.
use TypeSafeAI\Question\Noul; use TypeSafeAI\Question\NoulCriteria; $request = new SystemOneRequest($state, questions: [ 'is_urgent' => new Noul( 'Does this convey urgency?', new NoulCriteria(false: 'No urgency expressed'), ), ]);
Requests use the jev-latest model by default; to use a different model, provide it as the second argument to build() or the constructor.
Reading Answers
Each answer type has its own accessor, so the IDE will tip you on the available fields:
$urgent = $response->noul('is_urgent'); $urgent->noul; // 0.95 $department = $response->choice('department'); $department->choice; // 'billing' $department->probabilities; // ['sales' => 0.0, 'technical' => 0.11, 'billing' => 0.89] $department->confidence; // 0.82 $frustration = $response->score('frustration'); $frustration->score; // 1.04 $frustration->legend; // [0 => 'Calm', 1 => 'Frustrated', 2 => 'Very angry'] $frustration->probabilities; // [0 => 0.0, 1 => 0.96, 2 => 0.04] $frustration->confidence; // 0.94 $response->model; // 'jev-N.NN', the actual model version $response->usage->input_tokens; // 414
An accessor throws UnexpectedValueException if there is no answer with that ID, or if the answer has a different type.
All answers are also available in $response->answers, keyed by question ID.
Each answer also has a type field, useful when you walk $response->answers:
$urgent->type; // 'noul'
Listing Models
models() returns the models available to your account:
foreach ($client->models()->models as $model) { echo "{$model->name}: {$model->description} ({$model->release_date})\n"; }
Custom Question Types
SystemOneRequest::ask() extends the request with any object that implements the TypeSafeAI\Question\Question marker interface. The noul(), choice(), and score() methods are shortcuts for ask() with the bundled Noul, Choice, and Score classes.
Questions are plain data objects. The client serializes their properties to JSON, null values included.
Examples
Check out the examples directory. Examples use the API key from the TYPESAFE_API_KEY environment variable:
TYPESAFE_API_KEY=your-api-key php examples/urgency.php
- urgency.php: a yes/no question.
- routing.php: a choice between teams, with the probability of each option.
- frustration.php: a score along ordered levels.
- chat-log.php: questions of all three types about a chat log, in one request.
- attributes.php: a result declaring its own questions using attributes.
- errors.php: an invalid request, and the validation error as returned by the API.
Errors and Retries
The client retries 408 Request Timeout, 429 Too Many Requests, every 5xx response, and connection timeouts, twice at most. Other errors throw Guzzle exceptions: a ClientException for 401 Unauthorized (check your API key), for 400 Bad Request (e.g. for choices without options), and for 422 Unprocessable Entity (the error will hint at the failed field).
use GuzzleHttp\Exception\ClientException; try { $response = $client->systemOne($request); } catch (ClientException $e) { $error = $e->getResponse(); echo "Request failed (HTTP {$error->getStatusCode()}): {$error->getBody()}\n"; }
Client Configuration
Requests time out after 10 seconds by default, with an option to raise the timeout through $clientOptions.
createInstance() takes optional $extraHeaders, $retryOptions, and $clientOptions arrays after the API key. Use them to send extra headers (a custom User-Agent, for example), to tune the bundled retry middleware, or to override defaults such as base_uri, timeout, or connect_timeout.
Debug Logging
TypeSafeClient accepts any PSR-3 logger through setLogger().
$client->setLogger($psrLogger);
Development
To run all tests:
make -j -k