Anthropic SSE implementation: from bytes to AssistantMessage
providers/anthropic.ts is the heaviest of the 9 built-in providers — it implements SSE incremental decoding itself, converts Anthropic events to AssistantMessageEvent, injects OAuth/Claude Code identity, applies cache_control policy, and handles block-level incremental assembly for thinking and tool calls. It does not rely on the Anthropic SDK's streaming parser; it reads the byte stream of response.body directly and runs the SSE protocol itself. The final output is a sequence of events pushed into AssistantMessageEventStream, which consumers iterate with for await to get increments.
Responsibilities
- SSE byte-stream decoding:
iterateSseMessagesturns aReadableStream<Uint8Array>into aServerSentEventasync generator, supporting\r\n/\nand cross-chunk reassembly. Seepackages/ai/src/providers/anthropic.ts:321-369. - Single-line decoding:
decodeSseLinehandlesevent:/data:/:comments / empty-line flush per the SSE spec. Seepackages/ai/src/providers/anthropic.ts:266-290. - Event → typed object:
iterateAnthropicEventsparses SSE data withparseJsonWithRepairintoRawMessageStreamEvent, filters non-Anthropic events, and validatesmessage_start/message_stoppairing. Seepackages/ai/src/providers/anthropic.ts:380-419. - Streaming main entry:
streamAnthropicconstructs the client, builds params, callsclient.messages.create({stream:true}), and pushes events toAssistantMessageEventStream. Seepackages/ai/src/providers/anthropic.ts:421-510. - Simple version:
streamSimpleAnthropictranslatesSimpleStreamOptionsinto the fullAnthropicOptions, distinguishing adaptive thinking (Opus 4.6 / Sonnet 4.6) from budget-based thinking. Seepackages/ai/src/providers/anthropic.ts:717-756. - Param construction:
buildParamsassembles messages / system / tools / thinking, and forces Claude Code identity injection for OAuth tokens. Seepackages/ai/src/providers/anthropic.ts:868-925. - Message conversion:
convertMessagesturns the internalMessage[]into AnthropicMessageParam[], handling text/image/tool blocks, cache_control, and surrogate cleanup. Seepackages/ai/src/providers/anthropic.ts:978-1070.
Design motivation
Why not just use @anthropic-ai/sdk's client.messages.stream()? Because pi-ai wants to control the event shape itself — all providers ultimately push the same AssistantMessageEvent, and the consumer side handles every provider with one for await code path. The SDK's built-in stream helper gives you the SDK's own event types, and converting them is an extra hop. Running SSE ourselves also lets us attach sse.raw to error messages when parsing fails, which is useful for tracking down CDN truncation or proxies rewriting responses.
Why split convertMessages and buildParams? Because OAuth tokens and plain API keys take different system prompt construction paths — buildParams decides whether to inject Claude Code identity, while convertMessages only handles per-message formatting. The two each own a layer: changing cache policy does not touch message conversion, and changing message conversion does not touch OAuth logic.
Key files
packages/ai/src/providers/anthropic.ts:421-510— the body ofstreamAnthropic, building the client + streaming consumption inside an async IIFE.packages/ai/src/providers/anthropic.ts:487-489—client.messages.create({ ...params, stream: true })+stream.push({ type: "start" }).packages/ai/src/providers/anthropic.ts:494-494—for await (const event of iterateAnthropicEvents(response, signal)), the main consumption loop.packages/ai/src/providers/anthropic.ts:321-369—iterateSseMessages, splitting the byte stream into lines.packages/ai/src/providers/anthropic.ts:266-290—decodeSseLine, single-line parsing.packages/ai/src/providers/anthropic.ts:380-419—iterateAnthropicEvents, SSE → typed events + pairing validation.packages/ai/src/providers/anthropic.ts:717-756—streamSimpleAnthropic, simple → full options translation.packages/ai/src/providers/anthropic.ts:762-810—createClient, beta header assembly + OAuth detection.packages/ai/src/providers/anthropic.ts:868-925—buildParams, OAuth injecting Claude Code identity.packages/ai/src/providers/anthropic.ts:978-1070—convertMessages, message block conversion.
The core of SSE decoding, where an empty line triggers a flush that returns the full event:
// packages/ai/src/providers/anthropic.ts:266-290
function decodeSseLine(line: string, state: SseDecoderState): ServerSentEvent | null {
if (line === "") {
return flushSseEvent(state);
}
state.raw.push(line);
if (line.startsWith(":")) {
return null;
}
const delimiterIndex = line.indexOf(":");
const fieldName = delimiterIndex === -1 ? line : line.slice(0, delimiterIndex);
let value = delimiterIndex === -1 ? "" : line.slice(delimiterIndex + 1);
if (value.startsWith(" ")) {
value = value.slice(1);
}
if (fieldName === "event") {
state.event = value;
} else if (fieldName === "data") {
state.data.push(value);
}
return null;
}The Anthropic event iterator throws on pairing failure, rather than silently swallowing a truncated stream:
// packages/ai/src/providers/anthropic.ts:416-418
if (sawMessageStart && !sawMessageEnd) {
throw new Error("Anthropic stream ended before message_stop");
}OAuth tokens go through a special system prompt that forces Claude Code identity:
// packages/ai/src/providers/anthropic.ts:883-890
if (isOAuthToken) {
params.system = [
{
type: "text",
text: "You are Claude Code, Anthropic's official CLI for Claude.",
...(cacheControl ? { cache_control: cacheControl } : {}),
},
];Data flow
The full chain from byte stream to event stream:
Boundaries and failure modes
- Truncated stream:
sawMessageStart && !sawMessageEndthrowsAnthropic stream ended before message_stop, seepackages/ai/src/providers/anthropic.ts:416-418. - JSON parse failure:
parseJsonWithRepairfirst attempts a repair before throwing, and the error message includessse.dataandsse.raw, seepackages/ai/src/providers/anthropic.ts:408-413. - OAuth injection:
isOAuthTokendetects thesk-ant-oatprefix and forces the Claude Code identity system prompt, seepackages/ai/src/providers/anthropic.ts:758-760andpackages/ai/src/providers/anthropic.ts:883-897. - cache_control policy:
getCacheControl(model, cacheRetention)decides which blocks of system / messages to attach it to, seepackages/ai/src/providers/anthropic.ts:54-110. - Adaptive thinking detection:
supportsAdaptiveThinking(model.id)distinguishes Opus 4.6 / Sonnet 4.6 (which use effort) from older models (which use budget tokens), seepackages/ai/src/providers/anthropic.ts:681-715. - Temperature and thinking are mutually exclusive: when
thinkingEnabledis set,params.temperatureis skipped, seepackages/ai/src/providers/anthropic.ts:910-912.
Summary
anthropic.ts runs SSE itself, chaining four transformations — byte stream → ServerSentEvent → RawMessageStreamEvent → AssistantMessageEvent — and handles OAuth identity, cache_control, and thinking policy in buildParams / convertMessages. For the event stream data structure, see EventStream async iterator; for how providers register into the table, see Provider abstraction and built-ins.