Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-redacted-thinking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/ai-chat": patch
---

Fix `_sanitizeMessageForPersistence` stripping Anthropic `redacted_thinking` blocks. The sanitizer now strips OpenAI ephemeral metadata first, then filters out only reasoning parts that are truly empty (no text and no remaining `providerMetadata`). This preserves Anthropic's `redacted_thinking` blocks (stored as empty-text reasoning parts with `providerMetadata.anthropic.redactedData`) while still removing OpenAI placeholders. Fixes #978.
57 changes: 34 additions & 23 deletions packages/ai-chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1219,36 +1219,25 @@ export class AIChatAgent<
* Sanitizes a message for persistence by removing ephemeral provider-specific
* data that should not be stored or sent back in subsequent requests.
*
* This handles two issues with the OpenAI Responses API:
* Two-step process:
*
* 1. **Duplicate item IDs**: The AI SDK's @ai-sdk/openai provider (v2.0.x+)
* defaults to using OpenAI's Responses API which assigns unique itemIds
* to each message part. When these IDs are persisted and sent back,
* OpenAI rejects them as duplicates.
* 1. **Strip OpenAI ephemeral fields**: The AI SDK's @ai-sdk/openai provider
* (v2.0.x+) defaults to using OpenAI's Responses API which assigns unique
* itemIds and reasoningEncryptedContent to message parts. When persisted
* and sent back, OpenAI rejects duplicate itemIds.
*
* 2. **Empty reasoning parts**: OpenAI may return reasoning parts with empty
* text and encrypted content. These cause "Non-OpenAI reasoning parts are
* not supported" warnings when sent back via convertToModelMessages().
* 2. **Filter truly empty reasoning parts**: After stripping, reasoning parts
* with no text and no remaining providerMetadata are removed. Parts that
* still carry providerMetadata (e.g. Anthropic's redacted_thinking blocks
* with providerMetadata.anthropic.redactedData) are preserved, as they
* contain data required for round-tripping with the provider API.
*
* @param message - The message to sanitize
* @returns A new message with ephemeral provider data removed
*/
private _sanitizeMessageForPersistence(message: ChatMessage): ChatMessage {
// First, filter out empty reasoning parts (they have no useful content)
const filteredParts = message.parts.filter((part) => {
if (part.type === "reasoning") {
const reasoningPart = part as ReasoningUIPart;
// Remove reasoning parts that have no text content
// These are typically placeholders with only encrypted content
if (!reasoningPart.text || reasoningPart.text.trim() === "") {
return false;
}
}
return true;
});

// Then sanitize remaining parts by stripping OpenAI-specific ephemeral data
const sanitizedParts = filteredParts.map((part) => {
// First, strip OpenAI-specific ephemeral data from all parts
const strippedParts = message.parts.map((part) => {
let sanitizedPart = part;

// Strip providerMetadata.openai.itemId and reasoningEncryptedContent
Expand Down Expand Up @@ -1280,6 +1269,28 @@ export class AIChatAgent<
return sanitizedPart;
}) as ChatMessage["parts"];

// Then filter out reasoning parts that are truly empty (no text and no
// remaining providerMetadata). This removes OpenAI placeholders whose
// metadata was just stripped, while preserving provider-specific blocks
// like Anthropic's redacted_thinking that carry data in providerMetadata.
const sanitizedParts = strippedParts.filter((part) => {
if (part.type === "reasoning") {
const reasoningPart = part as ReasoningUIPart;
if (!reasoningPart.text || reasoningPart.text.trim() === "") {
if (
"providerMetadata" in reasoningPart &&
reasoningPart.providerMetadata &&
typeof reasoningPart.providerMetadata === "object" &&
Object.keys(reasoningPart.providerMetadata).length > 0
) {
return true;
}
return false;
}
}
return true;
});

return { ...message, parts: sanitizedParts };
}

Expand Down
110 changes: 110 additions & 0 deletions packages/ai-chat/src/tests/sanitize-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,116 @@ describe("Message Sanitization", () => {
ws.close(1000);
});

it("preserves Anthropic redacted_thinking blocks with empty text", async () => {
const room = crypto.randomUUID();
const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
await new Promise((r) => setTimeout(r, 50));

const agentStub = await getAgentByName(env.TestChatAgent, room);

const messageWithRedactedThinking: ChatMessage = {
id: "msg-sanitize-redacted",
role: "assistant",
parts: [
{
type: "reasoning",
text: "",
state: "done",
providerMetadata: {
anthropic: {
redactedData: "base64-encrypted-data"
}
}
},
{ type: "reasoning", text: "", state: "done" },
{ type: "text", text: "Here is my answer" },
{
type: "reasoning",
text: "Visible thinking",
state: "done"
}
] as ChatMessage["parts"]
};

await agentStub.persistMessages([messageWithRedactedThinking]);

const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];
expect(persisted.length).toBe(1);

// The Anthropic redacted_thinking part (empty text + providerMetadata.anthropic) should be preserved
// The plain empty reasoning part should be filtered out
// The non-empty reasoning part should be preserved
const reasoningParts = persisted[0].parts.filter(
(p) => p.type === "reasoning"
);
expect(reasoningParts.length).toBe(2);

const redactedPart = reasoningParts[0] as {
text: string;
providerMetadata?: Record<string, unknown>;
};
expect(redactedPart.text).toBe("");
expect(redactedPart.providerMetadata?.anthropic).toEqual({
redactedData: "base64-encrypted-data"
});

expect((reasoningParts[1] as { text: string }).text).toBe(
"Visible thinking"
);

// Text part should be preserved
const textParts = persisted[0].parts.filter((p) => p.type === "text");
expect(textParts.length).toBe(1);

ws.close(1000);
});

it("removes empty OpenAI reasoning placeholders after stripping metadata", async () => {
const room = crypto.randomUUID();
const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
await new Promise((r) => setTimeout(r, 50));

const agentStub = await getAgentByName(env.TestChatAgent, room);

// OpenAI returns empty reasoning parts with only ephemeral metadata.
// After stripping OpenAI fields, these should be filtered out entirely.
const messageWithOpenAIReasoning: ChatMessage = {
id: "msg-sanitize-openai-reasoning",
role: "assistant",
parts: [
{
type: "reasoning",
text: "",
state: "done",
providerMetadata: {
openai: {
itemId: "item_reasoning_1",
reasoningEncryptedContent: "encrypted-blob"
}
}
},
{ type: "text", text: "Final answer" }
] as ChatMessage["parts"]
};

await agentStub.persistMessages([messageWithOpenAIReasoning]);

const persisted = (await agentStub.getPersistedMessages()) as ChatMessage[];
expect(persisted.length).toBe(1);

// The empty reasoning part should be gone (OpenAI metadata stripped, then empty part filtered)
const reasoningParts = persisted[0].parts.filter(
(p) => p.type === "reasoning"
);
expect(reasoningParts.length).toBe(0);

// Text part should be preserved
const textParts = persisted[0].parts.filter((p) => p.type === "text");
expect(textParts.length).toBe(1);

ws.close(1000);
});

it("strips callProviderMetadata from tool parts", async () => {
const room = crypto.randomUUID();
const { ws } = await connectChatWS(`/agents/test-chat-agent/${room}`);
Expand Down
Loading