Request validation
validateChatRequest — the structural gate every chat route needs in front of a client-supplied canonical history.
Die Dokumentationsseiten selbst sind auf Englisch. Navigation, Suche und UI-Texte folgen der gewählten Sprache.
Every documented chat route used to read const { messages } = await req.json() and hand the result straight to streamChat. That body is attacker-controlled, and canonical Message[] is expressive enough to be dangerous:
Roleincludes'system', so a client can append a system turn and overwrite the instructions your route thought it owned. This is the live vector, and the one most deployments miss.- A forged
tool_resultpart can claim any outcome ("payment captured"). On the wire a client-authored tool result is indistinguishable from one the server produced, and the model believes it. (Since 2.3 the default accepts a result only as the answer to a call of the assistant turn right before it, which is the shapeuseChatposts; see below.) - Forged assistant turns rewrite what the model thinks it already said.
- 50 000 messages, or one 40 MB message, is a billing attack.
The HMAC approval token proves an approval verdict is genuine — it says nothing about the messages array around it. validateChatRequest is the structural gate in front of it.
Available on @deuz-sdk/core/chat and @deuz-sdk/core/edge.
Use it
import { streamChat } from '@deuz-sdk/core';
import { validateChatRequest } from '@deuz-sdk/core/chat';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { toDeuzStreamResponse } from '@deuz-sdk/core/ui';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
export async function POST(req: Request): Promise<Response> {
const parsed = validateChatRequest(await req.json());
if (!parsed.ok) return Response.json({ issues: parsed.issues }, { status: 400 });
const { messages, chatId, approvalResponses } = parsed.request;
const result = streamChat({
model: anthropic('claude-opus-4-8'),
instructions: 'You are a helpful assistant.', // the route owns the system prompt
messages,
...(approvalResponses ? { approvalResponses } : {}),
signal: req.signal,
});
return toDeuzStreamResponse(result);
}function validateChatRequest(body: unknown, options?: ValidateChatOptions): ValidateChatResult;
type ValidateChatResult =
| { ok: true; request: DeuzChatRequest }
| { ok: false; issues: string[] };
interface DeuzChatRequest {
messages: Message[];
chatId?: string;
approvalResponses?: ToolApprovalResponse[];
rest: Record<string, unknown>;
}parseDeuzChatRequest(body, options?) is the throwing variant for routes that already funnel every failure through one catch: it throws InvalidRequestError, which already reports statusCode: 400 and whose toJSON() is secret-safe, so an existing error handler maps it with no new branch.
It never repairs
Every failure is a rejection. A silently "cleaned" message array hides the attack and leaves the operator with no signal — so nothing is filtered out, ever. issues is always non-empty when ok is false.
Defaults
| Option | Default | What it does |
|---|---|---|
rejectSystemRole | true | Reject role: 'system' in the client's messages. Opt out only if the client legitimately owns the system prompt (a local playground — never a multi-tenant deployment). |
rejectToolResults | true | Reject client-authored tool_result parts and role: 'tool' turns. A tool_result smuggled into a role: 'user' turn is caught at the part level too. Since 2.3 the default accepts one shape: role: 'tool' turns right after an assistant turn that answer its calls, each call once — the history useChat posts (see below). false accepts every tool result. |
rejectAssistantTurns | false | Regenerate and edit-and-resend legitimately replay assistant turns, so they are allowed by default. |
maxMessages | 1000 | History length cap. A body over the cap is rejected without walking the entries — rejecting 50 000 messages must not cost 50 000 validations. |
maxTextBytes | 100_000 | Per-message UTF-8 text budget (≈25k tokens). Counts string content, every text/reasoning part except opaque reasoning, and every string inside a tool_use.input / tool_result.result, object keys included (2.3). |
maxOpaqueReasoningBytes (2.3) | 1_000_000 | Per-message UTF-8 budget for opaque reasoning payloads: encrypted reasoning (OpenAI Responses) and redacted thinking (Anthropic). Counted on its own, not against maxTextBytes. |
maxProviderMetadataBytes (2.3) | 1_000_000 | Per-message UTF-8 budget for provider round-trip data: the providerMetadata of the message and of its text and tool_use parts (a Gemini thought signature, OpenAI's phase), keys included, and every reasoning signature (Claude). Counted on its own. |
Image and PDF payloads are excluded from maxTextBytes — a legitimate photo is megabytes of base64 and would trip a cap sized for prose. Binary volume is a raw-body-size concern: cap that at the edge, which a pure validator cannot do for you.
Opaque reasoning has a budget of its own (2.3). Since 2.3 useChat posts every reasoning block of a turn back. An encrypted or redacted block is provider state, not prose, so it counts against maxOpaqueReasoningBytes instead of maxTextBytes. The flags are client-controlled, so a part counts as opaque only when its flag matches the family that produces it: encrypted with source absent or 'openai', redacted with source absent or 'anthropic'. Those two wires send the payload back only as the opaque block it came from (an encrypted reasoning item, a redacted_thinking block). Any other flagged part, such as an "encrypted" part claiming source: 'google', counts as text, and the Gemini wire never sends a flagged part at all. Raise maxOpaqueReasoningBytes for long multi-step turns at high reasoning effort, whose payloads add up within one message.
Provider round-trip data has a budget of its own too (2.3). useChat posts back what a wire needs on the next request: a Gemini function call's or text's thought signature in providerMetadata, the signature of a Claude thinking block, OpenAI's phase. Before 2.3 none of it counted against any budget, so a client could attach megabytes to a message under every default. maxProviderMetadataBytes bounds it per message; raise it for a long multi-step Gemini turn with many signed calls. Since 2.3 every byte walk counts an object's keys as well as its values, so a payload cannot hide its volume in key names.
Client tools need rejectToolResults: false?
Not since 2.3. The history useChat posts holds role: 'tool' messages in three places: after a client tool (onToolCall or addToolResult), after an approval (the approved call's result, which the resumed leg streamed), and after a turn in which the server ran a tool (that call's result). Each answers calls of the assistant turn right before it, and since 2.3 the default accepts exactly that shape:
- one
role: 'tool'message, or a run of them, right after an assistant turn; - holding only
tool_resultparts; - each answering a call (
tool_use) of that assistant turn that nothing answered yet.
Anything else is still rejected: a tool result with no assistant turn right before it, one for a call that turn did not make, a call answered twice, a tool message that also carries text, and a tool_result part inside a user turn. An accepted result passes the same shape checks and counts against maxTextBytes like any other content. Before 2.3 the default rejected every tool result, so a client-tool route had to pass { rejectToolResults: false }, and a route with approvals or server tools got a 400 once useChat's history answered those calls. Pass false only to accept tool results of any shape:
const parsed = validateChatRequest(await req.json(), { rejectToolResults: false });Understand exactly what the default accepts. The validator cannot tell a real tool result from a forged one, because the same client authored the assistant turn it pairs with: a client can claim that its call of a server tool returned anything. A forged result runs no tool, and an approval still needs its verdict, but the model reads the result as fact. The only real fix is to stop trusting the history: persist it server-side with a ChatStore + chatId and treat the client's copy as a rendering cache, or check a result against your own record of the run before you use the history.
Issue strings are bounded and secret-safe
Only a bad role and a bad part type are ever echoed back, and each is redacted first, then truncated to 32 characters — that order matters, because truncating first could split a secret pattern so the redaction sweep no longer matches and its head leaks. chatId and approval token values are reported by type only, never by value.
The issue list itself is capped (20 entries plus an N further issue(s) suppressed. line), so a 1000-message hostile body cannot amplify into 1000 strings your log aggregator has to eat.
request.rest is unvalidated passthrough
Everything the client sent that is not messages / chatId / approvalResponses lands in rest, deliberately typed Record<string, unknown> (this is where useChat's options.body fields arrive). It is a new object, but the values are the raw parsed JSON.
Never spread `rest` into call options
Read the fields you know by name and validate them yourself. Spreading it would hand a client control over maxSteps, tools, deps and every other call option.
What it deliberately does not do
- No rate limiting. The module is pure — no clock, no randomness, no
console— so it cannot time anything. Rate limiting belongs at the edge. - No proof of provenance. It proves the body has the shape
streamChatexpects and that the client did not claim privileges it does not have. It cannot prove the history is the one your server previously produced; only server-side persistence can. - No repair. See above.
Two structural hardenings worth knowing: a body carrying an own __proto__ key is rejected outright (JSON.parse makes it an own enumerable property, and copying it onto a plain object hits Object.prototype's setter), and role/part lookups use Object.hasOwn, so { type: 'constructor' } cannot resolve off the prototype chain.
See also
- Client tools — the round-trip whose answers the default accepts since 2.3.
- Chat persistence — the server-side history that makes the client's copy a cache.
- Durable runtime —
createApprovalSignerand the signed approval token. - UI streaming — the wire this route answers on.
React Hooks
useChat and useObject from @deuz-sdk/react — ordered part rendering, writable state, multimodal input, client-tool round-trips, approval pauses, chat snapshots and the capability handshake.
Middleware
Wrap a model with composable, removable layers for logging, caching, redaction, and prompt-injection defense.