Search by

adams100111 / typesafe-php

adams100111

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.

Package info

github.com/adams100111/typesafe-php

pkg:composer/adams100111/typesafe-php

Statistics

Installs: 2

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-09-21 15:43 UTC

This package is auto-updated.

Last update: 2026-09-21 17:27:16 UTC


README

CI

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-latest is 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)
backoffInitialMsbackoffMaxMs 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, and json_encode would then send a JSON list. This client always sends an object, and returns option names as strings.
  • Score legend and probabilities are 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