phox / typesafe-sdk-php
A PHP SDK for the TypeSafe AI API, direct or through OpenRouter
Requires
- php: ^8.3
- ext-json: *
- guzzlehttp/guzzle: ^7.9
- psr/http-client: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- illuminate/support: ^11.0 || ^12.0
- orchestra/testbench: ^9.0 || ^10.0
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^11.5
Suggests
- illuminate/support: Registers the client as a singleton from config/typesafe.php (Laravel 11+)
Provides
None
Conflicts
None
Replaces
None
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