Search by

sanmai / typesafe-ai-php

sanmai

TypeSafe AI API PHP SDK

Package info

github.com/sanmai/typesafe-ai-php

pkg:composer/sanmai/typesafe-ai-php

Fund package maintenance!

sanmai

Statistics

Installs: 44

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.2.2 2026-09-22 09:08 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:

  • TypeSafeClient sends requests. Build it with the static createInstance() factory.
  • SystemOneRequest retains the state/questions to evaluate. Easy to build using a fluent interface.
  • SystemOneResult retains 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