@reinsjs/lowering-fetch
A zero-dependency lowering layer for reins: timeline events → provider wire protocol → event drafts, using nothing but fetch and a hand-written SSE parser. No provider SDK, no node:*, the same code on Node, Workers, Deno and Bun.
Three wire protocols: OpenAI Chat Completions (the front door of the OpenAI-compatible ecosystem: DeepSeek, Qwen, vLLM, gateways), Anthropic Messages (mid-conversation system placement, signed thinking replay, explicit cache breakpoints) and OpenAI Responses (stateless store: false, encrypted reasoning replay, developer messages anywhere).
pnpm add @reinsjs/lowering-fetch
import { anthropic, anthropicMessages, deepseek, openai, openaiChat, openaiResponses, chatCompletions } from "@reinsjs/lowering-fetch"
import { createAgent } from "@reinsjs/agent"
const opus = anthropic("claude-opus-5", { apiKey: process.env.ANTHROPIC_API_KEY! })
const ds = deepseek("deepseek-flash", { apiKey: process.env.DEEPSEEK_API_KEY! })
const gpt = openai("gpt-5-mini", { apiKey: process.env.OPENAI_API_KEY!, requestOptions: { reasoning: { effort: "low", summary: "auto" } } })
const mini = openaiChat("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY! })
// any Anthropic-protocol endpoint (DeepSeek's compatible port, a gateway)
const dsClaude = anthropicMessages("deepseek-v4-flash", {
provider: "deepseek",
baseUrl: "https://api.deepseek.com/anthropic",
apiKey: process.env.DEEPSEEK_API_KEY!,
reasoning: true,
midConversationSystem: true,
})
const qwen = chatCompletions("qwen-max", {
provider: "qwen",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: process.env.DASHSCOPE_API_KEY!,
contextWindow: 128_000,
})
export const agent = createAgent({ model: ds, store, tools })
Each factory returns a BoundModel = { model, lowering }. apiKey is required and never read from the environment. For several models on one lowering instance, construct FetchLowering yourself and pass models.
Choosing between @reinsjs/lowering-fetch and @reinsjs/lowering-pi
Both implement the same Lowering interface from @reinsjs/core and both declare every landing in a LOSS_MATRIX; createAgent does not care which one you pass. They coexist on purpose — pick per host, not per project.
@reinsjs/lowering-fetch | @reinsjs/lowering-pi | |
|---|---|---|
| Protocols | OpenAI Chat Completions, Anthropic Messages, OpenAI Responses | Anthropic Messages, OpenAI Responses |
Deferred tools (Anthropic defer_loading + tool_reference, used by lazyTools()) | Yes — capabilities.deferredTools on official Anthropic models | No (request shaping is pi-ai's; references are rendered as text) |
| Dependencies | @reinsjs/core only (≈90 KB of ESM) | pi-ai and its ten dependencies (≈65 MB installed) |
| Request body | payload.body is what is sent | pre-rewrite shape; the real body is produced in pi-ai's onPayload hook |
Mid-conversation system on Anthropic | placed by the encoder | placed by rewriting pi-ai's payload |
| Unsigned thinking | dropped (declared) | may fall back to visible text |
| Model table | a minimal built-in table you override per model | pi-ai's table, maintained upstream |
| Runtime | verified on Cloudflare workerd at the strictest tier (2023 compatibility date, no nodejs_compat), Bun, Deno (--allow-net only) and Vercel edge-runtime, all three protocols against live providers | verified on the same four runtimes, both protocols; Deno needs --allow-sys=osRelease, an Edge runtime needs a process global for the OpenAI path |
Start with lowering-fetch for a new host: it covers everything lowering-pi covers plus the OpenAI-compatible ecosystem, ships nothing but this package, and the request you debug is the request that was sent. Stay on lowering-pi when you already run on it (there is nothing to migrate for), or when you want the provider table and new wire-protocol features to arrive through pi-ai rather than through this package. Events written by one layer replay through the other: replay.thinkingSignature uses the same shape on both, so switching does not invalidate a stored timeline.
Runtime
No node:*, no process, no Buffer — the same dist/index.js runs on Node ≥ 22 and on Cloudflare Workers with a 2023 compatibility_date and no compatibility flags (spikes/edge-runtime-check, tier "最严档": module load, a byte-sliced SSE stream against a local fake endpoint, and one live request per protocol). Bun 1.4, Deno 2.9 (only --allow-net needed — the layer reads no system information) and Vercel's edge-runtime (bare vm, no process, no eval) pass the same probes, all three protocols against live providers (spikes/runtime-matrix).
Options
| option | meaning |
|---|---|
apiKey | required; use auth: "none" when the credential lives in headers (Cloudflare AI Gateway's cf-aig-authorization) |
baseUrl | protocol root; /chat/completions (OpenAI: …/v1, DeepSeek: the bare host), /messages (Anthropic: …/v1) or /responses (OpenAI: …/v1) is appended |
contextWindow, maxOutputTokens, reasoning, images, cost, midConversationSystem | override the built-in table; unknown models start from conservative defaults (128k / 16k / no reasoning / no images) |
chat.reasoningContent | DeepSeek dialect: replay reasoning_content (on by default in deepseek(), see below) |
chat.liftToolImages | when the model takes images, move images in tool results into a user message right after the batch of tool messages (default true); set false for an upstream that rejects that shape and images become a placeholder again |
anthropic.betas | values for the anthropic-beta header; sent only when set (mid-conversation system needs none) |
anthropic.cacheBreakpoints, anthropic.cacheTtl, anthropic.midSystemCacheBreakpoint | explicit cache breakpoints on Anthropic (default on, 5 min); what to do when a note ends the request — "automatic" (default, top-level cache_control), "previous-user", "drop" |
anthropic.deferredTools | whether ToolSpec.deferLoading becomes defer_loading: true and tool_reference content parts become native tool_reference blocks. Default: on for provider: "anthropic", off for third-party Anthropic-protocol endpoints (DeepSeek's compatible endpoint ignores defer_loading, so every tool would be visible). When off, deferred tools are simply not sent and references are rendered as text |
responses.systemRole | role for the system prompt and notes on Responses: default developer for reasoning models, system otherwise; pin it for an upstream that rejects one of them |
responses.encryptedReasoning | default on for reasoning models: include: ["reasoning.encrypted_content"] is always sent so reasoning items can be replayed under store: false; off for upstreams that reject include (then thinkingReplay reports false) |
requestOptions | spread into the body (max_tokens, temperature, thinking, OpenAI's parallel_tool_calls, Responses' reasoning / max_output_tokens / prompt_cache_key…); messages / input / tools / system / model / stream / store / previous_response_id cannot be overridden. On Anthropic max_tokens defaults to the model's maxOutputTokens and thinking is left to you (Opus 5+ defaults to adaptive server-side; Haiku 4.5 still needs budget_tokens); on Responses reasoning is likewise yours (gpt-5 defaults to medium, gpt-5.1+ to none) |
timeoutMs | whole-request deadline including the stream, default 600 000; a timeout is a retryable error, a host signal abort is aborted |
trustMarkers | default on: tool output is wrapped in <untrusted source="tool:…"> in the request; the log keeps the original |
How events land on Chat Completions
Every landing is declared in LOSS_MATRIX["openai-chat"] and asserted by tests — nothing is dropped silently.
system_note→ a mid-conversationsystemmessage (exact; verified on OpenAI and DeepSeek). SetmidConversationSystem: falsefor an upstream that rejects it and the note goes out as a framedusermessage (lossy). A note markeddirective: true(an instruction the model must act on, such ascompact's request) always goes out as the framedusermessage, on all three protocols.- A user message or note that arrives while tool results are still pending is moved after them on the wire (
toolmessages must directly follow theassistantthat called them); the user message is recordedlossy(user), the log order is untouched. - Thinking: Chat Completions has no replay slot (no signature, no encrypted item). On the official API thinking events are
dropped. Withchat.reasoningContent(DeepSeek) they are replayed asreasoning_content— and DeepSeek requires the field on every historical assistant message oncetoolsis present (400 otherwise), so the layer always sends it, empty when there is nothing to replay. - One turn with several text blocks is merged into one
contentstring (lossy(merged-text));toolmessages carry text only: when the model takes images, each image leaves a pointer ([image n attached in the next user message]) and the images go into oneusermessage right after the whole batch oftoolmessages — before any user message that was moved after the batch — with a line per result naming the tool, the call id and the image count and saying it is tool output, not the user; untrusted results keep their<untrusted>marking (lossy(tool+lifted-images); verified on DeepSeek's official API — check other OpenAI-compatible upstreams before relying on it, and turn it off withchat.liftToolImages: falseif they reject the shape). Without image support, or withchat.liftToolImages: false, images become a placeholder (lossy(tool-text-only));isErrorhas no flag on this protocol and is expressed as a[tool error]prefix (lossy). - No explicit cache breakpoints exist on this protocol; provider-side automatic prefix caching applies and
cached_tokensis reported ascacheRead. - Usage follows core semantics:
inputis the uncached prompt count.costUsduses the built-in price table (DeepSeek: peak rates, an upper bound).
How events land on Anthropic Messages
Declared in LOSS_MATRIX["anthropic-messages"], cell for cell comparable with @reinsjs/lowering-pi.
-
system_note→ a mid-conversation{ role: "system" }message on model families that accept it (Fable 5.x / Mythos 5.x / Opus 5 / Opus 4.8, ormidConversationSystem: true). The provider requires such a message to follow auserturn and be followed by anassistantturn or end the request, so notes are held and emitted right before the next assistant turn (or at the end); when that slot follows an assistant turn — or the note would be the first message — the note goes out as a framedusertext (lossy(user-role)). Other models always get the framed text. -
Tool results and anything moved after them (a user message that arrived while results were pending) share one
usermessage, sotool_resultblocks directly follow thetool_useturn.is_erroris carried natively. -
Thinking is replayed only when the event carries a
signaturefrom the same provider, API and model (thinking/redacted_thinkingblocks); unsigned thinking (an interrupted stream), thinking from another provider, or thinking from another model of the same provider isdroppedand declared — it is not turned into visible text (a signature is only valid for the model that produced it: replaying one across models is a 400). Empty-text thinking blocks with a signature (display: "omitted") are kept and replayed. -
Cache breakpoints: one on the last system block, one on the last tool, one on the last block of the last
usermessage. When a note is the last message, the conversation breakpoint becomes a top-levelcache_control(measured on par with no injection). Never more than four in total. -
Usage:
input_tokensis already the uncached count;cache_read_input_tokens/cache_creation_input_tokensmap tocacheRead/cacheWrite.stop_reason: "refusal"becomes a non-retryableerrorcarryingstop_details. -
Deferred tools (
ToolSpec.deferLoading, set bylazyTools()whencapabilities.deferredToolsis true):defer_loading: trueon the tool, the cache breakpoint moves to the last non-deferred tool (a deferred tool cannot carrycache_control), and if every tool would be deferred none is (the API rejects that). Atool_resultwhose content is onlytool_referenceparts, comes from a system-trusted result, and names tools present in this request'stoolsis sent astool_referenceblocks the API expands in place — the tool list never changes, so the cache prefix survives (exact/tool-reference); text parts in the same result are moved after the batch oftool_resultblocks (lossy/tool-reference, they cannot be mixed). References to tools not in this request, or inside untrusted results, are rendered as text instead — the API validates every reference in the whole history and answers 400 for an unknown name.
How events land on OpenAI Responses
Declared in LOSS_MATRIX["openai-responses"], cell for cell comparable with @reinsjs/lowering-pi.
- Stateless by construction. Every request carries the whole history in
input;store: falseis forced and aprevious_response_idinrequestOptionsis dropped — the timeline is the only source of truth, OpenAI's server-side conversation state is never relied on. - The system prompt and
system_notearedevelopermessages (reasoning models) orsystemmessages (others) at any position ininput— no placement rules, alwaysexact.compactionis a framedusertext (lossy). - Reasoning: reasoning models always request
include: ["reasoning.encrypted_content"]; each reasoning item is stored whole in the event'sreplay.thinkingSignatureand replayed verbatim (the provider verifies the encrypted payload — a forged one is a 400). Items withoutencrypted_content(an interrupted stream,includerefused), from another provider, or from another model aredroppedand declared, never turned into text. Reasoning items with an encrypted payload but no summary are kept as empty-text thinking events so the next turn can replay them. - Assistant text becomes a
type: "message"item per text block, replaying the provider'smsg_id (replay.textSignature, lowering-pi's JSON form is accepted too) or a generated one. Tool calls arefunction_callitems:call_idis the event'stoolCallId, thefc_item id lives inreplay.itemIdand is replayed only for the same model (OpenAI validates the pairing offc_andrs_items);argumentsis a JSON string, so non-object arguments need no wrapping. Tools are sent withstrict: false. - Tool results are
function_call_outputitems matched bycall_id: a string when text-only, an array ofinput_text/input_imageblocks when the result has images and the model accepts them;isErrorhas no flag on this protocol and is expressed as a[tool error]prefix (lossy). - Usage:
input_tokensincludes cached and cache-write tokens, soinputisinput_tokens − cached_tokens − cache_write_tokens, with the two mapped tocacheRead/cacheWrite.status: "incomplete"withmax_output_tokensislength; withcontent_filtera non-retryableerror;response.failedanderrorframes are errors carrying the provider's code and message. Automatic prefix caching is best-effort on OpenAI's side; passprompt_cache_keyinrequestOptionsto improve hit rates. - Built-in table: the gpt-5 family (5.5 / 5.4 / 5.4-mini / 5.2 / 5.1 / 5 / 5-mini / 5-nano), o3 / o4-mini and gpt-4.1 / 4.1-mini / 4o-mini on Responses; the same OpenAI ids also exist as Chat Completions entries, which
openaiChat()picks by protocol. A bare{ provider: "openai", id }on aFetchLoweringyou construct yourself resolves to Responses.
Errors and retries
Non-2xx responses throw HttpError with status, headers and the raw body ("<status> <body>", the same shape the official SDKs use), so runLoop's transient-failure rule applies unchanged: 408 / 409 / 429 / 5xx retry, everything else does not. Network failures propagate from fetch untouched.
Documentation
docs/archive/implementation-plans/2026-09-08-技术方案v1.0.md §11, docs/系统全景图_模块盘点/lowering-fetch.md, spikes/f1-chat-live/, spikes/f2-anthropic-live/, spikes/f3-responses-live/ (live verification against DeepSeek and, through the Cloudflare AI Gateway, OpenAI and Anthropic) and spikes/edge-runtime-check/ (Cloudflare workerd) — in Chinese, at the repository root.
MIT © 2026 NewRate Limited.