Search by

phox / typesafe-sdk-php

phox

A PHP SDK for the TypeSafe AI API, direct or through OpenRouter

Package info

github.com/Fox-Islam/typesafe-sdk-php

pkg:composer/phox/typesafe-sdk-php

Statistics

Installs: 237

Dependents: 5

Suggesters: 1

Stars: 0

Open Issues: 0

v0.5.2 2026-10-16 02:38 UTC

This package is auto-updated.

Last update: 2026-11-06 09:52:34 UTC


README

A PHP SDK for the TypeSafe AI API.

Ask a model named questions about a piece of text or a JSON structure, and get back answers shaped by the questions you asked — a probability for a yes/no question, a label for a choice, a level on a rubric for a score.

Jev is reachable two ways: directly from TypeSafe, or through OpenRouter. One call switches between them and nothing else about your code changes.

Install

composer require phox/typesafe-sdk-php

Requires PHP 8.3 or newer.

Quickstart

Set TYPESAFE_API_KEY in your environment (or OPENROUTER_API_KEY — see Providers), then:

use Phox\TypeSafe\Client;

$client = new Client();

$response = $client->systemOne()
    ->state('I was charged twice. Please fix this ASAP.')
    ->choice('category', ['billing', 'technical', 'other'], 'What is this ticket about?')
    ->send();

echo $response->choice('category')->choice();      // 'billing'
echo $response->choice('category')->confidence();  // 0.91

Providers

Jev is served both by TypeSafe directly and by OpenRouter's System One endpoint. The answer bodies are the same on both, and so are the request bodies apart from images, so the provider decides the host, the path, which key is read and how images are sent:

$client = Client::make()->openRouter();   // reads OPENROUTER_API_KEY

or set TYPESAFE_PROVIDER=openrouter and construct the client normally.

TypeSafe OpenRouter
Host https://api.typesafe.ai https://openrouter.ai
Path /v1/systemone /api/v1/systemone
Key TYPESAFE_API_KEY OPENROUTER_API_KEY
Request id x-typesafe-request-id x-generation-id, also in the body
Per-call cost not reported $response->usage()->cost()
models()->list() yes no catalogue — name the model directly
Images uploaded as multipart/form-data inline as base64 image_url parts

baseUrl and apiKey follow the provider when you switch, unless you set either of them yourself, in which case yours is kept:

Client::make()->openRouter()->getBaseUrl();                     // openrouter.ai
Client::make(baseUrl: 'https://proxy.example.com')->openRouter()
    ->getBaseUrl();                                             // proxy.example.com

Model names

jev-latest — the SDK default — works on both providers and resolves to the same build. OpenRouter also accepts its own namespaced names:

Name Meaning
jev-latest the current build, on either provider
~typesafe/jev-latest the same, in OpenRouter's naming
typesafe/jev-1.13 a pinned build

The leading ~ marks a floating alias and pairs only with -latest; ~typesafe/jev-1.13 is rejected.

What comes back

The answers are identical either way. OpenRouter adds two things:

$response = Client::make()->openRouter()->systemOne()
    ->state($ticket)->noul('urgent', 'Is the customer blocked?')->send();

$response->provider();         // 'TypeSafe' — who actually served it
$response->usage()->cost();    // charge in USD, or null calling TypeSafe directly
$response->requestId();        // the generation id

cost() returns null when the provider did not price the call, and 0.0 when it priced it at nothing — those are different answers.

Laravel

The package auto-registers a Client singleton, so it can be injected straight away and nothing needs wiring:

public function __construct(private readonly Client $typeSafe) {}

Set TYPESAFE_API_KEY (or OPENROUTER_API_KEY) and that is the whole setup. To change anything else, publish the config:

php artisan vendor:publish --tag=typesafe-config
// config/typesafe.php
'provider' => env('TYPESAFE_PROVIDER', 'typesafe'),
'keys' => [
    'typesafe' => env('TYPESAFE_API_KEY'),
    'openrouter' => env('OPENROUTER_API_KEY'),
],
'model' => env('TYPESAFE_DEFAULT_MODEL', 'jev-latest'),
'timeout' => (float) env('TYPESAFE_TIMEOUT', 10.0),
'log' => ['enabled' => true, 'channel' => null, 'level' => env('TYPESAFE_LOG_LEVEL', 'warn')],

A config value that is null or empty falls through to the environment variable for that setting, so publishing the file does not force you to fill it in.

Publish the config if you run php artisan config:cache. With a cached config Laravel never loads the .env, so a key left to an environment lookup resolves to nothing — and because a missing key is only reported when a request is sent, that shows up as calls failing in production rather than as a boot error. Reading the key from config is what avoids it.

The client logs through Laravel's logger; log.channel names a channel and log.level accepts error, warn, info, debug or off. Credential headers are redacted, bodies are not, so debug writes your state and answers to the log.

Questions

Every question is built fluently and keyed by the name its answer comes back under. Instructions and criteria may be a string or a JSON-ready array.

Noul — yes or no

use Phox\TypeSafe\Questions\Noul;

Noul::ask('Is the customer blocked right now?')
    ->yes('They cannot use the product at all')
    ->no('They have a workaround');
$answer = $response->noul('blocked');
$answer->noul();    // 0.82 — probability of yes
$answer->isYes();   // true, at the default 0.5 threshold
$answer->isYes(0.9);

Choice — one of several labels

use Phox\TypeSafe\Questions\Choice;

Choice::ask('What is this ticket about?')
    ->option('billing', 'Charges, invoices and refunds')
    ->option('technical', 'Something is broken')
    ->option('other');

// Or, when the labels speak for themselves:
Choice::between(['billing', 'technical', 'other']);
$answer = $response->choice('category');
$answer->choice();                  // 'billing'
$answer->is('billing');             // true
$answer->confidence();              // 0.91
$answer->probabilities();           // ['billing' => 0.91, 'technical' => 0.06, ...]
$answer->probabilityOf('technical');

Score — a level on an ordered rubric

Levels are scored from zero, in the order you add them. A rubric needs at least two.

use Phox\TypeSafe\Questions\Score;

Score::ask('How urgent is this ticket?')
    ->level('Can wait until next week')
    ->level('Should be handled today')
    ->level('The customer is blocked right now');

// Or:
Score::rubric(['Can wait until next week', 'Should be handled today', 'The customer is blocked right now']);
$answer = $response->score('urgency');
$answer->score();          // 1.4 — an expectation, so it falls between levels
$answer->nearestLevel();   // 1
$answer->describe();       // 'Should be handled today'
$answer->probabilities();  // [0 => 0.1, 1 => 0.4, 2 => 0.5]

Box — where something is in an image

Locates what the instructions describe in the request's image. It needs an image in the request (see Images) and Jev 1.14 or newer, such as jev-preview. An older build answers a box question with a bare 400 Invalid request., to which the SDK adds that box questions need Jev 1.14; on 2026-10-14 jev-latest resolved to 1.13 calling TypeSafe directly, and to 1.14 on OpenRouter, which has no jev-preview.

use Phox\TypeSafe\Assets\Image;
use Phox\TypeSafe\Questions\Box;
use Phox\TypeSafe\TypeSafe;

$response = $client->systemOne()
    ->header(TypeSafe::IMAGES_HEADER, 'true')
    ->model('jev-preview')
    ->state(Image::fromPath('photo.jpg'))
    ->ask('dog', Box::locate('The dog'))   // or ->box('dog', 'The dog')
    ->send();
$answer = $response->box('dog');
$answer->box();              // ['top' => 0.1, 'bottom' => 0.6, 'left' => 0.25, 'right' => 0.75]
$answer->top();              // fractions of the image's height and width, from its top-left
$answer->inPixels(800, 400); // [200, 40, 400, 200]: x, y, width, height
$answer->confidence();       // 0.88, or null when the API did not report one

Images

Images are experimental on the API: off unless the request sends the TypeSafe-Danger-Zone-Images: true header, and the format, answers and limits may change. The SDK does not send the header for you; set it on the client, or per call:

use Phox\TypeSafe\TypeSafe;

$client->header(TypeSafe::IMAGES_HEADER, 'true');

An Image goes anywhere text can - the state, instructions, a choice option, a score level, a noul outcome - on its own, nested in an array, or interpolated into a string:

use Phox\TypeSafe\Assets\Image;

$photo = Image::fromPath('returns/RMA-1042.jpg');   // or fromBytes(), fromBase64()

$client->systemOne()
    ->state(['claim' => 'The mug arrived chipped.', 'photo' => $photo])
    ->noul('damaged', "Is the product in {$photo} damaged?")
    ->send();

Calling TypeSafe, each image is written as a reference, <image:…>, and uploaded once alongside the JSON as multipart/form-data. The model receives a copy of the image every time a reference appears, and each copy is billed as input tokens.

An image interpolated into a string is found again through that reference, so it must be alive when the call is sent: 'See ' . Image::fromPath($p) frees the image straight away, and send() throws instead of sending a reference with nothing behind it. Keep it in a variable.

To refer to an image by a name of your own, such as in a prompt kept in config, attach it under that name:

$client->systemOne()
    ->state('A customer returned <delivered>. The catalog photo is <catalog>.')
    ->noul('matches', 'Is <delivered> the product shown in <catalog>?')
    ->asset('<delivered>', Image::fromPath($returned))
    ->asset('<catalog>', Image::fromPath($catalog))
    ->send();

Every occurrence of the name is replaced, so pick one that cannot occur in ordinary text. A name that appears nowhere in the request throws before sending.

The API takes PNG or JPEG stills, 256 to 1568 px on each side and at most 16 MiB each, and rejects anything outside that instead of resizing it. It ignores EXIF orientation, so rotate the pixels upright first. The API checks these limits, not the SDK; see the image docs.

OpenRouter takes no uploads, so there each image is written into the JSON where it was placed, as an image_url part carrying a base64 data URI. A string with text around an image becomes a list of the text and the image, in order. OpenRouter does not need the images header.

Asking several questions at once

use Phox\TypeSafe\Questions\Choice;
use Phox\TypeSafe\Questions\Noul;
use Phox\TypeSafe\Questions\Score;

$response = $client->systemOne()
    ->state([
        'subject' => 'Charged twice this month',
        'body' => $ticket->body,
        'plan' => 'pro',
    ])
    ->ask('category', Choice::between(['billing', 'technical', 'other']))
    ->ask('blocked', Noul::ask('Is the customer blocked right now?'))
    ->ask('urgency', Score::rubric(['Can wait', 'Today', 'Right now']))
    ->send();

$response->model();                  // the model that answered
$response->usage()->totalTokens();
$response->requestId();              // for support tickets about a request

choice(), noul(), score() and box() check that an answer came back in the shape its question asked for, so a mismatch is reported here instead of surfacing as a missing method later. answers() returns all of them; nouls(), choices(), scores() and boxes() return each kind.

The shorthands on the request builder cover the common case without importing a question class:

$client->systemOne()
    ->state($ticket)
    ->noul('blocked', 'Is the customer blocked right now?')
    ->choice('category', ['billing', 'technical', 'other'])
    ->score('urgency', ['Can wait', 'Today', 'Right now'])
    ->send();

Models

$models = $client->models()->list();

$models->names();              // ['jev-latest', ...]
$models->find('jev-latest')?->description();

foreach ($models as $model) {
    echo $model->name(), ' ', $model->releaseDate(), PHP_EOL;
}

Only TypeSafe publishes a catalogue. On OpenRouter this throws a TypeSafeException telling you to name a model directly, rather than returning an empty list that would read as "no models available".

Configuring the client

Values given in code win over environment variables, which win over the SDK defaults.

Setting Environment variable Default
apiKey TYPESAFE_API_KEY or OPENROUTER_API_KEY — (required)
provider TYPESAFE_PROVIDER typesafe
baseUrl TYPESAFE_BASE_URL the provider's host
defaultModel TYPESAFE_DEFAULT_MODEL jev-latest
logLevel TYPESAFE_LOG_LEVEL warn
timeout — 10 seconds per attempt

Which key is read follows the provider, so both can sit in the environment at once and only the relevant one is used.

use Phox\TypeSafe\Client;
use Phox\TypeSafe\Enums\LogLevel;
use Phox\TypeSafe\Retry\RetryPolicy;

$client = Client::make($apiKey)
    ->openRouter()
    ->defaultModel('jev-latest')
    ->timeout(30)
    ->header('X-Tenant', 'acme')
    ->retry(fn (RetryPolicy $policy) => $policy->maxRetries(4))
    ->logger($psrLogger)
    ->logLevel(LogLevel::Info);

The client's setters mutate it and return it, so it can be built in one chain and reused. Per-call overrides live on the request builder and never touch the client:

$client->systemOne()
    ->state($ticket)
    ->noul('blocked')
    ->model('jev-2026-01')
    ->timeout(5)
    ->header('X-Request-Source', 'support-inbox')
    ->retry(RetryPolicy::none())
    ->send();

Retries

By default the SDK retries twice, on HTTP 408, 429 and 5xx, and on connection failures and timeouts. Backoff starts at 500ms, doubles up to 5s, and has up to 25% of itself randomly subtracted. A Retry-After or retry-after-ms header is honoured when it asks for a minute or less.

$client->retry(fn (RetryPolicy $policy) => $policy
    ->maxRetries(4)
    ->initialBackoff(0.25)
    ->maxBackoff(8)
    ->jitter(0.5)
    ->retryStatuses(429, 502, 503, 504)
    ->maxRetryAfter(30)
    ->retryTimeouts(false));

The timeout applies to each attempt, not to the call as a whole, so a request that keeps timing out can take timeout × (maxRetries + 1) plus backoff.

RetryPolicy setters return a modified copy, so narrowing a policy for one call leaves the client's own policy alone.

Errors

Everything the SDK throws extends TypeSafeException.

Exception Raised when
TypeSafeException Configuration or question validation failed, before anything was sent
ApiException The API returned a non-2xx response
BadRequestException HTTP 400
AuthenticationException HTTP 401
PermissionDeniedException HTTP 403
NotFoundException HTTP 404
UnprocessableEntityException HTTP 422
RateLimitException HTTP 429
InternalServerException HTTP 5xx
ConnectionException The request never produced a complete response
TimeoutException An attempt ran out of time; a kind of ConnectionException
use Phox\TypeSafe\Exceptions\ApiException;
use Phox\TypeSafe\Exceptions\ConnectionException;
use Phox\TypeSafe\Exceptions\RateLimitException;

try {
    $response = $client->systemOne()->state($ticket)->noul('blocked')->send();
} catch (RateLimitException $exception) {
    $waitFor = $exception->getRetryAfter();
} catch (ApiException $exception) {
    $exception->getStatus();
    $exception->getRequestId();
    $exception->getBody();
} catch (ConnectionException $exception) {
    // Retries are already exhausted by this point.
}

Logging

Nothing is logged until you give the client a PSR-3 logger. info writes a line per attempt; debug adds request headers and bodies. Credential headers are redacted — bodies are not, so debug will log whatever state you send.

$client->logger($monolog)->logLevel(LogLevel::Debug);

Using another HTTP client

Requests go through Guzzle by default. Any PSR-18 client works too, though PSR-18 has no per-request timeout, so configure one on the client you pass in:

$client->httpClient($psr18Client);

For anything else — another HTTP stack, or a transport of your own — implement Phox\TypeSafe\Contracts\Transport. Faking the API in tests needs none of this: see Testing.

use Phox\TypeSafe\Contracts\Transport;

final class RecordingTransport implements Transport
{
    public function send(RequestInterface $request, float $timeout): ResponseInterface
    {
        // Retries, logging and error mapping happen above this.
    }
}

$client->transport(new RecordingTransport());

Testing

Tests should not call Jev: the answers move, the network fails, and a suite that depends on either is flaky. Phox\TypeSafe\Testing\FakeTypeSafe ships with the package and answers in its place.

use Phox\TypeSafe\Testing\FakeTypeSafe;

$fake = new FakeTypeSafe();

$router = new TicketRouter($fake->client());
$router->route('I was charged twice.');

self::assertSame('I was charged twice.', $fake->lastCall()->state());

Nothing has to be scripted. The fake reads the questions off the request and answers every one of them in the shape that question asked for, identically on every run: a yes at 0.75, the first label, level 0, a box around the whole image, with the rest of the probability split evenly over the other outcomes. A score answer comes back with the rubric it was asked about as its legend, as the API sends it.

$fake->client() is a client built for this — no API key, no network, and retries off, so a failure you queue surfaces as itself. To fake the client an application already configured, including the singleton Laravel registered, point that one at the fake instead:

$fake->bind($this->app->make(Client::class));

bind() takes that client's retries off too. A client configured for production retries 408s, 429s and 5xx, so a queued failure would be retried and the reply queued behind it spent answering the retry — a test that then fails somewhere else entirely. Keep the policy where the retrying is the thing under test:

$fake->bind($client, retries: true);

Scripting answers

FakeAnswers writes a response in answers rather than in JSON. Script what the test asserts on; anything else it asked stays simulated.

use Phox\TypeSafe\Testing\FakeAnswers;

$fake->reply(
    FakeAnswers::make()
        ->choice('category', 'billing')     // probabilities fill in from the options asked
        ->noul('urgent', true)              // 0.9, or pass a probability of yes
        ->score('severity', 2.0),
);
Method What it scripts
noul($name, 0.82|true|false) a yes/no answer
choice($name, $label, $probabilities?, $confidence?) the selected label
score($name, $score, $confidence?, $probabilities?, $legend?) a score on the rubric
box($name, $top, $left, $bottom, $right, $confidence?) a located box
raw($name, $payload) an answer payload verbatim
omit($name) leaves a question unanswered, as a dropped answer would
only() answers only what is scripted, leaving the rest unanswered
model(), usage(), provider(), id(), with() the fields around the answers

Simulated answers are the right default for a test asserting on one answer among several. They are the wrong one for a test that asks the same question of a list — is each of these candidates the one being looked for — where the unscripted questions are the negatives the assertion depends on, and a simulated yes at 0.75 would make every candidate a match. only() scripts the whole answer instead:

$fake->reply(FakeAnswers::make()->noul('c_billing', 0.91)->only());

Each reply() covers one call, in order; alwaysReply() covers every System One call the queue does not. Both also take a callback, which receives the call and returns the answers, for a fake that responds to what was asked:

$fake->alwaysReply(fn (FakeCall $call) => FakeAnswers::make()
    ->noul('urgent', str_contains((string) $call->state(), 'ASAP')));

Failures

$fake->fail(429);                                  // a RateLimitException
$fake->fail(500, times: 3);                        // enough for a client that retries
$fake->throw(new TimeoutException(10.0));          // nothing came back at all

Models

models()->list() comes back with one card for jev-latest unless a test says otherwise:

$fake->models(['jev-1.13' => 'A pinned build', 'jev-latest']);

What was asked

$fake->calls() holds a FakeCall per request, decoded so assertions read in terms of questions rather than JSON.

state(), model() what the call was about, and the model it resolved to
questions(), question($name), questionType($name), asked($name) the questions as sent
images() the image files sent, keyed by the reference they were sent under, or by order on OpenRouter
method(), url(), path(), headers(), header($name) the request itself
retryCount(), timeout() which attempt this was, and what it was allowed
body(), request() the raw payload and the PSR-7 request

$fake->callCount(), $fake->lastCall(), $fake->asked($name) and $fake->systemOneCalls() cover the common assertions; $fake->reset() clears calls and scripts between them.

For a test that needs a particular status code or body rather than answers, Phox\TypeSafe\Testing\FakeTransport is the layer underneath, and takes both:

$client->transport((new FakeTransport())->queue('<html>502</html>', 502));

Development

composer install
composer test
composer phpstan