binnash / typesafe-sdk
PHP & Laravel SDK for TypeSafe AI's JEV Model series
Requires
- php: ^8.2 || ^8.3 || ^8.4 || ^8.5
- ext-json: *
- php-http/discovery: ^1.18
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^1.0 || ^2.0 || ^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- guzzlehttp/psr7: ^2.6
- laravel/pint: ^1.24
- mockery/mockery: ^1.6
- orchestra/testbench: ^10.11
- pestphp/pest: ^3.0 || ^4.0
Suggests
- illuminate/support: Required to use the Laravel bridge (^11.0 || ^12.0).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-18 09:42:03 UTC
README
PHP SDK for TypeSafe AI — small units of AI intelligence you can use like programming primitives.
TypeSafe's System One models, including Jev, turn natural language and application state into typed judgments and probabilities that code can combine. A single request evaluates any number of typed questions about the same state, and your code owns the workflow: routing, thresholds, ranking, and side effects.
- Requirements
- Installation
- Quickstart
- Primitives
- Questions
- Reading answers
- Configuration
- Per-call options
- Retries
- Errors
- Logging
- Models
- Laravel
- Testing
Requirements
- PHP 8.2 or newer with
ext-json - A PSR-18 HTTP client and PSR-17 request/stream factories. The SDK discovers
whatever your project already has installed;
guzzlehttp/guzzleis a good default:
composer require guzzlehttp/guzzle
Installation
composer require binnash/typesafe-sdk
Create an API key in the TypeSafe console, then construct a client. The SDK never reads the environment; pass values explicitly so configuration stays in your application's control.
use Binnash\Typesafe\TypeSafe; $client = TypeSafe::client('your-api-key');
Quickstart
use Binnash\Typesafe\Support\Question; use Binnash\Typesafe\TypeSafe; $client = TypeSafe::client('your-api-key'); $result = $client->systemOne( state: [ 'subject' => 'Charged twice this month', 'body' => 'I see two charges of $49 on my card. Please fix this ASAP.', ], questions: [ 'is_billing' => Question::noul('Is this ticket about billing?'), 'tone' => Question::choice("What is the customer's tone?", [ 'calm' => null, 'frustrated' => null, 'angry' => null, ]), 'urgency' => Question::score('How urgent is this ticket?', ['can wait', 'this week', 'today']), ], ); $result->answers->is_billing->noul; // 0.94 $result->answers->tone->choice; // "frustrated" $result->answers->urgency->score; // 1.6, may land between levels $result->usage->inputTokens; // 312 $result->requestId; // "req_..." for support and tracing
state is the content to evaluate: a string, or structured data such as a record
or chat log. questions is a map you name; answers come back under the same names.
Question names are keys in your code only — they are not sent to the model, so put
the full meaning in the question itself.
Primitives
Choose by what the answer means.
| Question | Use when | Answer |
|---|---|---|
Question::noul() |
A condition holds or not | Probability of yes, 0.0–1.0 |
Question::choice() |
One of a defined set | Winning label, distribution, confidence |
Question::score() |
Degree along an ordered rubric | Probability-weighted score, legend, confidence |
Use one Question::noul() per label when several labels may apply independently.
Use comparable Question::score() questions for graded ranking across items.
The SDK ships no global functions. Everything lives in the
Binnash\Typesafe namespace, so installing it never reserves identifiers such as
Question::choice() in your application's global scope — a collision risk with Laravel's
own global helpers, other packages, or your own code. One import covers all three
builders:
use Binnash\Typesafe\Support\Question;
Questions
Noul — yes or no
use Binnash\Typesafe\Support\Question; Question::noul('Does this convey urgency?'); Question::noul('Does this convey urgency?', [ 'true' => 'Explicitly time-sensitive', 'false' => 'No urgency expressed', ]);
A probability near 0.5 means yes and no are similarly likely — not medium
intensity. There is no separate confidence.
$answer = $result->answers->is_urgent; $answer->noul; // 0.92 $answer->isYes(); // true, using the default 0.5 threshold $answer->isYes(0.9); // true, using your own threshold
Choice — one of a set
use Binnash\Typesafe\Support\Question; Question::choice('Which team should handle this?', [ 'billing' => 'Payments, invoicing, refunds', 'technical' => 'Bugs, outages, integrations', 'sales' => null, // null when the label needs no extra detail ]);
$answer = $result->answers->department; $answer->choice; // "technical" $answer->confidence; // 0.82 $answer->probabilities; // ['billing' => 0.08, 'technical' => 0.85, 'sales' => 0.07] $answer->probabilityOf('technical'); // 0.85
Confidence summarizes how concentrated the distribution is. It is not a guarantee of correctness and not permission to act; validate thresholds on your own data and consequences.
Score — degree along a rubric
use Binnash\Typesafe\Support\Question; Question::score('How frustrated is the customer?', ['Calm', 'Frustrated', 'Very angry']);
At least two levels are required, ordered from zero. Levels should describe concrete situations and stand on their own.
$answer = $result->answers->frustration; $answer->score; // 1.6 — between "Frustrated" and "Very angry" $answer->legend; // [0 => 'Calm', 1 => 'Frustrated', 2 => 'Very angry'] $answer->levelDescription(2); // "Very angry" $answer->probabilityOf(1); // 0.3 $answer->confidence; // 0.78
Instructions and criteria
Instructions may be text, or a JSON object or array when a question needs structure. Descriptions may also be objects, which helps define contrasts, exclusions, and examples:
use Binnash\Typesafe\Support\Question; Question::noul('Is the customer reporting a duplicate charge?', [ 'true' => [ 'meaning' => 'the same amount charged more than once', 'examples' => ['billed twice'], ], ]); Question::choice('Are these the same incident?', [ 'same' => ['match_on' => 'actors, action, location, occurrence time'], 'different' => ['distinguish_by' => 'occurrence time or distinguishing numbers'], ]);
Composing requests
Ask independent questions over the same state together; they run in parallel and cannot see one another's answers. Include speculative questions when useful — code consumes only the applicable answers. A second request is warranted only when an earlier answer determines what to fetch or ask next.
Reading answers
Answers are keyed by the names you chose and support property, array, and typed access:
$answers = $result->answers; $answers->tone; // ChoiceResponse|null $answers['tone']; // ChoiceResponse|null $answers->has('tone'); // true $answers->get('tone'); // throws when the answer is missing $answers->names(); // ['is_billing', 'tone', 'urgency'] $answers->all(); // every answer as an array $answers->noul('is_billing'); // NoulResponse, throws on a type mismatch $answers->choice('tone'); // ChoiceResponse $answers->score('urgency'); // ScoreResponse
Answers are immutable. SystemOneResult also carries model, usage, and
requestId, and json_encode($result) produces the response body plus
request_id.
Configuration
Use ClientConfig when you need anything beyond an API key:
use Binnash\Typesafe\Config\ClientConfig; use Binnash\Typesafe\Retry\RetryPolicy; use Binnash\Typesafe\TypeSafeClient; $config = new ClientConfig( apiKey: 'your-api-key', baseURL: 'https://api.typesafe.ai', defaultModel: 'jev-latest', timeout: 10.0, // seconds, per attempt retryPolicy: new RetryPolicy(maxRetries: 2), logger: $psr3Logger, // optional PSR-3 logger logLevel: 'warn', // debug | info | warn | error | off defaultHeaders: ['X-Team' => 'support'], httpClient: $psr18Client, // optional overrides; discovered otherwise requestFactory: $psr17RequestFactory, streamFactory: $psr17StreamFactory, ); $client = new TypeSafeClient($config);
ClientConfig::make() accepts an API key string or a map of the same arguments,
and TypeSafe::client('key', ['timeout' => 30.0]) is shorthand for it. The API
key is always redacted from var_dump() and json_encode() output.
Per-request headers are merged case-insensitively, and SDK-owned headers
(Authorization, Accept, User-Agent, X-TypeSafe-SDK, X-TypeSafe-Runtime,
Content-Type, X-TypeSafe-Retry-Count) cannot be overridden.
Per-call options
Every call accepts an options array and, for systemOne, extra top-level payload
fields:
use Binnash\Typesafe\Support\Question; $result = $client->systemOne( state: $ticket, questions: ['tone' => Question::choice('Tone?', ['calm' => null, 'upset' => null])], model: 'jev-1.13.0', // pin a version options: [ 'headers' => ['X-Trace' => $request->id()], 'timeout' => 30.0, 'retry' => ['maxRetries' => 0], // or a RetryPolicy instance ], extra: ['future_option' => null], // forwarded, never overriding state/model/questions );
Use systemOneWithResponse() when you also need the HTTP status, headers, or
request ID (the JavaScript SDK's withResponse()):
$response = $client->systemOneWithResponse($state, $questions); $response->status; // 200 $response->requestId; // "req_..." $response->header('retry-after'); // null when absent $response->data; // raw response body
Retries
Failures are retried automatically with capped exponential backoff and jitter.
| Setting | Default | Meaning |
|---|---|---|
maxRetries |
2 |
Retries after the initial attempt; 0 disables |
backoffInitialMs |
500 |
First backoff delay |
backoffMaxMs |
5000 |
Maximum backoff delay |
backoffJitter |
0.25 |
Fraction of each delay randomly subtracted |
httpStatuses |
408, 429, 500–599 |
Status codes to retry |
respectRetryAfter |
true |
Honor retry-after-ms and Retry-After headers |
maxRetryAfterMs |
60000 |
Longest server delay to honor before using backoff |
retryConnectionErrors |
true |
Retry connection failures |
retryTimeoutErrors |
true |
Retry attempts that exceeded the timeout |
use Binnash\Typesafe\Retry\RetryPolicy; $policy = new RetryPolicy(maxRetries: 4, backoffInitialMs: 250); $client = TypeSafe::client('key', ['retryPolicy' => $policy]); $limited = $policy->with(maxRetries: 0); // a copy with one setting changed $client->models->list(['retry' => ['maxRetries' => 0]]); // per call
A per-call timeout is enforced per attempt: the SDK measures each attempt and
raises ApiTimeoutException when the budget is spent. Configure real transport
timeouts on your PSR-18 client as well (for example Guzzle's timeout option).
Errors
Every exception extends Binnash\Typesafe\Exceptions\TypeSafeException.
| Exception | Raised for |
|---|---|
ApiException |
Any other non-2xx response; exposes status, body, headers, requestId |
BadRequestException |
400 — the request is invalid |
AuthenticationException |
401 — the API key is missing or invalid |
PermissionDeniedException |
403 — access is denied |
NotFoundException |
404 |
UnprocessableEntityException |
422 — request validation failed |
RateLimitException |
429 — exposes retryAfterMs |
InternalServerException |
5xx |
ApiConnectionException |
The request or response delivery failed |
ApiTimeoutException |
The attempt exceeded the timeout; exposes timeoutMs |
Messages are derived from the response body, including FastAPI/Pydantic validation details:
use Binnash\Typesafe\Exceptions\ApiException; use Binnash\Typesafe\Exceptions\RateLimitException; try { $client->models->list(); } catch (RateLimitException $e) { $e->retryAfterMs; // 2500, when the server asked for a delay } catch (ApiException $e) { $e->status; // 422 $e->requestId; // "req_..." $e->getMessage(); // '422 questions.q.score.criteria.0: Input should be a valid string' $e->body; // the decoded response body }
Shapes the API would reject are caught before sending, so failures surface
locally: an empty questions map, Question::score() criteria that are not a list of
at least two levels, Question::choice() criteria that are a list or empty, and
unknown Question::noul() criteria keys.
Logging
Pass any PSR-3 logger. info logs request summaries; debug adds headers and
bodies. Credential headers are redacted before they reach the logger.
use Binnash\Typesafe\Config\ClientConfig; use Binnash\Typesafe\TypeSafeClient; $client = new TypeSafeClient(new ClientConfig( apiKey: 'your-api-key', logger: $logger, // any PSR-3 logger, such as Monolog logLevel: 'info', // debug | info | warn | error | off ));
Redaction covers Authorization, Proxy-Authorization, X-API-Key (scheme and
last four characters preserved), and Cookie/Set-Cookie (replaced entirely).
Request bodies are not redacted — avoid logging debug when state contains
sensitive data.
Models
foreach ($client->models->list() as $model) { echo $model->name, ' ', $model->releaseDate, PHP_EOL; $model->description; } $response = $client->models->listWithResponse(); $response->requestId; $response->data; // the raw { "models": [...] } payload
jev-latest tracks the most recent stable release and moves when a new one
ships, so answers can change without a change on your side. The response's
model field reports the versioned ID that answered — log it, and pin a version
in defaultModel when you have tuned thresholds against it.
Laravel
Laravel 11 and 12 are supported through package auto-discovery: install the package, set your key, and start calling it.
composer require binnash/typesafe-sdk
# .env TYPESAFE_API_KEY=your-api-key
That is the whole setup. The bridge reads these variables for you:
| Variable | Default | Purpose |
|---|---|---|
TYPESAFE_API_KEY |
required | API key used as a bearer token |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
API root |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
Model used when a call omits one |
TYPESAFE_TIMEOUT |
10.0 |
Timeout per attempt, in seconds |
TYPESAFE_LOG_LEVEL |
warn |
debug, info, warn, error, or off |
TYPESAFE_MAX_RETRIES |
2 |
Retries after the initial attempt |
TYPESAFE_BACKOFF_INITIAL_MS |
500 |
First backoff delay in milliseconds |
TYPESAFE_BACKOFF_MAX_MS |
5000 |
Maximum backoff delay in milliseconds |
TYPESAFE_BACKOFF_JITTER |
0.25 |
Fraction of each delay randomly subtracted |
TYPESAFE_RESPECT_RETRY_AFTER |
true |
Honor server retry delay headers |
Only the bridge reads the environment — the core SDK always receives explicit values, so its behavior is identical outside Laravel.
The facade
use Binnash\Typesafe\Laravel\Facades\TypeSafe; use Binnash\Typesafe\Support\Question; $result = TypeSafe::systemOne( state: $ticket, questions: [ 'is_billing' => Question::noul('Is this ticket about billing?'), 'urgency' => Question::score('How urgent is this?', ['can wait', 'this week', 'today']), ], ); $result->answers->is_billing->noul; $models = TypeSafe::models()->list();
Dependency injection
The client and its configuration are shared singletons, so type-hinting either one resolves the same instance the facade uses — in controllers, jobs, commands, and listeners:
use Binnash\Typesafe\Support\Question; use Binnash\Typesafe\TypeSafeClient; final class TriageTicket { public function __construct(private readonly TypeSafeClient $typeSafe) {} public function handle(Ticket $ticket): void { $result = $this->typeSafe->systemOne( state: ['subject' => $ticket->subject, 'body' => $ticket->body], questions: ['urgency' => Question::score('How urgent?', ['can wait', 'this week', 'today'])], options: ['retry' => ['maxRetries' => 0]], // per call, for queue jobs with their own retries ); $ticket->update(['urgency' => $result->answers->urgency->score]); } }
Publishing the config
php artisan vendor:publish --tag=typesafe-config
This copies config/typesafe.php into your application, where the defaults above
can be changed and a custom PSR-18 client, PSR-17 factories, or logger can be
configured (http_client, request_factory, stream_factory, logger). Values
left as null fall back to whatever your container binds.
Logs go to Laravel's logger by default, filtered by TYPESAFE_LOG_LEVEL.
Testing your application
Bind your own PSR-18 client in a test to keep the network out of it:
use Psr\Http\Client\ClientInterface; $this->app->instance(ClientInterface::class, $fakeClient);
The package's own bridge tests do exactly this; see
tests/Feature/Laravel/LaravelBridgeTest.php.
Testing
composer test # unit and feature tests, no network access composer test:integration # live API tests; skipped without TYPESAFE_API_KEY composer pint # code style
The live suite calls https://api.typesafe.ai for real. Set TYPESAFE_API_KEY
to run it, and optionally TYPESAFE_BASE_URL to point at another host. Tests are
skipped automatically when no key is present, and are excluded from
composer test.