particle-academy/prism-harness
A testing harness for Laravel's Prism AI package. Fake model responses, record and replay AI interactions, and assert prompts, tool calls, and outputs in your test suite without hitting real APIs or burning tokens.
composer require prism/harness (v0.15.0 is additive — no breaking changes, existing capped runs without an observer continue to work).php artisan vendor:publish --tag=prism-harness-config. The relevant key for this release is prism-harness.cost_observer — a container-resolved class name used only as a fallback when a run tree has no observer.Prism\Harness\Contracts\CallCostObserver, the DTOs CallReservation (callId, provider, model, worstCaseUsd, kind) and CallSettlement (callId, chargedUsd, usageKnown, ?Usage, outcome), and RunContext::root(...).use Prism\Harness\RunContext;
$run = RunContext::root(
budgetCapUsd: 25.00, // required when an observer is attached
observer: new \App\Harness\SpendLedgerObserver(),
);
$response = Prism::text()
->using('anthropic', 'claude-sonnet-4-5')
->withPrompt('...')
->run($run); // or ->send/->stream/->sendStructured($run)
Observer as a ledger mirror. Back the observer with a table or Redis key keyed by callId. reserving() writes a pending row with worstCaseUsd; settled() flips it to settled with chargedUsd. Because the harness charges exactly the reserved maximum when usage is unknown, your row and the harness ledger agree without you recomputing anything — never derive cost yourself.
Use reserving() for policy, not accounting. This is the only place you can refuse. Throw CallRefused('org_budget_exhausted') for a clean, typed refusal; the call is never sent, the reservation is released without charge, and the run fails with RunNotPermitted. Anything else you throw becomes call_refused_by_host with your exception chained as previous — fine for genuine errors, bad as a control-flow signal.
public function reserving(CallReservation $call, RunContext $run): void
{
if ($this->balance($run) < $call->worstCaseUsd) {
throw new CallRefused('org_budget_exhausted');
}
$this->store->reserve($call->callId, $call->worstCaseUsd, $call->kind);
}
settled() must be defensive. It fires exactly once per admitted call — completed, failed, or a stream the caller stopped reading — which is precisely what makes it the right place to close the row. But if it throws, the harness settlement still stands and the exception propagates out of your application code. Wrap the body in try/catch, log, and return; never let bookkeeping break a completed request.
Make it idempotent on callId. Use insertOrIgnore / SETNX-style upserts. A retried job, a queue restart, or a duplicated settlement callback should be a no-op, not a double charge.
Bucket by kind. text, structured, and stream are distinguishable at reserve time, and the same observer covers send, sendStructured, stream, plus approve/deny/decide continuations. Streaming is the interesting one: settled() still fires when the consumer disconnects mid-stream, so you get a truthful number for a request nobody finished reading.
Set the observer on the root, once. Children inherit and a run tree cannot drop or replace it. Fan-out workers should be spawned beneath the root that already carries the observer, not created as fresh roots.
$child = $run->child(...); // inherits $observer
Prefer the config fallback for ambient observers (metrics, tracing) and explicit injection for tests. Because the config value is container-resolved, $this->app->instance(SpendLedgerObserver::class, $fake) gives you a fake everywhere without touching call sites.
Testing shape: an in-memory observer asserting reserving then settled per call, with chargedUsd matching the reservation, is a one-class integration test that covers the whole capped path — including the refusal case.
budget_observer_uncapped. Attaching an observer to a run with no cost cap is refused outright. The rationale is sound (only capped runs serialize one model call at a time), but it bites in tests and ad-hoc scripts: always pass a cap, even an absurd one like 9999.00.reserving() and settled(), your row stays open forever — the harness has no reaper, because it does not own your storage. Build a TTL sweep: expire pending reservations older than your longest plausible call and reconcile against the harness ledger rather than assuming the worst.CallRefused codes must be lowercase and ≤64 characters. Violating that silently degrades to call_refused_by_host, which is a miserable thing to debug from a log line.usage exists. usageKnown can be false and Usage is nullable; chargedUsd is then the reserved maximum. Dashboards reading usage->totalTokens will NPE on exactly the calls you most want to see.reserving() runs after admission and before any provider call, so every millisecond is added to your first-token latency. Keep it to a single indexed upsert; push analytics to a queue.reserving() to settled().settled() failures are visible but not recoverable. Because the exception propagates after settlement is already committed, a try { ... } finally in your controller will see an exception whose underlying charge succeeded. Catch inside the observer.callId. It is identical at reserve and settle; log it in both, and alert on reservations with no matching settlement older than N minutes — that is your crash detector, and it doubles as the leak detector from the point above.RunNotPermitted, and the cause is either your CallRefused code or call_refused_by_host with your original exception as previous. Check previous first when the code is generic.How can I help you explore Laravel packages today?