Adapter Responsibilities
Every inference driver is built from two main translators, each of which may use additional formatters internally:Request Translation
The request adapter converts a PolyglotInferenceRequest into an HttpRequest. It is responsible for:
- Message formatting — mapping Polyglot’s typed
Messages(with roles, content parts, tool calls, and tool results) into the provider’s expected structure - Body formatting — assembling the full request body including model, tools, response format, and mode-specific adjustments
- HTTP request assembly — setting the URL, headers (including authentication), and body
Response Translation
The response adapter converts raw HTTP responses back into Polyglot data objects:How They Compose
For most providers the wiring is not code at all — it is a row in the bundled registry. AnInferenceDriverSpec names the pieces, and SpecifiedInferenceDriver is the single class
behind every provider declared this way:
requestAdapter field in their InferenceDriverSpec; they do not need a
provider driver class. The adapter owns that provider-specific behavior while
SpecifiedInferenceDriver supplies the shared execution lifecycle.
The BaseInferenceRequestDriver handles the shared execution logic — sending HTTP requests, reading responses, and parsing event streams. The adapters only need to handle format translation.
The Contracts
Request Side
TheCanTranslateInferenceRequest contract defines a single method:
CanMapRequestBody implementation:
CanMapMessages, which receives typed Messages and returns a provider-native array. Implementations compose a MessageMapper utility for typed iteration instead of duplicating the loop:
CanMapRequestBody (which itself wraps
a CanMapMessages) and produces the final HttpRequest.
Almost none of that is per-provider. BaseHttpRequestAdapter owns the skeleton — POST, the
body from the body format, the stream flag, the telemetry-correlation wrapper — and leaves two
abstract hooks, which are the only things providers actually disagree about:
OpenAIRequestAdapter is just the two hooks:
{apiUrl}{endpoint}. Only two of the bundled
adapters would use such a default; the rest assemble a URL from a region, a model name or a
fallback endpoint, and a silently plausible URL is a worse failure than a compile error.
AzureOpenAIRequestAdapter, CohereV2RequestAdapter, GeminiOAIRequestAdapter and
HuggingFaceRequestAdapter extend OpenAIRequestAdapter rather than the base directly — they
are OpenAI-compatible providers that differ only in headers, and are happy to inherit the URL.
Response Side
TheCanTranslateInferenceResponse contract handles both synchronous and streaming responses:
toEventBody() method extracts the payload from an SSE line (stripping the data: prefix, detecting [DONE] markers). The fromStreamDeltas() method parses a sequence of those payloads into PartialInferenceDelta objects carrying incremental content, tool call fragments, and usage snapshots.
Usage extraction is handled by CanMapUsage:
InferenceUsage object.
Streamed usage: pass null, do not build a zero
Usage arrives on only a handful of events per stream — roughly one chunk in 943 on a real one. A response adapter must therefore pass usage: null on every delta that carries no usage payload, rather than calling its usage format unconditionally:
StreamingUsageState::apply() already discards a zero-total InferenceUsage, so building one is pure waste — one constructor call and one allocate/free cycle per delta, ten thousand of them on a long stream.
The predicate is provider-specific. Do not copy another adapter’s. CanMapUsage::fromData() implementations read different keys, and a guard that checks the wrong one silently drops real token counts:
Express it as a
protected function hasUsageData(array $data): bool on the adapter, so the per-provider difference is visible and testable. OpenAIResponseAdapter defines the default (!empty($data['usage'])); subclasses that read usage from elsewhere override it.
Two tests enforce this contract, and a new adapter should be added to both:
tests/Unit/Drivers/StreamDeltaUsageGuardTest.php— assertsusage === nullon a quiet delta, non-null on a carrying one, and that the assembled totals are unchanged.tests/Benchmarks/StreamAdapterUsageAllocationTest.php— counts actualInferenceUsageconstructions over a 1,000-delta stream and fails if an adapter exceeds O(1).
Varying the response_format payload
Providers disagree sharply about how a response format reaches them: some take a json_schema
envelope with a name and a strict flag, some accept only json_object, some hang the schema off
json_object or off a bare value key, and some have no JSON-schema support at all and must
degrade to plain JSON.
A body format expresses that by overriding one of three methods on OpenAIBodyFormat. Each
receives the ResponseFormat the caller asked for and returns the payload to send:
Override only what differs. A provider with no schema support degrades by delegating:
toResponseFormat() to do this. That method decides whether a response
format is sent at all, while the three methods above decide what the payload looks like once
that question is settled. DeepSeek V4 uses the latter hook to degrade JSON Schema to JSON Output.
Provider variation used to be injected intoWhatever these return is pinned byResponseFormatitself throughwithToTextHandler()/withToJsonObjectHandler()/withToJsonSchemaHandler(). Those methods no longer exist:ResponseFormatis a plain four-field value object and carries no rendering behaviour. Every request used to allocate two closures and two copies of it to reach a payload the body format already had everything to build.
ResponseFormatFragmentGoldenTest, which snapshots the
emitted fragment for every body format in the family across every mode. A new provider must be
added to its table — the test fails if one is missing.
Embeddings Adapters
Embeddings drivers follow the same pattern with their own set of contracts:Adding a New Provider
To add support for a new provider, you typically need to create:- A message format class if the provider uses a non-OpenAI message structure
- A body format class to assemble requests with any provider-specific fields
- A request adapter to set the URL, headers, and authentication scheme
- A response adapter to parse responses and streaming events
- A usage format class if token usage is reported differently
- An
InferenceDriverSpecnaming those pieces — or, if the provider assembles its own URL or headers, a driver class extendingBaseInferenceRequestDriver
InferenceDriverSpec with OpenAICompatibleBodyFormat. The ollama, together, moonshot and openai-compatible names all share exactly that one spec in the bundled registry.