Skip to content

Carrying the torch: 48 community PRs merged + fork fixes (v0.101.0-v0.109.0) - #1028

Open
wishborn wants to merge 226 commits into
prism-php:mainfrom
Particle-Academy:main
Open

Carrying the torch: 48 community PRs merged + fork fixes (v0.101.0-v0.109.0)#1028
wishborn wants to merge 226 commits into
prism-php:mainfrom
Particle-Academy:main

Conversation

@wishborn

@wishborn wishborn commented Jul 7, 2026

Copy link
Copy Markdown

Upstream has been quiet since March 2026 (v0.100.1), so particle-academy/prism — a drop-in fork, Prism\Prism namespace 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

Full release notes: https://github.com/Particle-Academy/prism/releases

🤖 Generated with Claude Code

cemarta7 and others added 30 commits November 24, 2025 22:18
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.
…h job management, result handling, and error mapping
… 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
wishborn and others added 30 commits August 23, 2026 12:34
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.