vbcherepanov / jev-symfony-bundle
Unofficial Symfony bundle for the TypeSafe AI Jev model: typed client, validator constraints, Messenger, Workflow guards and profiler panel
Package info
github.com/vbcherepanov/jev-symfony-bundle
Type:symfony-bundle
pkg:composer/vbcherepanov/jev-symfony-bundle
Requires
- php: ^8.4
- psr/log: ^2.0 || ^3.0
- symfony/clock: ^7.4 || ^8.0
- symfony/config: ^7.4 || ^8.0
- symfony/console: ^7.4 || ^8.0
- symfony/dependency-injection: ^7.4 || ^8.0
- symfony/event-dispatcher-contracts: ^3.5
- symfony/http-client: ^7.4 || ^8.0
- symfony/http-client-contracts: ^3.5
- symfony/http-kernel: ^7.4 || ^8.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.90
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
- symfony/framework-bundle: ^7.4 || ^8.0
- symfony/messenger: ^7.4 || ^8.0
- symfony/property-access: ^7.4 || ^8.0
- symfony/twig-bundle: ^7.4 || ^8.0
- symfony/validator: ^7.4 || ^8.0
- symfony/web-profiler-bundle: ^7.4 || ^8.0
- symfony/workflow: ^7.4 || ^8.0
- symfony/yaml: ^7.4 || ^8.0
Suggests
- symfony/messenger: For asynchronous evaluation with EvaluateMessage
- symfony/property-access: For reading workflow guard state from a subject property
- symfony/validator: For the #[JevNoul] and #[JevChoice] constraints
- symfony/web-profiler-bundle: For the Jev profiler panel
- symfony/workflow: For Jev-backed transition guards
Provides
None
Conflicts
None
Replaces
None
README
Unofficial Symfony integration for TypeSafe AI's Jev model. Jev answers yes/no (noul), multiple-choice (choice) and rubric (score) questions about any text or JSON, and returns calibrated probabilities. This bundle is not affiliated with or endorsed by TypeSafe AI. "TypeSafe" and "Jev" are used only to name the API this package talks to.
What you get:
- A typed client (
JevClientInterface) built on Symfony HttpClient. It supports named clients, retries matching the official SDKs, strict response decoding and a full exception hierarchy. #[JevNoul]and#[JevChoice]validator constraints, e.g. "reject comments that are probably spam".- Messenger evaluation in the background, with a
DecisionMadeEventfor fan-out. - Workflow guards that allow or block a transition based on Jev's answer.
- A web profiler panel listing every call with its questions, answers, probabilities, confidence, tokens, request ids, retries and timing.
- Console commands
jev:modelsandjev:ask. - PSR-3 logging with structured context and a metrics hook for Prometheus or OpenTelemetry. Neither is a hard dependency.
JevClientFakefor your application tests. It makes no HTTP calls.
Requirements
| Component | Requirement |
|---|---|
| PHP | 8.4+ |
| Symfony | 7.4 or 8.x |
| Optional | symfony/validator, symfony/messenger, symfony/workflow (+ symfony/property-access), symfony/web-profiler-bundle |
Install
composer require vbcherepanov/jev-symfony-bundle
With Symfony Flex, the recipe registers the bundle, creates config/packages/jev.yaml and adds
TYPESAFE_API_KEY, TYPESAFE_BASE_URL and TYPESAFE_DEFAULT_MODEL to .env. Put the real key in
.env.local or in Symfony secrets:
php bin/console secrets:set TYPESAFE_API_KEY php bin/console jev:models
Without Flex, register the bundle in config/bundles.php:
return [ // ... Jev\Bundle\JevBundle::class => ['all' => true], ];
Configuration
Every option with its default:
jev: api_key: '%env(default::TYPESAFE_API_KEY)%' # checked when a request is sent, never printed base_url: 'https://api.typesafe.ai' model: 'jev-latest' # jev-latest, jev-preview or a pinned id such as jev-1.13.0 timeout: 10.0 # seconds per attempt default_client: default http_client: ~ # id of a base HttpClientInterface service; a dedicated one when null fake: false # true: every client becomes JevClientFake (test env) retry: max_retries: 2 delay_ms: 500 multiplier: 2.0 max_delay_ms: 5000 jitter: 0.25 # up to 25 % subtracted from each backoff delay max_retry_after_ms: 60000 clients: {} # named clients, see below validator: fail_open: false # when the API is unavailable: false adds a violation, true accepts metrics: service: ~ # service implementing Jev\Bundle\Observability\JevMetricsInterface workflow_guards: {} # see "Workflow guards"
The API key is checked when a request is sent, not at container build time. cache:clear and
CI builds therefore work without a key. An empty key, a key with a newline or other control
character, or a key with surrounding whitespace raises InvalidConfigurationException. The
message never contains the key.
Named clients
Named clients inherit every root setting they leave unset:
jev: api_key: '%env(TYPESAFE_API_KEY)%' clients: default: ~ moderation: model: jev-preview timeout: 3 retry: { max_retries: 0 } eu: base_url: '%env(TYPESAFE_EU_BASE_URL)%' api_key: '%env(TYPESAFE_EU_API_KEY)%'
Inject a client by argument name (JevClientInterface $moderationClient). An untargeted
JevClientInterface receives default_client. The service ids are jev.client.<name>, and
jev.client points to the default client.
Asking questions
use Jev\Bundle\Client\JevClientInterface; use Jev\Bundle\Model\Evaluation; use Jev\Bundle\Model\Question\{Choice, Noul, Score}; final readonly class TicketTriage { public function __construct(private JevClientInterface $jev) {} public function triage(string $ticket): Triage { $decision = $this->jev->evaluate(new Evaluation($ticket, [ 'spam' => Noul::ask('Is this message unsolicited advertising?'), 'department' => Choice::fromEnum(Department::class, 'Which team should handle this ticket?'), 'urgency' => new Score(['Can wait', 'Needs attention this week', 'Needs attention today'], 'How urgent is it?'), ])); return new Triage( isSpam: $decision->noul('spam')->isYes(0.8), department: $decision->choice('department')->as(Department::class), urgency: $decision->score('urgency')->nearestLevel(), confident: $decision->choice('department')->confidence >= 0.7, ); } }
- State can be a string, an array, a
\stdClassor a\JsonSerializable. Instructions and criteria accept the same types, e.g.new Noul(['task' => '...', 'examples' => [...]]). - Noul:
new Noul(instructions, true: 'what counts as yes', false: 'what counts as no'). You need instructions or at least one criterion. - Choice:
new Choice(['billing' => 'Payments and invoices', 'other' => null], 'Which team?'),Choice::of(['a', 'b'])orChoice::fromEnum(MyEnum::class). Enum cases that implementDescribedOptionsupply their own description. Up to 255 options. - Score: an ordered list of 2 to 10 levels. Level N is the N-th entry, counting from 0.
- Numeric ids and labels such as
"0"and"1"are always sent as JSON objects. Plainjson_encode()turns such PHP arrays into JSON lists, which the API rejects. To force an object for your own state, pass(object) [...].
Decision gives you model (the resolved id, e.g. jev-1.13.0 for jev-latest), usage
(inputTokens, outputTokens), requestId (the x-typesafe-request-id header) and typed
accessors. Accessors throw AnswerNotFoundException for a missing id and
UnexpectedAnswerTypeException for the wrong type. Answer types this version doesn't know are
kept as UnknownAnswer (raw payload) rather than failing the whole decision.
| Answer | Fields and helpers |
|---|---|
NoulAnswer |
probability (of "yes"), isYes(0.5), isNo(0.5) |
ChoiceAnswer |
choice, probabilities, confidence, probability($label), is(...), as(Enum::class), ranked() |
ScoreAnswer |
score (probability-weighted, may fall between levels), legend, probabilities, confidence, nearestLevel(), mostLikelyLevel(), criterion($level) |
Per-call overrides: $jev->evaluate($evaluation, new RequestOptions(timeout: 3.0, maxRetries: 0, headers: ['X-Correlation-Id' => $id])).
To override the model for a single evaluation, pass it as the third Evaluation argument or use ->withModel('jev-preview').
$jev->models() returns list<ModelCard> (name, description, releaseDate).
Errors and retries
Every exception implements Jev\Bundle\Exception\JevException.
| Exception | When |
|---|---|
ApiException (base: status, errorType, detail, requestId, retryAfter) |
any other non-2xx status |
BadRequestException |
400: semantically invalid request, e.g. Unknown model: jev |
AuthenticationException |
401 invalid key, 403 missing key |
NotFoundException |
404 |
ValidationException (violations: list<Violation>) |
422 schema violations |
RateLimitException |
429 |
OverloadedException |
529 |
ServerException |
other 5xx |
TransportException, TimeoutException |
network failure, per-attempt timeout |
InvalidResponseException |
a 2xx body that doesn't match the contract (fields are never silently defaulted) |
InvalidEvaluationException |
a question or evaluation rejected locally before sending |
InvalidConfigurationException |
bad key, URL, client name or configuration |
Retries follow the official SDKs. Statuses 408, 429 and 5xx (including 529), connection errors
and timeouts are retried up to max_retries times. The server's retry-after-ms header wins,
then Retry-After (seconds or an HTTP date), capped at max_retry_after_ms. Otherwise the
bundle backs off exponentially with jitter. Each retry carries X-TypeSafe-Retry-Count.
Statuses 400, 401, 403, 404 and 422 are never retried.
Validator constraints
use Jev\Bundle\Validator\{JevChoice, JevNoul}; final class CommentInput { #[Assert\NotBlank] #[JevNoul(question: 'Is this comment spam or advertising?', max: 0.2)] #[JevChoice(question: 'What is the tone?', enum: Tone::class, allowed: [Tone::Friendly, Tone::Neutral], minConfidence: 0.6)] public string $body = ''; }
JevNouladds a violation when P(yes) is abovemaxor belowmin. Usemaxfor "is this bad?" questions andminfor "is this good?" questions. Options:true,false,client,model,failOpen,message,unavailableMessage. Message placeholders:{{ probability }},{{ limit }},{{ question }}.JevChoicetakesoptions(a list, or label => description) orenum. It adds a violation when the choice is not inallowed(an empty list allows every option) or its confidence is belowminConfidence. Placeholders:{{ choice }},{{ confidence }},{{ allowed }},{{ limit }}.nulland""are skipped; combine withNotBlankwhen needed.- Fail-closed by default. When the API is down, rate-limited or answers badly, the value is
rejected with
unavailableMessage(codeJevNoul::UNAVAILABLE_ERROR). SetfailOpen: trueon the constraint, orjev.validator.fail_open: trueglobally, to accept values instead. Configuration errors such as a missing key are always thrown.
Each constraint makes one API call. For several questions about the same value, call the client
directly with one Evaluation.
Messenger
use Jev\Bundle\Messenger\EvaluateMessage; $bus->dispatch(new EvaluateMessage($evaluation, client: 'moderation', correlationId: (string) $comment->getId()));
framework: messenger: routing: Jev\Bundle\Messenger\EvaluateMessage: async
The handler dispatches Jev\Bundle\Event\DecisionMadeEvent (message and decision) through
the event dispatcher:
#[AsEventListener] public function onDecision(DecisionMadeEvent $event): void { if ($event->decision->noul('spam')->isYes(0.9)) { $this->comments->hide($event->message->correlationId); } }
Errors that can't succeed later (400, 401/403, 404, 422, invalid evaluation or configuration)
are wrapped in UnrecoverableMessageHandlingException, so Messenger doesn't retry them. Rate
limits, overloads, 5xx and network errors go through Messenger's retry strategy after the
client's own retries. Messages use Messenger's default PHP serializer.
Workflow guards
jev: workflow_guards: no_spam: workflow: comment transitions: [publish] state_property: body # property path read with PropertyAccess noul: question: 'Is this comment spam?' max: 0.2 # block when P(yes) > 0.2 (use min for "must be yes") message: 'Blocked as probable spam ({{ probability }}).' safe_to_ship: workflow: article transitions: [publish, feature] client: moderation choice: question: 'Classify this article' options: { safe: ~, nsfw: 'Sexual or graphic content', hateful: ~ } allowed: [safe] min_confidence: 0.8 fail_open: false # API unavailable: block (default) or allow
Instead of state_property, the subject can implement Jev\Bundle\Workflow\JevSubjectInterface
(jevState(string $guard)). A blocked transition gets a TransitionBlocker with code
jev_guard_blocked and parameters guard, reason, request_id, plus the verdict values. If
the API is unavailable, the code is jev_guard_unavailable. Guards run on every
Workflow::can() call, so results are cached per subject state until the end of the request or
message. Rule mistakes, such as an allowed option that isn't in options, fail the container
build.
Profiler
With WebProfilerBundle enabled, a Jev panel lists each call: client, requested and resolved model, outcome and HTTP status, request id, duration, attempts and retry reasons, tokens, a truncated state preview, and each question with its answer, confidence and probability distribution. The collector is only registered when the profiler service exists, and it never sees the API key.
Console
php bin/console jev:models php bin/console jev:ask "BUY CHEAP WATCHES!!!" "Is this message spam?" php bin/console jev:ask "I was charged twice" "Which team handles this?" -o billing -o shipping -o other php bin/console jev:ask "Server is down" "How urgent is this?" -l "can wait" -l "this week" -l "right now" echo "text" | php bin/console jev:ask - "Is this polite?" --client=moderation --model=jev-preview
Logging and metrics
Every call is logged on the jev Monolog channel: info on success, warning per retry,
error on failure. The context holds client, operation, model, request_id, status,
attempt(s), duration_ms, token counts and, for failures, outcome and error_class. The
API key, state and questions are never logged.
For metrics, implement JevMetricsInterface (one counter and one histogram method) and set
jev.metrics.service. You can also wrap two closures in CallbackMetrics:
use Jev\Bundle\Observability\JevMetricsInterface; use Prometheus\CollectorRegistry; final readonly class PrometheusJevMetrics implements JevMetricsInterface { public function __construct(private CollectorRegistry $registry) {} public function counter(string $name, float $value, array $labels): void { $this->registry->getOrRegisterCounter('app', $name, $name, array_keys($labels))->incBy($value, array_values($labels)); } public function histogram(string $name, float $value, array $labels): void { $this->registry->getOrRegisterHistogram('app', $name, $name, array_keys($labels))->observe($value, array_values($labels)); } }
Emitted series:
jev_requests_total{client,operation,outcome}jev_request_duration_seconds{client,operation,outcome}jev_retries_total{client,operation,reason}jev_tokens_total{client,model,direction}
outcome is success, the HTTP status, timeout, transport_error, invalid_response or
invalid_request. For anything more, implement Jev\Bundle\Observability\JevObserverInterface;
services are autoconfigured and receive every retry and completed call.
Testing your application
The Flex recipe enables fake: true in the test environment. Every client then becomes one
Jev\Bundle\Testing\JevClientFake, which makes no HTTP calls:
use Jev\Bundle\Exception\RateLimitException; use Jev\Bundle\Model\Evaluation; use Jev\Bundle\Testing\{FakeDecision, JevClientFake}; public function testSpamIsRejected(): void { $client = static::createClient(); $jev = static::getContainer()->get(JevClientFake::class); $jev->queue(FakeDecision::of(['rule' => FakeDecision::noul(0.97)])); // #[JevNoul] asks under id "rule" $client->request('POST', '/comments', ['body' => 'BUY NOW']); self::assertResponseStatusCodeSame(422); $jev->assertEvaluatedTimes(1); $jev->assertEvaluated(fn (Evaluation $e): bool => $e->state === 'BUY NOW'); }
queue()acceptsDecisionobjects,JevExceptions to throw, or closuresfn (Evaluation $e): Decision. Queued items are used in order.fallback()answers everything once the queue is empty.withModels()feedsmodels().FakeDecision::noul(),::choice()and::score()build answers.- Assertions:
assertEvaluatedTimes(),assertNothingEvaluated(),assertEvaluated(),assertQueueEmpty(). They report through PHPUnit when it is present. - An empty queue throws a message telling you what to queue.
Validator constraints and workflow guards ask under the question id rule. jev:ask uses
question.
Related projects
- The official Python SDK and JavaScript SDK by TypeSafe AI. This bundle's retry defaults and error model follow them.
- symfony/ai PR #2549 adds a TypeSafe bridge to the Symfony AI Platform. It exposes Jev as a generic AI platform model. This bundle is complementary: it provides Jev-specific typed questions and answers plus Symfony integrations (validator, workflow, Messenger, profiler) that a generic platform bridge doesn't cover.
Development
make install # downloads tools/composer.phar if missing, then composer install make verify # composer validate, PHPUnit, PHPStan (max level), php-cs-fixer TYPESAFE_API_KEY=... make test-live # 4 small real API calls
CI runs the suite on PHP 8.4 and 8.5 with Symfony 7.4 and 8.0. The live smoke test is skipped
when TYPESAFE_API_KEY is not set. The Flex recipe lives in recipe/, ready
for symfony/recipes-contrib.
Support and license
Report bugs through GitHub Issues. Include PHP/Symfony versions, the request id from the exception or profiler, and a minimal example without your API key. See CHANGELOG.md. Licensed under Apache-2.0; see LICENSE.