Carrying the torch: 48 community PRs merged + fork fixes (v0.101.0-v0.109.0) - #1028
Open
wishborn wants to merge 208 commits into
Open
Carrying the torch: 48 community PRs merged + fork fixes (v0.101.0-v0.109.0)#1028wishborn wants to merge 208 commits into
wishborn wants to merge 208 commits into
Conversation
Add comprehensive Replicate provider implementation supporting all core features: text generation, streaming (SSE), structured output, embeddings, image generation, and audio (TTS/STT). Features: - Text generation with system prompts and conversation history - Real-time SSE streaming with automatic fallback to simulated streaming - Structured output with JSON schema validation - Image generation (FLUX, Stable Diffusion XL, etc.) - Text-to-Speech with multiple voices (Kokoro-82m) - Speech-to-Text with Whisper (WAV, MP3, FLAC, OGG, M4A) - Embeddings (single and batch, 768-dimensional vectors) Implementation: - Async prediction management with configurable polling - Sync mode (Prefer: wait header) for lower latency - Comprehensive error handling with typed exceptions - Full PHPStan level 8 compliance - 21 tests with 60 assertions (100% feature coverage) - 455 lines of comprehensive documentation Files changed: 58 files, 4,444+ lines added
…chronously This adds the ability to be able to send a request to a provider to create a transcript where the provider will give you an id and then send a webhook to you in the future when the job is done with that id. This is just supplying the interface that a provider can utilize in the future.
Add comprehensive support for Alibaba Cloud's Qwen models via the DashScope native API (/api/v1), covering text generation, streaming, structured output, embeddings, image generation, and image editing. Key features: - Text generation with multi-step tool calling - Multi-modal (VL) support with automatic endpoint routing - Streaming with DashScope SSE protocol and reasoning/thinking tokens - Structured output with both JSON Object and JSON Schema modes - Embeddings with configurable dimensions - Image generation (qwen-image-max/plus) and editing (qwen-image-edit) - Region-aware configuration (International, China, US deployments) - 52 tests with real API fixtures (176 assertions) Co-authored-by: Cursor <cursoragent@cursor.com>
StreamEndEvent.usage can be null when providers don't include usage data in their final stream chunk, causing a TypeError downstream. Add `?? new Usage(0, 0)` fallback to emitStreamEndEvent() in all providers missing it, matching the existing pattern in the OpenAI stream handler.
Add an `api_format` config option to the OpenAI driver that allows switching from the default `/responses` endpoint to `/chat/completions`. This enables using Prism with OpenAI-compatible backends like vLLM, LiteLLM, and LocalAI that only implement the chat/completions API. Set `OPENAI_API_FORMAT=chat_completions` in your env to use it. Only text, structured, and stream methods dispatch conditionally — other modalities (embeddings, images, moderation, TTS, STT) already use standard endpoints that work with compatible backends as-is.
…nfigured Providers that reject unknown parameters (e.g. Perplexity via LiteLLM) return HTTP 400 when `"tools": []` is sent. Return null instead so Arr::whereNotNull() filters it out entirely.
Providers with integrated search capabilities (e.g. Perplexity, You.com) return top-level `citations` and `search_results` fields in chat/completions responses. These were previously ignored. Add ChatCompletionsCitationsMapper to map these into Prism's existing Citation infrastructure, and extract them once per stream in the ChatCompletions stream handler. Citations are passed through on the StreamEndEvent, matching the existing pattern used by the Anthropic handler.
…ob management and result handling
…h job management, result handling, and error mapping
…-anthropic-and-openai
…lue for data retrieval
… provider methods for batch management
… tool loop ## Context When Anthropic's server-side tools (like `web_search`) are used alongside regular user-defined tools, the model can do both in a single response: perform a web search, write text with citations referencing the search results, and call a regular tool. Because a regular tool was called, Prism enters its multi-step tool loop. It executes the tool, then replays the entire conversation back to the API for the next turn. The problem is that when Prism builds the replayed assistant message, it includes the text with citations but drops the `server_tool_use` and `web_search_tool_result` content blocks that the citations reference. The API validates that every citation points to an existing search result, finds none, and rejects the request with: `invalid_request_error - Could not find search result for citation index.` This only triggers when the model performs a server-side tool call AND a regular tool call in the same response. If either happens alone, everything works fine. ## Changes Both the Text and Stream handlers had the same gap in their tool loop replay logic. **Text handler (`Text.php`):** Added `extractProviderToolContent()` that pulls `server_tool_use` and `*_tool_result` content blocks from the API response and stores them in `additionalContent` as `provider_tool_calls` and `provider_tool_results`, the same keys that `MessageMap::mapAssistantMessage()` already reads and serializes back to the API. This follows the existing pattern of `extractText()`, `extractCitations()`, and `extractThinking()`. **Stream handler (`Stream.php`):** The stream state already tracked provider tool calls, provider tool results, and citations during streaming, but `handleToolCalls()` only included `thinking` and `thinking_signature` in the replayed `AssistantMessage`'s `additionalContent`. Now it also includes `citations`, `provider_tool_calls`, and `provider_tool_results`.
…delete, and metadata retrieval functionalities
…e batch job handling with inputFileId support
…xtRequest
- Use ?? [] on items to avoid passing null to buildAndUploadFile()
- Cast json_encode() result to string to satisfy non-empty-string return type
- Change clientRetry default from [] to [0] to satisfy array{0: int} type constraint
Made-with: Cursor
…s from OpenAI responses
…update tests for empty array responses
docs: point README to the ai.particle.academy docs site
The recursive tool loop runs step N's tools before step N is recorded, so ToolInvoked carried no step ordinal and a consumer could not nest a tool span under its step. Track a per-generation step cursor on the ContextStack, advance it once per executed tool batch, and stamp it onto every ToolInvoked. No-op when telemetry is disabled.
…s + bounded content capture Adopts upstream issue prism-php#935: neutral Laravel telemetry events across the generation lifecycle (context/stack, step/tool ordinals, user/session ids), bounded opt-in content capture, and a step cursor so tool events are tagged with their owning step. Off by default and a complete no-op when disabled. Validated end-to-end (real multi-step tool generation -> OpenTelemetry bridge -> Phoenix) and security-reviewed (PASS WITH WARNINGS).
Pairs with the "main protection" branch ruleset: outside contributions now need a pull request with an approving review from a code owner before they can land on main.
The Gemini API specifies `tools` as `Tool[]`, but four handlers built it in ways
that break that contract once more than one kind of tool is in play.
**Mixed keys → JSON object.** Gemini/Text, Gemini/Stream and Vertex/Text map
provider tools into a numerically keyed list and then assign
`$tools['function_declarations'] = …` on top. The result is a mixed-key array,
which `json_encode` emits as an object:
"tools": {"0": {"google_search": {}}, "function_declarations": [...]}
Gemini and Vertex both reject that, so combining Google Search grounding with
any custom tool fails outright.
**Silent overwrite.** Vertex/Structured reassigns `$tools` instead of appending,
so provider tools vanish whenever custom tools are present.
**Mutual exclusion.** Vertex/Stream chained `elseif`, so custom tools were only
ever sent when there were neither provider tools nor `searchGrounding` — the
combination was impossible rather than merely malformed.
All four now append a separate Tool entry, matching Gemini/Structured, which
already had it right and serves as the reference:
"tools": [{"google_search": {}}, {"function_declarations": [...]}]
Provider-tool precedence in Vertex/Stream (explicit provider tools over the
legacy `searchGrounding` option) is preserved; only the custom-tool branch
becomes additive.
Adds a regression test asserting `array_is_list($data['tools'])` plus both
entries. Verified it fails on the current code and passes with the fix; the 169
Gemini + Vertex tests and Pint stay green.
Extends the fix with runnable coverage for the previously untested paths: Gemini/Text custom-tools-only, Gemini/Stream grounding + custom tools, and Vertex/Text custom-tools-only — each asserting `tools` serializes as a JSON array (array_is_list), the shape Gemini and Vertex require.
Covers enabling telemetry, the config block, the five lifecycle events and their payloads, listening, TelemetryContext, user/session metadata via withTelemetryMetadata(), the content-capture PII bounds, and exporting to OpenTelemetry / Arize Phoenix via prism-opentelemetry. Adds it under Advanced.
Resolves the open high-severity Dependabot alert for postcss (GHSA-r28c-9q8g-f849, path traversal via sourceMappingURL) plus two further high advisories npm audit surfaced in the same tree: brace-expansion (GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895) and js-yaml (GHSA-5p4m-2wfm-xmqj). All three are transitive dev dependencies of the VitePress docs site and were already permitted by the existing semver ranges — the lockfile was simply stale. Refreshed with `npm audit fix --package-lock-only`, so no override pins and no package.json changes. `npm audit` now reports zero vulnerabilities.
Two repository-hygiene fixes found during the v0.111.1 release audit. **Line endings.** There was no eol rule, so on Windows (core.autocrlf=true) every checkout writes CRLF while Pint writes LF. `git status` then reports 30+ files as modified with empty `git diff`s, and `git add -A` will stage that noise into an unrelated commit. `* text=auto eol=lf` makes the working tree match what the tools produce. Renormalising touches exactly five files. Every fixture is stored with LF except tests/Fixtures/gemini/stream-with-tools*.json, committed with CRLF and the anomaly — including the .sse stream fixtures, whose CRLF I first took for stored bytes when it was only a checkout artefact. Those five are now LF like the rest; the Gemini suite (146 tests) passes unchanged, so nothing depended on it. **docs.** /docs was the only non-runtime path not export-ignored, so 9.6M of committed .vitepress/dist build output and a dev package-lock.json ship inside every Composer install — dead weight in vendor/, and a lockfile that makes dependency scanners flag consumers for advisories in our docs toolchain. The directory itself must keep shipping: the docs site renders markdown AND parses the VitePress sidebar out of vendor/particle-academy/prism/docs, and installs with --prefer-dist. Excluding /docs wholesale would have taken that site down. So only what nothing reads at runtime is excluded, verified against a real `git archive`: 46 markdown files and .vitepress/config.mts still present, dist output and lockfile gone. 1923 passed, 11 skipped.
"0" is falsy in PHP, so `if ($content)` silently discards a message, delta or payload whose entire text is the single character 0. Rector's ExplicitBoolCompareRector (SetList::CODE_QUALITY) rewrote those checks into the explicit `$content !== '' && $content !== '0'`, which is a faithful translation — and is why the bug survived in plain sight. A lone "0" is ordinary model output: a count, a numeric answer, a JSON number, or one digit landing alone in a stream chunk. Scoped to the call sites where a "0" can actually reach a user: message maps Anthropic, Gemini, OpenAI, OpenRouter, Requesty stream deltas Azure, DeepSeek, Qwen, XAI structured output Gemini, Vertex (a "0" body was reported as empty) file content Media::fromLocalPath and rawContent used `?: ''` Deliberately NOT swept: the same pattern in Anthropic's SSE line parsing, Ollama's line parsing, the TTS voice name and ToolCall::arguments(). A data line or a voice is never the single character 0, and ToolCall already funnels through `is_array($decoded)`, so changing them would edit real files across four more providers to fix nothing. Two findings beyond the mechanical rewrite: - HandlesStructuredJson returned json_decode() straight from a method declared `: array`. The "0" guard was accidentally shielding it, so any scalar JSON from a provider — "12", "\"text\"", "true" — raised a TypeError. Now guarded on the decoded shape. - Anthropic's assistant map built its text block with a bare array_filter(), dropping 'text' => '0' while only meaning to drop a null cache_control. Removing the '0' arm made three thinking-complete conditions provably constant; PHPStan flagged them and they are gone, matching upstream prism-php#1005. Rector's rule stays enabled: it is what made this visible. Suppressing it would only hide the same bug behind `if ($content)`. Covered by tests/Regression/FalsyZeroStringTest.php, verified to fail against the unfixed source with these exact modes (including the TypeError).
Closes #14. All three Vertex handlers refused provider tools alongside custom tools: throw new PrismException('Use of provider tools with custom tools is not currently supported by Vertex.'); Investigated rather than assumed, because #12 left its Structured and Stream fixes unreachable behind these guards. The guards are not evidence of an API restriction. They arrived with the very first Vertex support commit (d81de06, Feb 2026) as a conservative placeholder, not in response to Vertex rejecting anything, and were never revisited. The Vertex handlers are otherwise near-copies of the Gemini ones, which carry no such guard — so the same package already permits on Gemini exactly what it forbade on Vertex, against the same API shape. Google documents the combination as supported: "Gemini 3 models also support combining these built-in tools with custom tools (function calling)". That is precisely the case reported in #12 — Gemini 3 on Vertex with Google Search grounding plus custom function tools. Removing all four guards (the three provider-tool ones plus Stream's separate searchGrounding one, whose message claimed Prism did not support the combination at all — it does, on Gemini). #12 already fixed the payload these paths emit, so the tools array serialises as a proper Tool[]; the code was correct and simply unreachable. Tests assert the combined payload is a list of two entries, google_search then function_declarations, and that the call no longer throws. Not verified against the live Vertex API — that needs Google Cloud credentials I do not have. If Vertex does reject some model/tool combination, the caller now gets Google's own error, which is more accurate than a blanket Prism exception that contradicts Google's docs. Pint · PHPStan clean · 1925 passed, 11 skipped.
Upstream PR by @mrmorgan-i. Browsers report the CONTAINER type, so an audio-only MediaRecorder clip arrives as video/webm or video/mp4 and was being written out as audio.mp3 — the wrong extension on every browser recording, which providers then reject or mis-transcode. Applied upstream's mapping (video/mp4, video/ogg, video/webm) plus one gap it does not cover: MediaRecorder attaches codec parameters, so the very mime types this fix targets arrive as "audio/webm;codecs=opus" and fell straight through to the mp3 default anyway. The mime type is now reduced to its bare type and lowercased before matching, so the fix works on real browser output rather than only on the canonical strings.
Inspired by prism-php#1022 by @cerebrixos, rewritten rather than absorbed. The gap that PR identified is real: `url` appears in the config block but nothing on the page says it can point somewhere other than OpenAI, so the single most common deployment question — "can I put this behind vLLM / a gateway / Azure" — is unanswered. The upstream PR answers it with one vendor's product as the worked example, including their hostname, their env var name, and a paragraph of their positioning. Merging that would put a supplier advertisement in our provider docs and date the page to that supplier. So this documents the capability instead: a neutral placeholder URL, self-hosted runtimes and gateways named as categories, and no vendor endorsed. Also adds what a user actually gets wrong here and the upstream text omits: "OpenAI-compatible" rarely covers the whole surface, so an unsupported feature fails at the endpoint rather than in Prism; and model names drive capability inference, so a llama model behind the OpenAI provider is not treated as structured-output capable. Closes with a pointer to the dedicated providers, which map their APIs' real quirks.
…#16) Follow-up to the falsy-"0" fix. The message maps built their payloads with a bare `array_filter()`, which drops EVERY falsy value. That is correct for a null cache_control or an empty tool_calls array — and wrong for `'content' => '0'`, which is falsy in PHP but is ordinary model output. The recurring shape, present in eight providers: array_filter([ 'role' => 'assistant', 'content' => $message->content, // '0' silently removed 'tool_calls' => $toolCalls, // [] correctly removed ]) Because the same array mixes a content value with a collection that SHOULD vanish when empty, neither a plain `array_filter` nor `Arr::whereNotNull` is right. Added Providers\Support\Payload::compact(), which keeps the original intent — no null, no empty string, no empty array, no false — and keeps scalar zero in string and int form. Converted 21 content-bearing sites across Anthropic, Azure, Groq, Mistral, Ollama, OpenAI (chat completions), OpenRouter, Qwen, Requesty and XAI, covering assistant content, user text, system prompts, tool-result content and citation text. The Anthropic site fixed with Arr::whereNotNull in the previous commit now uses the same helper, so there is one mechanism. Deliberately NOT converted: the remaining array_filter calls carry file ids, model names, provider options, schema fragments and tool names/descriptions — no message content — so changing them would alter provider payloads for no benefit. One two-argument call in Anthropic's CitationsMapper already had an explicit null-only callback and was correct; a first pass converted it by mistake and PHPStan caught it. Tests: tests/Regression/PayloadCompactTest.php, 20 cases. Verified against the unfixed source — the 10 "keeps 0" cases fail, and the 8 "still omits an empty tool_calls array" guards pass, confirming the strip behaviour the array_filter existed for is unchanged. Pint · PHPStan clean · 1950 passed, 11 skipped. Known, not addressed: ElevenLabs' speech-to-text call array_filters boolean provider options, so `diarize: false` is dropped. Same falsy class, but a boolean rather than "0", and dropping it may match the API default. Needs a maintainer decision rather than a blind change.
Without a `replace`, a package requiring prism-php/prism installs it
ALONGSIDE this fork — two copies of the `Prism\Prism\` namespace, with
whichever autoloader wins deciding behaviour. Not hypothetical: requiring
prism-php/relay next to this package resolved to prism-php/prism v0.100.1
in addition to us, silently.
Composer now refuses the combination outright:
Only one of these can be installed: prism-php/prism[v0.93.0, ...,
v0.100.1], particle-academy/prism. particle-academy/prism replaces
prism-php/prism and thus cannot coexist with it.
and, given a released version, resolves relay against this fork instead —
verified with a scratch project whose path repo reports 0.112.1:
Locking particle-academy/prism (0.112.1)
Locking prism-php/relay (v1.8.0)
No prism-php/prism in the tree. This unblocks every package pinned to
upstream within its constraint; relay is just the case that surfaced it.
`self.version` rather than `*`: upstream stopped at v0.100.1 and this fork
continues that lineage to v0.112.0, so claiming to BE 0.112.0 of it is
accurate. `*` would also satisfy a dependent pinned to `^0.100` — caret
pins the minor on a 0.x — and telling that dependent we are compatible
when we are twelve minors ahead is the overclaim that breaks people. Those
should widen deliberately, as prism-opentelemetry did.
Takes effect only in a published version, since `replace` is read from the
package metadata Packagist serves.
Pint · Rector · PHPStan clean · 1963 passed, 11 skipped.
Prism has no conversation memory: every call rebuilds the message array by hand, and an application that already stores its conversations has no way to hand one over. This adds the smallest thing that fixes it. `Thread` describes a stored conversation and nothing else — one method returning the messages exchanged so far. No Eloquent, no migrations, no config, no storage opinion. An Eloquent model, a cache entry or an array in a test all satisfy it equally. The contract is deliberately read-only. Prism never writes to a thread, because it does not need to: `$response->messages` is already the full exchange including the tool calls and results from every step, so a caller persists what it wants afterwards and Prism stays out of schema and lifecycle decisions entirely. A conversation interrupted mid-tool-loop can therefore be stored and resumed where it stopped. History composes with a new turn rather than replacing it. `withMessages()` and `withPrompt()` remain mutually exclusive, but a thread is the history and the prompt is the turn being taken now, so those two work together — which is the whole point of continuing a conversation instead of rebuilding it. Resolved history is memoised. `messages()` may return a Generator, and a Generator is spent after one pass; without memoisation, building the request a second time would quietly produce a conversation with no history at all. Works on both text and structured requests, and therefore on streaming too, since it lives on the shared HasMessages concern. Note for implementers, documented on the interface and in the docs: whatever a thread returns is replayed to the model as context, and `Message` includes `SystemMessage`. Stored history is only as trustworthy as the store it came from, and Prism cannot vouch for it.
Found by dogfooding the migration against a live key, which is the only way
this surfaces: the Lab passed withTools() and got a perfectly good answer back
with zero steps and zero tool calls. Nothing errored. The tool was simply
never offered to the model, and the run reads as the model choosing not to
call it.
Perplexity's tools run server-side — you declare which of ITS tools a run may
use (web_search, fetch_url, sandbox, mcp) and it executes them itself. There
is no round trip that would let it invoke a PHP closure, so a Prism Tool
cannot be handed over at all.
This is not a regression: the provider never supported Prism tools, on the
Sonar endpoint either. It has been silently dropping them the whole time. Now
it says so, and points at the way that does work:
->withProviderOptions(['tools' => [['type' => 'web_search']]])
Verified live rather than assumed. The first version of this check appeared to
pass because the probe built its Tool wrongly and threw for that reason
instead — the guard is confirmed against a correctly constructed Tool, and
Perplexity's own server-side tools confirmed still working through provider
options, returning ten sources.
Two other questions the docs could not settle, now answered against a real
endpoint:
Multi-turn `input` as an item array WORKS. A three-message conversation came
back correctly answered from history, so sending roles rather than a flattened
string is right.
`response.model` really does echo a third party: a `sonar` request resolved to
`openai/gpt-5.6-luna`, which is exactly why it is surfaced as
additionalContent['resolved_model'].
1991 tests / 6664 assertions.
DeepSeek rotated to a v4 generation on 2026-08-25 and withdrew deepseek-chat along with deepseek-reasoner. The streaming example named deepseek-chat, and docs examples get copied verbatim — so this one shipped a first request that fails. deepseek-v4-flash is where deepseek-chat sat: their fast general model. The rest of the current roster is deepseek-v4-pro and deepseek-v4-flash-vision-exp. Left alone deliberately: `deepseek/deepseek-chat-v3-0324` on the OpenRouter page and `deepseek/deepseek-chat` on the Requesty page. Those are those aggregators' own catalog names, which are namespaced separately from DeepSeek's direct API and may well still resolve there. Changing them on the strength of a DeepSeek delisting would be a guess. Caught by the drift watcher.
The Lab showed "provider reported cost —" with "derived in Phoenix after export" beside it. For Anthropic and OpenAI that is correct: they return tokens and no price. For Perplexity it was wrong. It prices every request in its own response and we dropped the number, so an application that could have had the exact figure was left deriving an estimate from a rate card. Perplexity is one of only two providers that do this — OpenRouter is the other, and already reads it. Its shape differs: OpenRouter sends a flat scalar, Perplexity a breakdown, so the total comes from usage.cost.total_cost and the input/output/request components stay on the raw response. Mine to fix: the ExtractsUsage written for the Agent API migration carried tokens and left cost behind. Zero is treated as an answer rather than an absence. A cached or free-tier request costs nothing, and returning null there would send the caller off to estimate a figure the provider had already given them. Also defaulted the Meta fields. Meta types id and model as non-nullable strings and Perplexity passed data_get straight in, so a response missing either raised a TypeError inside a value object — naming Meta rather than the provider that omitted the field, and failing a generation that had otherwise completed. OpenRouter already defaults these; this matches. Found because two new tests built minimal responses, which is the shape a proxy or a future API version can produce. 1994 tests / 6667 assertions.
* Stop dropping a prompt that is exactly "0" Found by prism-parity's conformance corpus on its first run against the released package — which is the sort of defect a corpus exists to find, because nothing about it looks wrong from inside the codebase. `toRequest()` gated the prompt on truthiness, twice, and PHP considers "0" falsy. Two failures follow, and the second is the serious one. A prompt of "0" was dropped: the message list came back empty and the model was asked nothing. "0" is a legitimate prompt — an answer to "how many", a menu selection, a minimal test fixture. And the prompt-versus-messages refusal was gated on the same truthiness. So a caller who set BOTH messages and a "0" prompt got no exception AND no prompt: a successful call that answered a different question than the one asked, with nothing anywhere to indicate it. An error would have been far better. Both builders had it, text and structured. This is the same falsy-zero class that 0f4e8ae fixed across eleven providers' message maps. That fix reached the maps and missed the entry point, which is worth recording: the payload could not corrupt the value any more, but the value was already gone before the payload was built. `filled()` rather than an explicit null-and-empty-string comparison, matching the helper already used a few lines below for tools. 1993 tests / 6667 assertions. * Use an explicit emptiness test rather than filled() filled() fixed "0" and broke " ". It trims, so a whitespace-only prompt started being dropped in exactly the silent way this change exists to prevent — the same defect, one input over. "" and "0" are the only strings PHP counts as falsy, so testing !== null && !== '' differs from the original truthiness check on exactly one input: the one being fixed. Adds the whitespace regression guards and pins the "" boundary, so the next person to reach for filled() here sees it fail.
Found while building prism-memory, which round-trips embeddings through storage and hit this immediately. `toArray()` satisfies Arrayable and wraps the vector under an `embedding` key. `fromArray()` took the bare list. So the obvious round trip — `Embedding::fromArray($e->toArray())` — built an embedding whose components were a single nested array. That does not fail where the mistake is. It fails at the first arithmetic, somewhere else entirely, with a value that looks like a vector until you index into it. A named constructor that cannot consume its own serialiser is a trap a caller can only find by falling into it. The shapes are unambiguous — a wrapper has a string key, a vector has integer keys — so accepting both costs nothing. Not fixed here, and worth their own change: `Embedding::$embedding` is typed `int|string|float` and is not readonly, so every consumer normalises it defensively and nothing stops it being mutated after construction. Narrowing the type is a BC decision rather than a bug fix. 1996 tests / 6670 assertions.
Nothing here had one. An agent landing in the repo cold had the README — which is written for someone USING the package — and no statement of what has to stay true while they change it. AGENTS.md is that: the boundary this package holds, the gates, and the traps that already cost someone time. It deliberately does not restate the README or the ecosystem rules; the shared half lives once in prism-parity/docs/AGENTS.md and this links there, for the same reason the patterns live once — restated documentation drifts exactly like restated code, and nothing tests prose. README points at it with an @link. CLAUDE.md is a one-line pointer so harnesses that look for that filename find the same file rather than a second copy to keep in sync.
Upstream added CLAUDE.md and AGENTS.md to .gitignore in prism-php#445, for the right reason at the time: a contributor's own scratch file has no business in the repository. This fork wants the opposite file. AGENTS.md here is not somebody's private notes — it is the shipped statement of what core is allowed to become, which the README now points at and which every satellite's guide assumes exists. A guide that cannot be committed cannot be relied on. .claude/ stays ignored. That IS per-developer harness configuration and upstream's reasoning still holds for it.
Writing a class-based tool by hand means repeating a shape that has three
easy ways to get wrong: the model-facing name, a schema that agrees with
the handler signature, and the fact that a subclass needs no ->using()
because Prism falls back to __invoke.
php artisan make:prism-tool SearchTool \
--description="Search the web for current events" \
--parameter="query:string:What to search for" \
--parameter="scope:enum(web,news,images):Which index to search" \
--parameter="limit:integer?:How many results to return"
Deliberately NOT make:mcp-tool. laravel/mcp already owns that name and it
generates the opposite thing — a tool your application exposes over MCP,
rather than one you hand to a model. Shadowing it would have put two
commands with one name at opposite ends of a protocol; the directions are
recorded in prism-parity decision 0018 and the docs say which is which.
Three decisions worth knowing:
Optional parameters are emitted last whatever order they were listed in.
PHP will not accept a required argument after an optional one, so
honouring the given order would generate a file that is a fatal parse
error. Reordering is invisible to the model, which addresses parameters
by name.
Array and object parameters are refused rather than half-generated. They
need a Schema instance a flat flag cannot express, and emitting a broken
withArrayParameter() for someone to repair is worse than saying so and
naming the guide.
Bad flags fail before anything is written, via fail() rather than a falsy
return — Laravel casts a falsy handle() return to exit code 0, so a
generator that prints an error and returns false still tells CI it
succeeded.
Tests cover the source AND load the generated class to drive it through
Prism's own handler resolution, because a generator whose output is a
parse error passes every string assertion you can write about it. The
example in the docs is pinned by a test so the guide cannot go quietly
stale.
The site had no section for them at all — the only trace of a companion anywhere was a passing mention of prism-opentelemetry in the telemetry page. Anyone evaluating Prism saw a provider shuttle and no evidence that sessions, memory, workspaces or MCP existed. New Companion Packages section: an overview that explains WHY the split exists (every capability added to core is one eighteen providers carry forever, so the question is never "is this useful" but "which companion owns it"), then a page each for Harness, MCP, Memory and Workspace. Harness is marked a release candidate still in testing, and its table states the status of every row. An earlier version of that table in the package README did not, and it misled a reader into believing tool gating was implemented; identical weight and position were doing two different jobs. Memory and Workspace are documented as NOT yet on Packagist, with a VCS repository block instead of a composer require that would fail. Four of the six are published; saying so beats printing install commands that do not work. Two fixes while in the config: The Replicate sidebar entry had three text/link pairs in one object literal. JS keeps the last, so Replicate and Qwen have never rendered in the sidebar at all despite both providers shipping. Relay is gone from the packages list. It is superseded by prism-mcp, which declares `replace` on it, so linking it as a recommended package pointed people at a client that hardcodes protocol 2024-11-05 and has no trust boundary.
The xAI page imported Prism\Prism\Schema\IntegerSchema and built a property with it. There is no such class — src/Schema has Number, not Integer — so anyone copying that example got a fatal error, and it has been sitting in a provider page nobody re-read. Found by prism-parity's factcheck, which is also wired up here: it reads every `use` in a php block and holds it to a class that exists.
make:prism-tool uses Symfony\Component\Console\Attribute\AsCommand and InputOption directly. Both arrived transitively through laravel/framework and neither was declared, so composer-require-checker failed the build — correctly, and on my change. Declared rather than whitelisted. The whitelist exists for symbols we genuinely do not depend on; this package now ships a console command and uses that component's API in it, which is a dependency. Silencing the check instead would leave a real transitive reliance that breaks the day Laravel restructures its own requirements. Constraint mirrors symfony/http-foundation, already required here on the same policy.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Upstream has been quiet since March 2026 (v0.100.1), so particle-academy/prism — a drop-in fork,
Prism\Prismnamespace unchanged — has been absorbing the open backlog and shipping releases (context: discussion #1027). This PR offers all of that work back upstream in one piece: 166 commits, nine releases (v0.101.0–v0.109.0), gated throughout by Pest + PHPStan + Pint/Rector.If maintainership resumes, merge wholesale or tell us how you'd like it split — we're happy to break it into reviewable chunks. Either way the fork remains active.
Community PRs from this repo merged into the fork (48)
v0.101.0 — correctness fixes (17): #952, #958, #964, #971, #977, #985, #986, #987, #989, #991, #996, #1001, #1004, #1009, #1012, #1013, #1024
v0.103.0 — provider correctness / API drift (16): #949, #954, #961, #965, #975, #976, #980, #992, #993, #995, #997, #1000, #1002, #1008, #1020, #1021
v0.104.0 — features (9): #951 (batches + files APIs), #960 (xAI images), #978 (Vertex AI provider, answers #795), #988 (fine-grained tool streaming), #998 (Anthropic adaptive thinking), #1003 (pause_turn/refusal), #1014 (Mistral FIM), #1018 (provider-agnostic withReasoning()), #1026 (Requesty provider)
v0.105.0 — features + providers (6): #757 (Replicate provider), #810 (async STT interface), #835 (Azure OpenAI provider), #898 (Qwen provider), #907 (OpenAI chat/completions api_format + streaming citations, answers #900), #920 (cost tracking in Usage)
Reimplemented rather than rebased: #932 (client-executed tools + human-in-the-loop approval, answers #921) — clean-room implementation across all providers including streaming; docs at https://ai.particle.academy/docs/core-concepts/human-in-the-loop
Adjudicated, not merged (rationale posted): #950 (duplicate of #977), #937 and #1005 (superseded by an escape-based control-character fix), #999 (superseded), #1025 (rejected — a composer-require-checker CI gate solves the underlying goal properly; analysis in Particle-Academy/prism#3)
Fork-original changes
laravel/framework ^12.61.1|^13.12.0).Tool::requiresApproval(bool|Closure)/Tool::clientExecuted(), deny-by-default resume from message history, streaming approval events — text/structured/stream on all 18 providers.promptTokens= non-cached input everywhere;cacheReadInputTokenspopulated wherever the provider exposes it. Fixed silent double counting in Gemini, Vertex, OpenRouter (v0.108.1) and Z.AI + Requesty streams (v0.109.0); added cache visibility for OpenAI chat/completions, Azure, Groq, Qwen, xAI.anthropic_betaprovider option.src/.Full release notes: https://github.com/Particle-Academy/prism/releases
🤖 Generated with Claude Code