LLM Adapters and Conversation Protocol Translation
How internal messages project into DeepSeek, Pi AI, and replay adapters
Conclusion: an adapter translates one call; the Session Log owns the conversation
DeepSeek Harness never lets a provider SDK become the owner of conversation state. It first constructs an immutable, sourced Message vocabulary and a closed StreamChunk protocol. An adapter only projects one complete request onto a wire protocol and translates the response back. Persistence, retries, replay, and tool continuation all remain outside the adapter.
The boundary does more than enable multiple models. It confines provider differences to four surfaces—capability resolution, wire serialization, stream translation, and error classification—while the Agent Loop applies the same Turn/Step, request-header, and SessionEvent semantics to every dispatch.
1. One model call crosses six commit boundaries
Derive history from the log
The Loop reads the current request header and Session.deriveMessages(); it never asks an SDK for conversation history.
Prepare the exact call
prepareCall() resolves one route, exact-model capabilities, adapter defaults, context capacity, and retry policy.
Commit request facts
The canonical header and request context enter the Session before the final request is deep-frozen.
Cross a durability barrier
llm/stream middleware flushes the complete request prefix before constructing the downstream adapter stream.
Record the raw stream
Every chunk is appended as assistant/chunk before it enters the canonical assembler.
Commit the semantic message
Only a successful finish produces one assistant/message; request-error policy owns failures.
The request, raw chunks, and final message inside one Step all carry the same Turn and Step. Providers never receive the Session object and never decide when another Step begins.LOOP-STEP-STATELOOP-REQUEST-BUILD
Model I/O is an explicit semantic side-effect boundary. If request facts cannot be made durable, the checkpoint listener never invokes the adapter.LLM-CHECKPOINT-BARRIER
2. The internal protocol is deliberately narrower than any one provider
| Layer | Core representation | Critical constraint | Why it matters |
|---|---|---|---|
| Message | {id, role, content, source} | Cloned and deep-frozen at creation; stable identity crosses delivery, log, and request | There is no second SDK-owned history authority LLM-MESSAGE-VALUE |
| Content | text / reasoning / image / tool-call / tool-result | Tool arguments retain the raw JSON string produced by the model | Parsing belongs at the tool boundary; the adapter does not rewrite intent LLM-CONTENT-PROTOCOL |
| Provenance | user / plugin / model / tool | Assistant messages name provider and model and may carry replay state | Cross-provider history cannot impersonate a native response LLM-MESSAGE-IDENTITY |
| Stream | block-start / delta / block-end / usage / finish | Indexes permit interleaving; usage precedes terminal finish; nothing follows finish | UI, log, assembler, and adapters share one stream contract LLM-STREAM-PROTOCOL |
| Request | config + system + tools + messages | Provider and model route exactly; sessionId and purpose are model-hidden metadata | Conversation semantics stay separate from transport metadata |
The vocabulary is declaration-merge extensible, but built-in adapters do not automatically behave symmetrically for an unknown block. A new block type needs adapter, UI, compaction, and replay support; extending a TypeScript union alone does not create an end-to-end capability.
3. A provider route is a unique mapping, not a fallback list
One provider string maps to at most one adapter registration. Empty routes, duplicates, or a metadata ID mismatch fail synchronously, and a multi-route registration is all-or-nothing. Effect teardown removes exactly the routes still owned by that registration, while replace() swaps old and new route sets in one synchronous section.LLM-ADAPTER-REGISTRY
prepareCall() captures the registration, resolved config, retry policy, model context, and adapter-default markers. Its frozen handle can dispatch once and rejects caller-side config mutation as INVALID_PREPARED_CALL.LLM-PREPARED-CALL
When a route is replaced after preparation, the old prepared call remains served by the old adapter and old retry policy. A fresh registry lookup sees the new registration.LLM-PREPARED-PIN-TEST
4. BlockAssembler is the sole semantic fold for successful responses
The assembler retains insertion order by block index, tolerates adapters with delta-only protocols, and allows reasoning, text, and multiple tool calls to interleave. The first block-end becomes authoritative; later deltas or another close for that index are ignored.LLM-BLOCK-ASSEMBLER
- Without an explicit finish, the accessor defaults to
stop; production adapters should enforce their own terminal protocol. - Open text, reasoning, and tool-call blocks can be assembled from deltas; an unknown open block type fails.
- On
max-tokens, every tool-call block is filtered so truncated JSON arguments cannot execute. - The last usage chunk wins; replay state comes from the terminal finish.
- Raw chunks have already entered the Session Log, so the semantic message is not the only observable layer.
This is a consequential “one folding algorithm” choice: adapters emit facts instead of implementing their own message concatenation. The cost is deliberate tolerance for some malformed-stream branches; the assembler preserves agreement with an already closed and displayed block instead of failing on every protocol violation.
5. The direct DeepSeek adapter: narrow protocol, broad model passthrough
| Surface | Actual behavior | Boundary or consequence |
|---|---|---|
| Route | Fixed deepseek-official | The advisory catalog starts with V4 Flash and Pro, but unknown model IDs pass through |
| Capability | Default 1,000,000 context, 256,000 output, text only | The catalog is discovery metadata, not an allowlist LLM-DEEPSEEK-MODELS |
| Thinking | off/high/max; normally defaults high; a disabled deployment offers only off | session-title always disables thinking |
| Configuration | Endpoint, credential, and last-good settings resolve for each request | A bad live settings snapshot never replaces the last serviceable one LLM-DEEPSEEK-CONFIG |
| Transport | Native fetch POST to /chat/completions plus SSE | Sends attribution, anonymous user ID, session ID, and a compaction-specific header LLM-DEEPSEEK-TRANSPORT |
System becomes the first wire message, user text is joined, and internal user-role tool results expand into standalone role: tool messages. Assistant text and tool calls preserve order, but reasoning_content is replayed only on tool-call turns. Plain-turn reasoning remains in the durable internal message but does not enter later DeepSeek wire history. Images are rejected before any text-flattening path.LLM-DEEPSEEK-SERIALIZE
The translator waits for the literal [DONE] before closing blocks and emitting final usage and finish. Malformed JSON becomes MALFORMED_RESPONSE, missing DONE becomes STREAM_CLOSED, and a normal stop with no content becomes EMPTY_RESPONSE. Because DeepSeek prompt totals include cache hits, the adapter subtracts hits to form disjoint input buckets.LLM-DEEPSEEK-TRANSLATE
6. The Pi AI adapter: broad provider surface, strict exact-model surface
For an installed catalog route without a wire-API override, the Harness reuses the original pi-ai Provider and retains its native API implementation and ambient authentication. A manual route or protocol override permits only openai-completions, openai-responses, and anthropic-messages. Bedrock, Vertex, Azure, and OAuth-only paths are not advertised when the current configuration shape cannot express their requirements.LLM-PI-PROVIDER
The plugin starts with no routes. Each configured profile owns model catalogs and overrides, endpoint, credential reference, reasoning mappings, thinking budgets, cache retention, SSE/WebSocket transport, timeouts, headers, and retry policy.LLM-PI-CONFIG
| Call behavior | Pi AI path | Difference from direct DeepSeek |
|---|---|---|
| Model resolution | Must exist in the current profile; unknown models fail before I/O | Unlisted DeepSeek IDs pass through |
| Stop sequences | Explicitly unsupported | DeepSeek writes stop to the wire |
| Images | Require an image-capable model and the durable attachment service | The DeepSeek path is always text-only |
| Retries | The SDK receives maxRetries: 0 | Both paths leave visible attempts to Agent recovery |
| Reasoning | Enumerated per exact model; invalid effort is rejected rather than clamped | DeepSeek uses fixed off/high/max levels |
| Error detail | After the upstream library flattens Error/cause, classification uses message regexes | The direct path retains HTTP status, Retry-After, and request ID |
Before the first await, the Pi adapter captures the profile, model collection, and descriptor, then resolves credentials. A configuration update during the active request cannot alter that snapshot; the next call sees the new one.LLM-PI-STREAM
7. “Replay” names two adapter-level mechanisms that must stay separate
Harness Message content remains authoritative for Pi AI text, reasoning, and tool calls. PiAiReplayState v1 persists only API, source provider/model, response identity, stop reason, and per-block signatures. Restoration validates kind and version, source identity, block count, block types, and metadata.LLM-PI-REPLAY
LlmRuntime retains this private state only when the historical provider and target provider are currently owned by the same adapter instance. Otherwise it strips the state and reconstructs an ordinary “foreign assistant.” One adapter never interprets another adapter’s private protocol data.
dsh-llm-replay is a test and snapshot adapter, not production session recovery. It derives one provider call at each assistant/chunk finish, so retries sharing a Turn and Step become multiple script entries. A thrown stream or a hang cannot be recovered from the successful event log alone and requires an explicit sidecar override.LLM-REPLAY-DERIVATION
Session replay reconstructs the next request; adapter replay state restores provider-native metadata for a historical response; test replay reproduces a recorded chunk stream. They solve different problems.
8. A retry is another provider attempt inside the same Step
The while (true) in step() encloses request construction, streaming, and the request-error waterfall. After a failure yields {kind:'retry'}, it directly continues without closing the Step or Turn. The next attempt derives messages and rebuilds the request again while reusing the Step’s prompt assembly.PROMPT-RETRY-BOUNDARYLLM-RETRY-SAME-STEP-TEST
Normal mode defaults to at most two retries for EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, and TRANSPORT. Local delay grows exponentially from 500ms to 10s with 10 percent symmetric jitter. Always mode retries every request failure without a count limit until success, cancellation, or plugin disposal.LLM-RETRY-POLICY
The plugin appends llm/retry with policy key, failure, retry number, and delay before a cancellable wait. It appends llm/retry-started only after the wait and immediately before returning the retry action. A provider Retry-After value is accepted directly only when it does not exceed the cap.LLM-RETRY-EXECUTOR
The real mock-HTTP tests require byte-equivalent request bodies for failed and successful attempts. Failed partial chunks remain in the raw log but outside the surface, and only one assistant message is committed. A clean EOF is STREAM_CLOSED, which the default policy does not retry. Dependencies are not installed here, so this study reviewed but did not run the tests.LLM-RETRY-WIRE-TEST
9. The upstream retry documentation has drifted from the implementation
The llm-retry README says each retry opens a newly numbered Turn, that the Loop closes the failed Turn before a retry Turn, and that Agent Turns are the only retry boundary. Current production source and two test suites prove that attempts share the same Turn and Step.LLM-RETRY-DOC-DRIFTLOOP-REQUEST-ERROR-TEST
This is not a cosmetic wording issue. It changes how events are interpreted, grouped in a UI, attributed for cost, segmented into replay scripts, and folded by any projection that treats (turn, step) as request identity. Analysis and integrations must follow the current Loop and tests rather than copying the README’s “new Turn” narrative.
10. Error normalization encloses only adapter-owned failures
Provider selection, adapter stream() invocation, iterator construction, and next() failures all become one terminal finish {error|aborted, failure}. The serializable failure snapshot can retain a Harness-owned code, HTTP status, provider delay, and request ID.LLM-FAILURE-BOUNDARY
- A failure thrown by
llm/streammiddleware remains thrown instead of impersonating a provider failure. - A downstream consumer failure after a yield remains thrown.
- An adapter-iterator cleanup failure after early consumer close remains thrown.
- An adapter may throw an arbitrary non-Error value; normalization produces
UNKNOWNwithout letting hostile coercion escape. - An already-aborted signal or an ABORTED failure code produces an aborted terminal reason.
The distinction preserves responsibility. Provider and transport failures can enter common retry policy; plugin, consumer, and cleanup bugs cannot be silently swallowed by automatic retry. Pi AI’s text-based classification after upstream Error flattening is a capability degradation, not a requirement of the core protocol.
11. Tokens, caching, and one high-confidence accounting gap
| Source | Mapping | Fidelity boundary |
|---|---|---|
| DeepSeek | input = prompt_tokens - cache_hit; output passes through; reasoning may be present | No cache-write field; reasoning is an output subdimension and is not added twice |
| Pi AI | Maps input, output, cacheRead, and cacheWrite; zero cache buckets are omitted | pi-ai already folds reasoning into output and does not report it separately |
| Harness projection | Accumulates uncached input, output, cache read, and cache write | The latest sample for the same Turn and Step replaces the prior sample |
A usage chunk arrives before the final message, and a successful message carries the same usage again. The projection therefore uses (turn, step) to identify early and final samples for “one call,” subtracting the former before adding the latter to avoid double counting.LLM-TOKEN-USAGE-PROJECTION
Retries make multiple provider attempts share one (turn, step), while the replay test explicitly recognizes those attempts as separate calls. If a failed attempt emitted usage, the successful attempt’s usage replaces it rather than accumulating it, so the cumulative projection understates actual provider consumption.LLM-MULTI-ATTEMPT-REPLAY-TESTLLM-RETRY-SAME-STEP-TEST
12. A prepared registration does not pin the adapter’s dynamic configuration generation
prepareCall() pins the adapter registration and resolved model config. A checkpoint may then asynchronously flush. When iteration truly starts, the DeepSeek adapter calls options() again and the Pi adapter calls current() again. If settings update between those moments, logged model defaults or context may come from generation A while endpoint, profile, or credential comes from generation B.LLM-PREPARED-CALLLLM-CHECKPOINT-BARRIERLLM-DEEPSEEK-TRANSPORTLLM-PI-STREAM
The existing Pi snapshot test covers an update after the adapter stream has started, during credential resolution. It proves stability after that capture, but it does not cover the interval between preparation and first iteration. The Loop reconstruction invariant also compares model, system, temperature, maxTokens, stop, tools, and messages—but not provider or reasoningEffort.LLM-REQUEST-INVARIANT-SCOPE
13. Capability matrix for the two production adapters
| Dimension | Direct DeepSeek | Pi AI | Architectural meaning |
|---|---|---|---|
| Provider breadth | One official route | Multiple profiles and catalog providers | Narrow and faithful versus broad and constrained by an upstream abstraction |
| Wire control | Owns fetch and SSE completely | Delegates to pi-ai provider APIs | The direct path retains more error and header facts |
| Model admission | Catalog-external IDs pass through | Exact catalog membership | Forward compatibility versus configuration closure |
| Images | Rejected | Capability plus attachment store | Durable binary ownership is an additional dependency |
| Reasoning | Fixed off/high/max | Mapped per model | Provider-neutral IDs still require adapter validation |
| History fidelity | Tool-turn passback; plain reasoning is not replayed | Versioned signatures and replay metadata | The same internal Message can have different wire fidelity |
| Usage | Cache read plus optional reasoning | Cache read/write with reasoning folded | Common buckets do not imply equally rich source data |
| Retry | Each adapter performs one attempt; Agent recovery owns visible retries | One event, cost, and cancellation boundary | |
14. Strengths, costs, and current unknowns
| Choice | Benefit | Cost or unknown |
|---|---|---|
| Provider-neutral immutable messages | Log, UI, replay, and requests share one fact | Every new block must land across all consumers |
| Raw chunks plus semantic messages | Supports both streaming observation and final meaning | Projections must identify attempts and replacements correctly |
| Prepared one-shot call | Defends against registration HMR and request-config drift | Does not yet close over adapter-internal config generation |
| Retries in the Agent Loop | Attempts, waits, cancellation, and cost become recordable | Always mode is unbounded; same-Step identity exposes an accounting gap |
| Direct and library-backed adapters | Combines one high-fidelity path with broad coverage | Error detail, reasoning, usage, stop, and image capabilities are asymmetric |
| No implicit failover | Provider switching cannot silently alter semantics or cost | Deployments must compose an explicit fallback policy |
- The complete built-in provider and model catalog of the pinned pi-ai 0.82.1 dependency remains unverified because dependencies are absent.
- No real-API experiment has compared reasoning, cache, and error fidelity between the two paths for the same model.
- The dynamic-configuration generation window has not been reproduced through real settings HMR.
- There is no standard event, idempotency, or cost protocol for automatic provider failover.
- The upstream retry documentation needs correction to the same-Step attempt semantics.
This chapter reviewed production source, types, and test source line by line, and labels test conclusions as contracts defined by tests. Dependencies are not installed in the upstream checkout, so this study did not run upstream tests and does not present source-derived inferences as runtime observations.
My Learning Notes
Autosaved only in this browser. Nothing is uploaded or committed. Export Markdown whenever you want to keep a copy.