Carrying the torch: 48 community PRs merged + fork fixes (v0.101.0-v0.109.0) - #1028
Open
wishborn wants to merge 226 commits into
Open
Carrying the torch: 48 community PRs merged + fork fixes (v0.101.0-v0.109.0)#1028wishborn wants to merge 226 commits into
wishborn wants to merge 226 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
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.
Fixes #31. /v1/agent decodes its body STRICTLY -- an unknown field is a 400, not an ignored key -- and the payload allowlist still carried ten chat/completions-era options. Arr::whereNotNull only sends what a caller set, so each one was a latent failure that fired solely on the runs where somebody populated it. The reporter's model narrowed a search on a fraction of its runs, which is why this reached production rather than being caught on the first call. The reporter was right that search_domain_filter was just the first a model happened to use, and that removing it alone would leave every sibling armed. Checked against Perplexity's own Sonar-to-Agent migration guide rather than inferred, which turned up two more nobody had hit yet: reasoning_effort was also a 400, and language_preference -- which looks exactly like the others -- is genuinely accepted and had to stay. TRANSLATED, not dropped. Silently discarding a domain allowlist is the worse of the two failures: the request succeeds, the search is quietly broader than the caller asked for, and the answer cites sources they deliberately excluded. - the six web search filters move onto the web_search tool's "filters", declaring the tool if the caller had not. An explicit nested "filters" wins, on the same principle as "preset" over "model". - reasoning_effort maps to reasoning.effort. An explicit "reasoning" object wins. - search_mode, return_images and return_related_questions have no Agent API equivalent at all, so they now THROW and name the alternative -- the same shape as assertToolsAreReachable, and for its reason. A caller who asked for something and got a successful response without it is worse off than one who got an error. 12 tests, including the reporter's exact reproduction. Verified by mutation that four of them fail if the filters go back to being dropped.
…d for Two findings from the security review of #32, neither a vulnerability and both on a model-triggerable path -- the same path that made #31 a production incident rather than a config bug. `return_images: false` asks for exactly what the Agent API already does, so the caller's intent is met and the refusal was rejecting a request that is in effect correct. All three refused options are commonly declared to a model as tool parameters, so a model supplying the no-op value took down a run it did nothing wrong in. The two booleans now pass through when false. `search_mode` keeps refusing every value, false included. It NAMES a mode rather than toggling one, so it has no value meaning "do nothing" and its presence is the ask. The asymmetry is pinned by its own test so it cannot be tidied into consistency later. A falsy value that is not `false` -- 0, '' -- is still refused: these are declared booleans, `false` is the only no-op spelling valid for the type, and quietly accepting the others would be the silent-drop failure the refusal exists to avoid. A non-array `filters` on a caller-declared tool reached `array_merge` and surfaced as a raw TypeError, which is an unhandled 500 in the calling app rather than a provider option it can catch. It now throws a PrismException naming the option, because `tools` can be model-supplied too. Six new tests; suite 2033 passed, 11 skipped. PHPStan and Pint clean.
ChangeOrIfContinueToMultiContinueRector, and it reads better anyway -- each continue now carries its own reason: never set at all, versus set to the value that asks for what the Agent API already does.
Fixes #31, a live production breakage at a downstream consumer. Ten chat/completions-era options were still in the Perplexity payload allowlist against an endpoint that strict-decodes, so each was a latent 400 that fired only on runs where a caller populated it. Six web-search filters are TRANSLATED onto the web_search tool rather than dropped, one maps to reasoning.effort, three with no equivalent throw, and two the API genuinely accepts are left alone. Verified against a live key, not only against fixtures. Reviewed under /pr-security-review: PASS WITH WARNINGS, no blocker. Findings 2 and 3 were fixed on the branch before merge -- the refusals no longer fire on a boolean's no-op value, and a malformed filters value names the option instead of escaping as a TypeError. Two release-note items are recorded on the PR and must survive into the release: the three options that now throw are reachable by a MODEL and not only by config, and the six filters that now work make search_domain_filter a source-steering control wherever it is model-callable.
There was no release path here. Tagging was manual and unverified, which is how this ecosystem ended up with packages tagged but absent from Packagist, and six packages carrying work behind their newest tag. No upload step and no token, and that is not an omission: Composer resolves versions from git tags and Packagist mirrors them over a webhook, so pushing the tag IS the publish. What this adds is the part that actually went wrong. Four refusals, each for a failure that has really happened: - tests.yml must have SUCCEEDED for that exact SHA -- succeeded, not merely "nothing failed". A package whose tests never run reports nothing failed. prism-opentelemetry shipped v0.1.1 in precisely that state: 32 tests on disk, no workflow invoking them, CI green. - No other gate may have concluded failure on that SHA. - composer.json must not declare a version, which would reintroduce the tag-versus-declared disagreement Composer avoids by deriving from the tag. - Packagist must actually serve the version. The GitHub release can succeed and this still fails, deliberately -- a tag is not a release and a release is not a distribution. prism-human-plus was tagged, released, and uninstallable, and nothing anywhere noticed. Every guard was exercised in both directions locally before rollout, not just on the passing case: the version-key check refuses a planted key and allows the real file, and the Packagist check finds a published version and correctly reports an unpublished one as missing.
…hey are
PHP has one array type, so a map-typed field serialised as `[]` when empty and
`{}` when populated — the same field changing JSON type with its contents. That
is a 400 from a provider validating a schema, and it is a distinction no
PHP-authored conformance golden could even state, which is why prism-parity's
trs-0006 and rtp-0009 were skipped for both ports.
It took TWO mechanisms, and the difference between them is the whole point.
`Support\JsonMap` wraps the fields Prism DECLARES to be maps. A UserMessage
constructed with no additionalAttributes was never decoded from anything, so
there is no input to consult and the declaration is the only evidence there is.
`Support\Json::decode(..., preservingContainerTypes: true)` is for everything
else. `json_decode($raw, true)` is where the information is lost, not the encode
that follows, and repairing it afterwards means guessing which keys were maps —
which works for the keys you thought of and fails for arbitrary JSON nested
arbitrarily deep. A model sending `{"filter":{}}` got `{"filter":[]}` back on
the next turn. The distinction is now CARRIED from the input: populated objects
fold back to arrays, empty ones stay objects, and `"required": []` stays a list,
because promoting every empty array would trade one silent divergence for a
commoner one.
Removed 15 duplicated per-provider guards — eight `?: (object) []` on tool call
arguments, seven `=== [] ? new \stdClass` on tool schema properties — and with
them the reason they existed. FOUR send sites never had a guard at all and were
shipping `[]`: Azure, OpenAI ChatCompletions and Qwen on arguments, OpenAI
ChatCompletions on properties. Every message map now goes through
ToolCall::argumentsAsObject()/argumentsAsJson()/hasArguments(), and every tool
map through Tool::parametersAsObject(); tests/Providers/ContainerTypeTest.php
discovers the maps from the filesystem, so a sixteenth provider is covered
without anyone remembering to add it.
BEHAVIOUR CHANGE. toArray() now yields stdClass for map-typed values —
additional_attributes, additional_content, structured, args, data, metadata,
arguments, categories — so anything reading `$array['additional_content']['k']`
breaks, though anything json_encoding it gets more correct bytes. `raw` is
deliberately excluded: it is an opaque echo of the provider's own body and its
JSON type is the provider's to choose. ToolCall::arguments() is deliberately
unchanged, so a tool handler typed `array $filter` keeps receiving `[]`.
Two consumer-side effects worth naming: an Anthropic provider tool call whose
input was `"{}"` used to be dropped by Payload::compact before it reached the
wire and now survives, and an MCP property declared `{}` — meaning "any value" —
is now offered to the model as `{}` rather than `[]`.
…ribes `processRateLimits()` runs on the SUCCESS path, after the model has answered and the call has been billed. It built the reset instant with `new Carbon($value)`, which raises InvalidFormatException on anything it cannot parse -- so an unreadable rate-limit header threw straight out through asText(), and a quota HINT destroyed the response it was attached to. The header is not necessarily the provider's. Whatever proxy or gateway sits in front of the API can set it, so a 200 does not make the value trusted input. Nor is the triggering value contrived. `1m30s` is the compound duration OpenAI really sends, and nothing stops it appearing on an Anthropic response; Carbon throws on it exactly as it does on 'soon'. Anthropic was the only provider exposed. OpenAI returns null when its duration regex does not match and Gemini guards every parse, so failing to null here makes this consistent with its siblings rather than inventing a policy. A missing reset is a far smaller loss than a lost response, and it is the same value a caller gets from a provider that sends no reset header at all -- the bucket's limit and remaining still arrive. Found by the conformance suite added for G-15, which is the argument for that suite arriving on day one: no per-language test could see it, because each asserts against headers it wrote itself and nobody writes 'soon'. The test fails with InvalidFormatException against the unguarded code, verified by reverting the guard and re-running rather than by assuming.
HTTP field names are case-insensitive (RFC 9110 5.1). Three of this package's rate-limit readers -- Anthropic, OpenAI and Groq -- matched their prefix against the raw `getHeaders()` keys, which carry whatever case the wire used. A gateway that title-cases headers is ordinary rather than hostile, and against `Anthropic-RateLimit-Requests-Limit` each of the three reported NO RATE LIMITS AT ALL. That is the worst shape a failure can take here: an empty list is also what a response that legitimately carried no quota headers looks like, so the failure is invisible at the moment it happens and permanent afterwards. A caller that watches quota to decide whether to send the next request simply stops seeing any, and nothing anywhere says so. Found by prism-parity's provider-rate-limits corpus (prl-0008), which runs one fixture through PHP, TypeScript and Python. Neither reference could see it alone: each language's own tests feed its parser the case its parser expects. prism-py was the only one of the three that already compared case-insensitively and was deliberately alone on that row; this brings the reference to it, and prism-ts moved in the same pass. Mistral's reader was already immune -- it goes through `Response::header()`, which is PSR-7 and case-insensitive -- so the fix is the other three. It lands as `Support\HeaderNames` rather than three inline `strtolower()` calls because the defect is the CAPABILITY, not the instance: only Anthropic's is in the corpus, and fixing only that one would have left two readers with the identical silent failure and no test that could tell. THE FOLD IS ASCII-ONLY, AND THAT IS THE LOAD-BEARING HALF. `mb_strtolower()` is Unicode-aware, and two codepoints matter. U+212A KELVIN SIGN folds to a plain `k`, so `anthropic-ratelimit-toKens-limit` would come back as a bucket NAMED `tokens` -- the name a caller matches on to decide whether it has token quota left, manufactured out of a header the provider never sent. U+0130 folds to two codepoints, changing the length the bucket/field split is computed over, and giving three languages three different bucket names for one header. A field name is an RFC 9110 `token` and ASCII by grammar, so the fold is `strtr()` over the 26 ASCII letters -- locale-independent on every PHP version, and spelled the same way in prism-ts and prism-py so the three produce identical bytes. `strtolower()` would have done for PHP 8.2+, and is locale-dependent before it. `strtr()` never was, and it is the one form that reads the same in all three languages. The lookalike case has no corpus row and cannot get one: Guzzle rejects a field name outside the `token` grammar before any reader sees it, so PHP would record the harness refusing rather than the reader answering. It is pinned per language instead, here in tests/Support/HeaderNamesTest.php.
The Formatting job has been red since the F-3 commit, and the code was never the problem. `laravel/pint` was constrained `^1.14` with no committed lock, so CI resolved v1.30.4 while this machine had v1.30.5 -- and 1.30.5 changed the rules in question. The result: `pint --test` passed locally and failed on CI, on the same bytes. That is the worst shape a gate can have. It is not reproducible, so it cannot be fixed by the person who broke it; the only way to see the failure is to push and read a log, and the only way to "fix" it is to hand-apply a diff that your own formatter will then undo. Floor raised to ^1.30.5 so CI resolves what a developer resolves. No code change was needed once the versions agreed: the full tree passes `pint --test` under 1.30.5 exactly as committed. Also moves `decodeArguments()` below the public methods while here. 1.30.4 enforced that ordering and 1.30.5 does not, so it was invisible locally -- but public-before-protected is what the class already does everywhere else, and the method had been inserted into the middle of the public block. Tests 2121 passed, types clean.
My previous commit raised laravel/pint to ^1.30.5 to stop CI and local disagreeing. That made it worse: pint 1.30.5 requires PHP ^8.3, this package supports ^8.2, and the CI jobs run 8.2 -- so composer could not resolve at all and Tests, PHPStan, Require Checker and Formatting ALL went red where only Formatting had been. Factcheck stayed green because it is the one job that installs no PHP dependencies, which is exactly the tell. I did not see it locally because this machine runs PHP 8.4, where 1.30.5 installs happily. A constraint that resolves differently per developer is the whole defect being fixed here, and I reproduced it in the fix. Pinned EXACTLY to 1.30.4 -- the version CI was already resolving, and the newest that installs on the 8.2 floor this package promises. An exact pin rather than a caret: a formatter is not a dependency you want floating, because its output is compared byte-for-byte by a gate. No code changes were needed. Under 1.30.4 the tree passes `pint --test` as committed, the ordering fix from the previous commit having been the only real item.
These packages ship no CHANGELOG file, so the ANNOTATED TAG is the changelog entry. `gh release create --generate-notes` silently discarded it and published a list of commit subjects plus a compare link instead -- not a shorter version of the notes, but different content written by nobody. It already cost us. prism-harness v0.3.0 was tagged with 1974 characters explaining a security fix that invalidates every existing MCP pin, and the release published 94 characters: a bare "Full Changelog" link. Anyone opening that release to find out whether to upgrade learned nothing. That release body has been restored from its tag. `--notes-from-tag` now, in all nine PHP packages, with the reason next to the line so it does not get "simplified" back. One consequence, stated in both the workflow and RELEASING.md rather than left to be discovered: this REQUIRES an annotated tag. `git tag v1.2.3` with no message fails the step. That is deliberate -- a release with nothing to say about itself should not be quietly publishable.
…response Quota headroom rode on `prism.telemetry.capture_content`, which is off by default, because the response's Meta was the only place it lived and `Telemetry::completed()` passes `capturesContent() ? $response : null`. So a SUCCESSFUL generation emitted no rate limits at all under the shipped config. The 429 path was unaffected — PrismRateLimitedException carries them itself — which made it worse rather than better: the numbers turned up exactly when a caller could no longer act on them, and never while there was headroom. `GenerationCompleted` and `StepCompleted` now take `ProviderRateLimit[] $rateLimits`, populated unconditionally, exactly as `?Usage $usage` already was and for the same reason. A token count and a quota bucket are numbers the provider reported about the call; `$response`/`$step` carry the completion. The content gate is untouched, and the split is commented in all three places so nobody folds them back under it. `GenerationStarted` deliberately does not get one: rate limits are read off response headers, so at start there is nothing to pass, and a parameter that can only ever be empty is a signature with no behaviour behind it. `rateLimitsOf()` filters to real `ProviderRateLimit` instances. `Meta::$rateLimits` is typed by docblock alone and is assembled by eighteen provider readers from raw headers, so the filter is what makes the events' own `ProviderRateLimit[]` a true statement rather than a second docblock. PHPStan calls the instanceof always-true from the docblock; the ignore says why the runtime disagrees. Not fixed here, and now visible: streaming exports no rate limits either, but for a different reason — `instrumentStream()` hands `completed()` a StreamContent, and StreamEndEvent/StepFinishEvent carry no Meta at all, so there is nothing to read even with capture on. Closes G-45.
Pint's use_arrow_functions rewrites a single-return closure into an arrow function. CI applies it to these five maps; this machine does not -- with the SAME pinned Pint 1.30.4, because the CI job formats on PHP 8.2 and only 8.4 is installed here, and PHP-CS-Fixer targets the interpreter it runs under. So the gate was unreproducible for a second reason after the version was pinned, and this machine cannot see it at all. Applied by hand from CI's own diff, then checked for the failure mode that would make hand-applying useless: local Pint does NOT revert the arrow form, so this settles rather than ping-pongs between two developers on different PHP versions. Verified after the change: pint --test clean, 2121 tests, types clean. The deeper problem is not fixed and should be. A formatting gate that depends on the interpreter is one nobody can reproduce unless they happen to run the same PHP as CI, and formatting -- unlike static analysis, which SHOULD run on the declared floor -- has no reason to care. Either the job should run the PHP a maintainer runs, or the requirement should be stated where somebody hits it.
…titutes
`--generate-notes` was replaced with `--notes-from-tag` an hour ago to stop the
tag's own message being discarded. It did not work, and it failed in the worst
possible way: prism v0.116.0 was tagged with 2491 characters describing a WIRE
FORMAT CHANGE, and the release published 1189 characters of the last commit
message instead.
Not an error, not a warning -- something plausible. Anyone reading that release
to find out whether `{}` now goes out where `[]` used to would have found a note
about the formatting gate.
In a GitHub Actions checkout `gh` does not resolve the tag annotation, and says
nothing when it cannot. So the annotation is now read directly with
`git tag -l --format='%(contents)'`, which is why these workflows check out at
fetch-depth 0, and passed as `--notes-file`. An empty result FAILS the step
rather than publishing an empty release.
Both affected releases have had their bodies restored from their tags:
prism-harness v0.3.0 and prism v0.116.0.
The general shape is one this ecosystem keeps meeting: a mechanism that reports
success while doing something else. The fix is the same every time -- read the
thing you actually want, and fail when it is not there.
…ntly
Third attempt, and the first two are worth recording because both produced
something plausible rather than an error -- which is why each was believed until
somebody opened the release page.
--generate-notes discarded the annotation and published a list of
commit subjects. prism-harness v0.3.0 went out with 94
characters where 1974 were written, describing a
security fix that invalidates existing pins.
--notes-from-tag published the last COMMIT message instead. gh does not
resolve the annotation in an Actions checkout and does
not say so.
git tag -l %(contents) published the last commit message too, and this is the
real reason: actions/checkout creates a LIGHTWEIGHT
local tag, and %(contents) on a lightweight tag falls
back to the commit it points at.
The last one is the instructive failure. It was the fix for the second, written
with a guard for an empty result -- and the result was never empty, it was just
the wrong text. A guard that checks for absence cannot catch a substitution.
The API holds the real annotation object, and asking it directly also lets the
lightweight case be DETECTED rather than degraded: `.object.type` is "tag" for an
annotated tag and "commit" otherwise, so a `git tag v1.2.3` with no message now
fails the release loudly instead of publishing a commit subject as its notes.
Verified against a real tag before shipping this time, rather than after: the
API returns the full 2328-character annotation for prism-mcp v0.2.0, which is
exactly what the workflow was publishing 1274 characters of commit message
instead of. Both affected release bodies have been restored.
Reported by the Moic Suite team, measured against the live API on claude-opus-5 rather than inferred: Anthropic puts reasoning at `usage.output_tokens_details.thinking_tokens`, and every Anthropic handler here built Usage without it. So `$usage->thoughtTokens` was null on every Anthropic call. That matters more than a missing field usually would, because adaptive thinking makes the model decide WHETHER to reason per request. With the field unset, "reasoning is off", "the model judged this easy" and "it reasoned hard" are indistinguishable -- and the last one is the expensive case. Wired at all five construction sites, not just the one the issue named: Text, Structured, the two streaming paths (message_start carries the first usage block, message_delta the final output count) and batch results. Streaming carries the earlier value forward on the delta rather than dropping it, for the same reason it already carries prompt and cache counts. Anthropic was the only provider missing this. OpenAI, Gemini, OpenRouter, DeepSeek, Vertex and Requesty all set it. Also documents the thing the issue asked for, on the property itself: this is a BREAKDOWN of completionTokens, not an addition to it -- 1240 thinking tokens inside 2820 output, not beside them. A consumer pricing `completion + thought` double-counts the expensive half. Stated there because the field is null on providers that do not report it, and a null reads as "no thinking" rather than "not measured". Two tests, and the second is the control: one asserts 1240 arrives and is less than the 2820 output, the other that a response with no thinking leaves it null. Without the control the first passes on an implementation that hardcodes the number. Verified failing against the unfixed handler before being kept.
Rector enforces that named arguments appear in the order the constructor declares them, and the thinking-tokens commit inserted thoughtTokens ahead of cacheReadInputTokens. Behaviour is identical -- that is the point of naming them -- so nothing here changes what the code does, only what it reads like next to the other four Anthropic mappings. Worth noting which gate caught it: the Formatting job runs pint AND rector, and this was rector. Pint had nothing to say about the order.
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