The unified request and stream
- In one line
- The unified types are the narrowest shape every backend can agree on, and every remaining difference lives in the provider adapter
- You need
- Nothing running
- Read this if
- You are wondering why the request has the fields it has, or where a provider quirk belongs
The seven HTTP backends use four wire shapes. OpenAI, vLLM, LM Studio, DeepSeek's native route and llama.cpp's chat route post /chat/completions. Anthropic and DeepSeek's bridge route post /messages. Gemini posts streamGenerateContent. llama.cpp's native route posts /completion with raw token frames. The eighth provider runs a model in process and has no wire.
Switching between two of them would otherwise mean rewriting the request body, the auth header, the frame reader and the tool-call round trip. UnifiedRequest and MessageContentBlock make that switch a feature flag and a base URL.
One turn
Stages 3 and 5 are the only places vendor JSON exists. Stage 4 is protocol work with no vendor knowledge, and stages 2 and 6 see normalized types only.
generate_stream overwrites request.provider with the client's selected provider before any hook runs. Setting the field on a UnifiedRequest therefore cannot mis-route it, and a plugin reading req.provider in on_request always sees the real endpoint. UnifiedRequest::new starts with ProviderEndpoint::Custom("") for that reason.
What the request carries
UnifiedRequest has nine fields: model, provider, messages, temperature, max_tokens, stream, thinking, tools and prompt_cache. Three are Option: temperature, max_tokens and thinking. An unset Option means the adapter omits the key instead of inventing a default, so an OpenAI-compatible body has no temperature or max_tokens unless the caller set them.
Anthropic is the exception because its API requires max_tokens. That adapter sends 1024 when the field is unset, since the alternative is refusing a request the caller considers complete.
The request has no field for a vendor-specific knob, and the type is built around that constraint. A HashMap<String, Value> of pass-through options would make UnifiedRequest easy to extend and would end its portability the first time anyone used it. The one vendor override is ThinkingParams, a closed enum with variants for Anthropic, Gemini and OpenAI-compatible reasoning, plus None.
Thinking: one level, several translations
ThinkingEffort has five levels. Each provider gets its closest native control, and where a provider has fewer gradations the levels collapse.
| Effort | OpenAI-compatible reasoning_effort | Anthropic budget_tokens | Anthropic adaptive effort | Gemini thinkingLevel |
|---|---|---|---|---|
Minimal | "minimal" | 1024 | "low" | "LOW" |
Low | "low" | 2048 | "low" | "LOW" |
Medium | "medium" | 8192 | "medium" | "MEDIUM" |
High | "high" | 16384 | "high" | "HIGH" |
XHigh | "high" | 16384 | "xhigh" | "HIGH" |
- DeepSeek's native route has no effort knob. It takes a mode object,
{"type":"enabled"}or{"type":"disabled"}, and drops the effort level. - llama.cpp's chat route uses the OpenAI-compatible column. Its native
/completionroute has no reasoning control and ignoresthinking. ThinkingParamscarries a native value for a caller who needs it, and a set override wins over the unified effort. Using it costs portability.
Five block types
MessageContentBlock has Text, ImageBase64, Thinking, ToolCall and ToolResult. Every adapter's translator emits only these, so code written against one backend behaves the same against the others.
The serialization is adjacently tagged with #[serde(tag = "type", content = "value")]. Internal tagging cannot represent a newtype variant, and Text(String) is one. Session logging serializes Text blocks, so every variant has to round-trip.
The Custom provider
ProviderEndpoint has nine variants. Eight have adapters: LlamaCpp, Vllm, LmStudio, OpenAi, Anthropic, GoogleGemini, DeepSeek and Local. The ninth is Custom(String), and dispatching it always fails.
configuration error: custom endpoints require a registered adapter
The crate does not assume an unknown gateway is OpenAI-compatible. A wrong guess sends a malformed request to a real endpoint and returns a confusing error from the far side. A caller who knows the gateway's protocol selects that protocol's feature and overrides the base URL, as in Point the client at another OpenAI-compatible server.
Where a provider quirk goes
Normalized types know nothing about vendors, and vendors are known only inside src/provider/. A quirk is therefore an adapter change, never a new field on UnifiedRequest.
- Gemini's
functionResponsenames the function, not the call id, so aToolResultis matched byUnifiedMessage::namewhen set, with the call id as a fallback. - DeepSeek's bridge route rewrites
claude-opustodeepseek-v4-pro,claude-sonnetandclaude-haikutodeepseek-v4-flash, and forces prompt caching off. - llama.cpp's native route builds one prompt string with
### User:and### Assistant:markers, because that route has no message array.
None of this is visible in the unified types. Each provider's page under Providers states its own.
Next page: The SSE parser.