Skip to content

Releases: marcreichel/laya-php

1.4.0

Choose a tag to compare

@marcreichel marcreichel released this 12 Oct 21:10
7601058

Decisions move further into Laravel: a Decides validation rule checks input against a decision, and the AsDecision cast stores a decision object in a JSON column. Decision classes take float parameters, decideWithResult() keeps the raw probabilities next to the decision, and laya:eval measures a decision class against labelled examples.

// Validation
$request->validate([
    'body'    => ['required', 'string', Decides::that(Moderation::class, fn (Moderation $m) => ! $m->abusive && ! $m->spam)],
    'comment' => [Decides::not('Does the text contain insults, hate speech or threats?')],
]);

// Eloquent
protected function casts(): array
{
    return ['triage' => AsDecision::of(Triage::class)];
}

// The decision and the Result it came from
$decision = $laya->decideWithResult($ticket, Triage::class);
$decision->value;                                         // Triage
$decision->result->choice('department')->probabilities;

New

  • Decides validation rule. Decides::that(Class::class, fn ($decision) => ...), Decides::yes('question') and Decides::not('question'), with ->message() for your own wording. When laya-serve is unreachable or busy, validation fails unless you call ->passWhenUnavailable(). Other errors are thrown. The default validation.laya message ships in 12 languages under the laya namespace, and an app's own validation.laya wins.
  • AsDecision Eloquent cast. AsDecision::of(Triage::class) or AsDecision::class.':'.Triage::class stores a decision object in a JSON column. Enums are stored by backing value and read back through the constructor, so readonly classes and private promoted properties work. Lists (#[Of]), floats, #[Scale] enums and null round-trip, and a null column is a null attribute. Assigning anything other than an instance of the class, or reading JSON with a missing key, a wrong type or an unknown enum value, throws the new InvalidDecisionCastException, which names the model attribute.
  • float parameters in decision classes. A float with #[Levels] becomes a score question and gets the expected level (ScoreAnswer::$score). A float without #[Levels] becomes a yes/no question and gets P(yes). You can now sort and rank with decide() instead of dropping down to predict(). threshold on a float throws InvalidQuestionException. Laya::fake() accepts a fractional expected level and splits it between the two neighbouring levels (1.3 → 70% level 1, 30% level 2).
  • decideWithResult() and decideManyWithResult(). They return a Decision with the hydrated $value and the raw $result, so probabilities, confidences and truncated are available without asking the questions twice. The batch variant is @experimental like decideMany() and keeps the keys of $states.
  • laya:eval and vendor/bin/laya-eval. Score a decision class against labelled rows from CSV or JSON Lines. The report covers accuracy, a confusion matrix (choice, score) or precision and recall (yes/no), calibration per answerConfidence bucket, the lowest minConfidence that reaches --precision with the share sent to a human, and the best-F1 threshold for yes/no. --json prints it as JSON. vendor/bin/laya-eval runs without Laravel and reads --url/--api-key or LAYA_URL/LAYA_API_KEY. The Evaluation class gives you the same report in PHP. See examples/08-evaluation.php.
  • Laya::questions(). It returns the questions decide() would send for a class and throws the same InvalidQuestionException for a broken one, so a one-line test checks a decision class without a server or a fake.
  • Versioned cache keys. With versionCache (LAYA_CACHE_VERSIONED in Laravel, off by default), cache keys include laya-serve's checkpoint revisions, so cached answers from an old checkpoint aren't served after an upgrade. /health is called once per instance, and the revision hash is cached for 60 s. If /health fails, the plain key is used and the prediction still goes through.
  • The full usage report on Result. stateTokens, stateTokensDropped, truncatedQuestions and collapsedOptions (a CollapsedOptions per question id with total, distinct and tokensPerOption). Servers that don't send them leave the fields null or empty. laya:try names the cut-off questions and the dropped tokens in its truncation warning, and its --json output includes the whole report.
  • HTTP client settings in Laravel. LAYA_TIMEOUT and LAYA_RETRIES build a symfony/http-client client that retries on 503 and transport errors with backoff. To use your own client, set laya.http_client to a service id or bind Psr\Http\Client\ClientInterface; it takes precedence over both settings. Setting either one without symfony/http-client installed throws an exception that says what to install.
  • Configurable batch size. Pass batchSize: to new Laya() (Laravel: batch_size, from LAYA_BATCH_SIZE). The default stays 64, and a size below 1 throws InvalidOptionException.
  • Server limits in Laya::fake(). The fake now rejects what laya-serve rejects: too many questions, choice options, score levels or total options, too long a state, and too large a batch, with laya-serve's wording. Pass limits: (MarcReichel\Laya\Testing\Limits) to change them, or Limits::none() to turn them off.

Improved

  • Server errors from decide() and decideMany() name the decision class, e.g. Triage: laya-serve: too many questions (65 > 64) (HTTP 413).

Upgrading

There are no breaking API changes, but two things behave differently:

  • Laya::fake() enforces laya-serve's limits by default. A test that fakes more than 64 questions, an oversized batch or a very long state now throws a ValidationException, just as the real server would answer. Pass limits: Limits::none() to get the old behaviour.
  • ValidationException messages from decide() and decideMany() start with the class name. Update any test that matches the exact message.

compose.yaml now builds Laya v0.4.6 (c73908d) instead of v0.4.0. The 0.4.1–0.4.6 changes are additive and need no SDK changes.

1.3.0

Choose a tag to compare

@marcreichel marcreichel released this 11 Oct 17:18
c4480d9

Decision classes get two new shapes: an enum can be a score scale, and an array of enum cases asks one yes/no question per case. laya-serve can now abstain on low-confidence answers itself, predictions dispatch PSR-14 events, and php artisan laya:try lets you test a decision class on a piece of text without writing code.

final class Triage
{
    public function __construct(
        #[Ask('How urgent is this?')]
        public Urgency $urgency,              // #[Scale] enum: Low, Normal, Blocking

        #[Ask('Does the message mention {case}?')]
        #[Of(Topic::class)]
        public array $topics,                 // [Topic::Billing, Topic::Account]
    ) {}
}

$triage = $laya->decide($ticket, Triage::class, minConfidence: 0.7);

New

  • #[Of(Enum::class)] on array parameters. laya asks one yes/no question per case and fills {case} in the question with the case's #[Describe] text or its value. You get the cases with P(yes) ≥ 0.5 (or your threshold), in declaration order. The questions have ids like topics.billing. A class that needs more than 64 questions throws an InvalidQuestionException.
  • #[Scale] enums. A backed enum marked #[Scale] becomes a score question. Its cases are the levels, lowest first, and you get back the case at the most likely level instead of an index. A backed enum without #[Scale] stays a choice question.
  • Server-side abstention. predict(), predictMany(), decide() and decideMany() take minConfidence. Pass one threshold from 0 to 1, or a map with a threshold per option-count bucket (['choice:3-5' => 0.6, 'noul:2' => 0.8, 'default' => 0.7]). Answers report what the gate decided in abstention (the new Abstention enum), abstentionThreshold and lowConfidence. A malformed value throws the new InvalidOptionException before anything is sent.
  • PSR-14 events. Pass a dispatcher as events: to get PredictionMade after each prediction (cache hits included), PredictionFailed right before an exception is thrown, and CacheFailed when the cache misbehaves. States are left out of the events unless you set includeState. In Laravel, the service provider wires up the app's dispatcher, so listeners and Event::fake() work without any setup. Turn events off with LAYA_EVENTS=false, or include states with LAYA_EVENTS_INCLUDE_STATE=true.
  • php artisan laya:try. It runs a decision class on text from an argument, --file or STDIN. For each parameter it prints the question, the hydrated value, the answer confidence and a bar for every option's probability. It also shows the routed checkpoint and warns when the text was cut off. It takes --model, --max-len, --head-max-len and --json, and resolves short class names under App\.
  • Idle unload in laya:health. HealthStatus exposes idleUnloadSeconds and idleSeconds, and laya:health prints them. When laya-serve has unloaded its checkpoints after a quiet period, an empty "Loaded" list now comes with the reason.
  • More Laya::fake().
    • when() gives states that match a callback their own answers.
    • sequence() hands out answers in order.
    • assertNotPredicted() and assertNotDecided() check that something didn't happen.
    • The fake dispatches events, answers #[Of] and #[Scale] parameters, and applies minConfidence the way laya-serve does.
  • New example. examples/07-model-routing.php routes each prompt to the cheapest Claude model that can handle it.

Improved

  • The cache is best-effort. If a cache read throws or returns something that isn't a laya response, Laya treats it as a miss. If a write throws, you still get the result, just uncached. Both dispatch CacheFailed.
  • Identical states in one predictMany() call are sent once. Each copy still gets its own result and its own PredictionMade.
  • Decision classes are reflected once per process. decide(), decideMany() and assertDecided() share one cached reading of each class.
  • A PredictionFailed listener that throws no longer hides the laya error. You still get the original LayaException, such as ServerBusyException.

Upgrading

There are no breaking API changes, but a few things behave differently:

  • Responses that don't fit the decision class throw ServerException. When laya-serve's answers are missing, have the wrong type or carry a label outside the enum, decide() and decideMany() used to throw OutOfBoundsException, UnexpectedValueException or ValueError. They now throw a ServerException that names the parameter and keeps the original exception as previous. Catch LayaException to handle these along with the other laya errors.
  • Events are on by default in Laravel. If you listen for nothing, they cost next to nothing. To turn them off, set LAYA_EVENTS=false.
  • psr/simple-cache ^1.0 and ^2.0 are allowed as well as ^3.0.
  • Laya 0.4.0 changes the default routing. compose.yaml now builds laya v0.4.0 (3cf26cb). In 0.4.0, text whose language laya can't identify, such as very short text or text without letters, goes to the multilingual checkpoint instead of english. If your traffic is mostly English, start laya-serve with LAYA_DEFAULT_MODEL=english to keep the old behaviour. Laya::fake() now reports multilingual when no model is pinned.

Each feature needs a minimum laya-serve version:

Feature laya-serve
minConfidence per bucket, idle-unload fields 0.3.25
Multilingual default routing 0.4.0

1.2.1

Choose a tag to compare

@marcreichel marcreichel released this 07 Oct 11:34
9a9c8ea

laya-php now works with any server that follows TypeSafe's OpenAPI spec for POST /v1/systemone, such as sys1, not just laya-serve. CI now checks requests and responses against that spec on every change and every week.

Fixed

  • Answers without answer_confidence. Servers that only follow the spec don't send answer_confidence. Before, answerConfidence was 0.0 for them, so every minConfidence check failed. It now falls back to the answer's confidence. If a yes/no answer has no confidence either, it gets laya's own max(P(yes), P(no)). Thresholds and minConfidence keep working, though confidence is stricter than the calibrated value for choice and score answers.
  • Requests always name a model. The spec requires a model string. When you don't pin a Model, requests now send "model": "jev-latest" instead of leaving the field out. laya-serve still routes by language for that name, and sys1 reads it as the model it serves. Laya::fake() still reports null to assertions when laya routes, so tests don't change.

Upgrading

No breaking changes and no new requirements. The only change on the wire is the model field on requests that don't pin a checkpoint. laya-serve routes them the same as before, but the cache key includes the request, so those requests miss the cache once after you upgrade.

Full Changelog: 1.2.0...1.2.1

1.2.0

Choose a tag to compare

@marcreichel marcreichel released this 01 Oct 17:59
5abd152

You can now ask the same questions about many states in one call. laya-serve 0.3.22 answers them in shared forward passes instead of one request per state. Results also tell you when laya cut a state off, so you know when to raise maxLen.

$results = $laya->predictMany($tickets->pluck('body', 'id')->all(), $questions);
$results[42]->choice('department');

$triages = $laya->decideMany($tickets->pluck('body', 'id')->all(), Triage::class, maxLen: 4096);

New

  • predictMany() and decideMany() (experimental). They ask one set of questions about many states. Results keep the keys you pass in, so $results[42] belongs to ticket 42. Cached states aren't sent again, and the rest go out in requests of at most 64 states. They share the cache with predict(), so a state you already predicted on its own costs nothing in a batch.

  • maxLen and headMaxLen on batches. They work the same as on predict() and apply to every state in the batch.

  • $result->truncated. It's true when laya cut the state off at its token budget. Check it and retry with a higher maxLen:

    $result = $laya->predict($contract, $questions);
    
    if ($result->truncated) {
        $result = $laya->predict($contract, $questions, maxLen: 4096);
    }
  • Laya::fake() supports batches. Each state in a batch is recorded as its own prediction, so assertPredicted(), assertDecided() and assertPredictedCount() work the same whether your code batches or not.

  • New example. examples/06-contract-fields.php asks which contract fields a clause is relevant to.

Good to know

  • One state that laya-serve rejects fails the whole batch with an exception, the same way predict() throws.
  • predictMany() and decideMany() are experimental, so their signatures may change in a minor release.

Upgrading

No breaking changes. Existing calls send the same requests as in 1.1.1 and hit the same cache entries.

Each feature needs a minimum laya-serve version:

Feature laya-serve
predictMany(), decideMany(), $result->truncated 0.3.22
maxLen / headMaxLen on batches 0.3.23

A server older than 0.3.22 answers a batch with a ServerException that names the version it needs, and always reports truncated as false. A 0.3.22 server ignores batch budgets without an error.

compose.yaml now builds laya v0.3.23 (d8a2e59) for local development and the integration tests.

1.1.1

Choose a tag to compare

@marcreichel marcreichel released this 27 Sep 20:11
68fbb09

Laya used to cut every state off at the checkpoint's default length, 512 or 1,024 tokens, and there was nothing you could do about it from PHP. laya-serve 0.3.21 lets a request raise that limit, and 1.1.1 passes it through.

$laya->predict($contract, $questions, maxLen: 4096);
$laya->decide($ticket, Triage::class, headMaxLen: 384);

New

  • maxLen on predict() and decide(). It sets the token budget for the state. A long contract or email thread no longer loses its second half.
  • headMaxLen on predict() and decide(). It sets the token budget for each question and its options. Raise it when a choice question with many or long option descriptions comes back as a 422.
  • Separate cache entries. Different budgets are cached separately, because the budgets are part of the request.

laya-serve caps both values at LAYA_MAX_TOKEN_BUDGET, 8,192 by default. It answers anything higher, zero or negative with a 422, which you get as a ValidationException.

Upgrading

No breaking changes. If you leave both arguments out, the request is byte for byte the same as in 1.1.0.

The new arguments need laya-serve 0.3.21 or later. A 0.3.20 server ignores them without an error and keeps using the default length, so upgrade the server before you rely on them.

compose.yaml now builds laya v0.3.21 (9d95567) for local development and the integration tests.

1.1.0

Choose a tag to compare

@marcreichel marcreichel released this 27 Sep 19:07
35ecc6c

Until now, decide() gave you a typed object and dropped the confidence behind it. So the moment you wanted to send unsure tickets to a human, you had to fall back to predict(). 1.1.0 moves that check into the decision class, and adds caching and a health check for production.

final readonly class Triage
{
    public function __construct(
        #[Ask('Which department should handle this?', minConfidence: 0.7)]
        public ?Department $department, // null when laya isn't sure

        #[Ask('Does the user threaten to cancel?', yes: 'says they will cancel or switch', threshold: 0.3)]
        public bool $churn,
    ) {}
}

New

  • minConfidence on #[Ask]. A nullable parameter is null when the calibrated answerConfidence is below the threshold. If the parameter isn't nullable, decide() throws before it sends a request.
  • yes, no and threshold for bools. yes and no describe what each answer means, the same as Question::yesNo(). threshold sets the P(yes) at which the value becomes true, instead of the fixed 0.5. Setting any of them on a parameter that isn't a bool throws, so a typo can't be silently ignored.
  • Fake a decision object. Laya::fake(new Triage(...)) takes its answers from the object's public properties, so tests build the same type your code gets back, and there are no string keys to go stale when you rename a field.
  • Unsure fake answers. Register null and the fake answers with even probabilities and zero confidence. That lets you test the minConfidence path without a server.
  • assertDecided(Triage::class). It passes when a recorded request asked exactly that class's questions. An optional callback gets the state and the model.
  • PSR-16 caching. Pass cache: and cacheTtl: to the constructor. The key is a hash of the state, the questions and the pinned model. Laya gives the same answer to the same input, so repeat requests never reach the server. The key doesn't include the checkpoint revision, so clear the cache or set a TTL when you upgrade laya-serve.
  • Laravel caching. Set LAYA_CACHE_STORE (e.g. redis) and optionally LAYA_CACHE_TTL in seconds. Caching stays off by default.
  • php artisan laya:health. It prints the server's status, device and loaded checkpoints, and exits with 1 when laya-serve is unhealthy or unreachable. Put it in your deploy checks.

Upgrading

No breaking changes. The one new dependency is psr/simple-cache ^3.0, which only contains interfaces. A published config/laya.php picks up the new cache block from the package defaults, so the env vars work without republishing.

1.0.0

Choose a tag to compare

@marcreichel marcreichel released this 27 Sep 15:25
ef67283

Classify text in PHP without paying for an LLM API. laya-php is a typed SDK for Laya, a self-hosted model that answers classification questions about text in 100+ languages, and gives you calibrated confidence with every answer.

1.0.0 is the first stable release. From here on the public API follows semver.

$triage = $laya->decide($ticket, Triage::class);

$triage->department; // Department::Billing
$triage->churn;      // true

What's in 1.0

  • Three question types. Question::choice(), Question::score() and Question::yesNo() return typed answers with probabilities and a calibrated answerConfidence, which is comparable across question types.
  • Decisions into objects. $laya->decide($text, Triage::class) reads #[Ask], #[Describe] and #[Levels] attributes from a class and returns a hydrated instance. Backed enums map to choices, bools to yes/no and #[Levels] ints to scores.
  • Any PSR-18 client. Guzzle, Symfony HttpClient and others are auto-discovered, or you can pass your own client, for example one with retries.
  • Laravel integration (new since 0.0.1). For Laravel 13+, the service provider is auto-discovered and reads LAYA_URL and LAYA_API_KEY. Publish the config with php artisan vendor:publish --tag=laya-config.
  • Testing without a server. Laya::fake([...]) answers from values you register, and offers assertPredicted(), assertPredictedCount() and assertNothingPredicted(). In Laravel it also replaces the container binding, so injected code and queued jobs get the fake.
  • Typed exceptions. They cover authentication, validation, a busy server, a failed transport and other server errors, and all implement LayaException.
  • Checkpoint pinning. Pass model: Model::English, Model::Multilingual or Model::TypedDecisions. Otherwise Laya's router picks a checkpoint by language.
  • Jev-compatible. laya-serve speaks the same /v1/systemone wire protocol, which makes this a self-hosted alternative to Jev AI. It hasn't been tested against the hosted Jev API.

Quality

  • 100% line coverage and a 100% mutation score (Pest), both enforced in CI
  • PHPStan at level max
  • Tested on PHP 8.4 and 8.5
  • Integration-tested against a live laya-serve; compose.yaml pins upstream Laya to commit 4066d5d

Requirements

  • PHP 8.4+
  • A running laya-serve; the repository's compose.yaml starts one
  • Laravel 13+ (optional)

Upgrading from 0.0.1

No breaking changes. Laravel users can drop any manual Laya binding and use the auto-discovered provider.

composer require marcreichel/laya-php:^1.0

0.0.1

0.0.1 Pre-release
Pre-release

Choose a tag to compare

@marcreichel marcreichel released this 26 Sep 23:24
736c0d9