adams100111 / typesafe-php
Unofficial PHP client for TypeSafe's System One API (Jev) — typed noul, choice and score judgements with retries, a typed error hierarchy and a pluggable transport. Not affiliated with TypeSafe.
Requires (Dev)
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A PHP client for TypeSafe's System One API — Jev — which returns typed judgements instead of generated text.
Unofficial. This is a community client. It is not affiliated with, endorsed by, or supported by TypeSafe. Its behaviour mirrors the official JavaScript SDK so the two are interchangeable. For Laravel, use
adams100111/typesafe-laravel.
composer require adams100111/typesafe-php
Requires PHP 8.2+ with ext-curl and ext-json. No HTTP library dependency.
Quick start
use Adams100111\TypeSafe\Question\Question; use Adams100111\TypeSafe\TypeSafeClient; $client = new TypeSafeClient(); // reads TYPESAFE_API_KEY $result = $client->systemOne( state: ['ticket' => 'I was charged twice for the same order.'], questions: [ 'billing' => Question::noul('Is `ticket` about billing?'), 'team' => Question::choice('Which team should handle `ticket`?', [ 'billing' => 'Payments, refunds and invoices.', 'technical' => 'Bugs, errors and outages.', ]), 'urgency' => Question::score('How urgent is `ticket`?', [ 'Routine — can wait days.', 'Soon — should be handled today.', 'Now — the customer is losing money.', ]), ], ); $result->noul('billing'); // 0.97 $result->choice('team')->choice; // 'billing' $result->choice('team')->confidence; // 0.81 $result->score('urgency')->score; // 1.05 $result->score('urgency')->probabilities; // [0 => 0.0, 1 => 0.95, 2 => 0.05]
All questions go in one request. They are answered in parallel, in isolation — no answer can see another. Adding questions costs input tokens only, so ask everything you might need up front; make a second request only when one answer decides what to ask next.
The three kinds of question
| Builder | Asks | Returns | Use when |
|---|---|---|---|
Question::noul() |
Is this true? | float 0–1 |
a yes/no where the probability itself is useful |
Question::choice() |
Which of these? | ChoiceAnswer |
one option from an unordered set (≤ 255) |
Question::score() |
Which level? | ScoreAnswer |
a position on an ordered scale you describe (2–10 levels) |
A noul of 0.5 does not mean "medium". It means yes and no are equally likely. For degree, use a score.
Score levels are descriptions, never numbers. The model sees only the text of each
level and judges each on its own, so each must describe a concrete situation. Bare labels
like "1", "2", "3" measurably degrade both the score and its confidence.
Question keys are never sent to the model — they only name the answer. The whole
question must be in the instructions. Instructions may be a structured array, and
backticked paths like `ticket.messages[0].text` point into the state.
Confidence is not correctness
confidence describes how concentrated an answer's distribution is — not whether it
is right. Calibration is a property of groups of answers: validate thresholds on your
own labelled data, by confidence band, before acting on them.
- Do not use this as a CI or policy gate. The model is self-consistent but not bit-deterministic; an answer close to a threshold can cross it between identical runs.
- Pin a versioned model once you have tuned thresholds.
jev-latestis an alias that moves when a release ships.
$client = new TypeSafeClient(defaultModel: 'jev-1.13.0');
Configuration
An explicit argument wins, then the environment variable, then the default. Empty or whitespace-only environment values are ignored.
| Argument | Environment | Default |
|---|---|---|
apiKey |
TYPESAFE_API_KEY |
required |
baseUrl |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
defaultModel |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
timeoutMs |
— | 10000, per attempt |
timeoutMs has no total budget: a request that retries can take several timeouts.
Retries
Defaults match the official SDK.
| Setting | Default |
|---|---|
maxRetries |
2 (so up to 3 attempts) |
backoffInitialMs → backoffMaxMs |
500 ms, doubling, capped at 5 000 ms |
backoffJitter |
0.25 — up to a quarter of each delay randomly subtracted |
httpStatuses |
408, 429, 500–599 |
respectRetryAfter / maxRetryAfterMs |
honours retry-after-ms and Retry-After up to 60 s |
retryConnectionErrors / retryTimeouts |
true |
use Adams100111\TypeSafe\RetryPolicy; new TypeSafeClient(retry: new RetryPolicy(maxRetries: 5)); new TypeSafeClient(retry: RetryPolicy::none()); // or per call $client->systemOne($state, $questions, retry: (new RetryPolicy())->with(maxRetries: 0));
Errors
Every exception extends TypeSafeException.
TypeSafeException
├── InvalidRequestException rejected locally — nothing was sent
├── UnexpectedResponseException a 2xx this version cannot read
├── ApiException non-2xx after retries: ->status, ->body, ->headers, ->requestId()
│ ├── BadRequestException 400
│ ├── AuthenticationException 401
│ ├── PermissionDeniedException 403
│ ├── NotFoundException 404
│ ├── UnprocessableEntityException 422
│ ├── RateLimitException 429 ->retryAfterMs()
│ └── InternalServerException 5xx
└── ApiConnectionException no response at all
└── ApiTimeoutException an attempt exceeded timeoutMs
Quote ->requestId() in support requests.
PHP-specific behaviour
Two places where PHP's arrays differ from JavaScript objects, handled on purpose:
- Choice options with numeric keys (
['0' => 'no', '1' => 'yes']). PHP makes those integer keys, andjson_encodewould then send a JSON list. This client always sends an object, and returns option names as strings. - Score
legendandprobabilitiesare integer-keyed by level. This matches the official Python SDK; the level is the position in the list you supplied.
Non-ASCII state (Arabic, CJK) is sent unescaped, which keeps logs readable.
Logging
Pass any PSR-3 logger. info logs one line per attempt; debug adds headers and the
request body. The Authorization header is redacted at every level. Bodies are not —
don't log at debug if your state is sensitive.
new TypeSafeClient(logger: $psr3Logger);
Custom transport
The client depends on a one-method Transport interface; the default uses cURL.
Implement it to route through your own HTTP stack, proxy, or tracing.
use Adams100111\TypeSafe\Transport\Transport; new TypeSafeClient(transport: new MyTransport());
A Transport should return non-2xx responses rather than throw — the client owns error
mapping and retries — and throw ApiTimeoutException / ApiConnectionException only when
no response arrived.
Models
foreach ($client->models->list() as $model) { echo $model->name, PHP_EOL; // jev-latest, jev-preview, … }
Versioned ids such as jev-1.13.0 are accepted even though they are not listed.
Testing
composer test # Pest composer analyse # PHPStan, level max
The suite never calls the live API.
License
MIT