README.md

September 20, 2026 · View on GitHub

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 decisions endpoint. The request and answer bodies are the same on both, so the provider only decides the host, the path and which key is read:

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

or set TYPESAFE_PROVIDER=openrouter and construct the client normally.

TypeSafeOpenRouter
Hosthttps://api.typesafe.aihttps://openrouter.ai
Path/v1/systemone/api/alpha/decisions
KeyTYPESAFE_API_KEYOPENROUTER_API_KEY
Request idx-typesafe-request-idx-generation-id, also in the body
Per-call costnot reported$response->usage()->cost()
models()->list()yesno catalogue — name the model directly

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:

NameMeaning
jev-latestthe current build, on either provider
~typesafe/jev-latestthe same, in OpenRouter's naming
typesafe/jev-1.13a 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]

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() and score() check that an answer came back in the shape its question asked for, so a mismatch is reported here rather than surfacing as a missing method later. answers() returns all of them; nouls(), choices() and scores() 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.

SettingEnvironment variableDefault
apiKeyTYPESAFE_API_KEY or OPENROUTER_API_KEY— (required)
providerTYPESAFE_PROVIDERtypesafe
baseUrlTYPESAFE_BASE_URLthe provider's host
defaultModelTYPESAFE_DEFAULT_MODELjev-latest
logLevelTYPESAFE_LOG_LEVELwarn
timeout10 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.

ExceptionRaised when
TypeSafeExceptionConfiguration or question validation failed, before anything was sent
ApiExceptionThe API returned a non-2xx response
BadRequestExceptionHTTP 400
AuthenticationExceptionHTTP 401
PermissionDeniedExceptionHTTP 403
NotFoundExceptionHTTP 404
UnprocessableEntityExceptionHTTP 422
RateLimitExceptionHTTP 429
InternalServerExceptionHTTP 5xx
ConnectionExceptionThe request never produced a complete response
TimeoutExceptionAn 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, 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),
);
MethodWhat 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
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
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