Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 8b47f33993 fix(ai): sanitize assistant text for Claude requests 2026-09-13 19:35:04 -05:00
4 changed files with 192 additions and 2 deletions
@@ -986,7 +986,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
else messages.push({ role: "user", content })
}
return messages
return ProviderShared.trimAssistantPrefill(messages)
})
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
@@ -383,6 +383,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
"tool-call",
])
if (part.type === "text") {
if (part.text.length === 0) continue
content.push(...textWithCache(breakpoints, part.text, part.cache))
continue
}
@@ -425,7 +426,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
else messages.push({ role: "user", content })
}
return messages
return ProviderShared.trimAssistantPrefill(messages)
})
// System prompts share the cache-point convention: emit the text block, then
+35
View File
@@ -122,6 +122,41 @@ export const parseJson = (route: string, input: string, message: string) =>
*/
export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n")
// Anthropic and Converse reject trailing whitespace in assistant prefills.
// Work on lowered copies so filtering and cache markers cannot hide the final text.
export const trimAssistantPrefill = <
T extends {
readonly role: string
readonly content: ReadonlyArray<{ readonly text?: string; readonly [key: string]: unknown }>
},
>(
messages: ReadonlyArray<T>,
): T[] => {
const result = [...messages]
while (result.at(-1)?.role === "assistant") {
const message = result[result.length - 1]
const content = [...message.content]
while (content.length > 0) {
const index = content.findLastIndex((part) => !("cachePoint" in part))
const part = content[index]
if (part?.text === undefined) break
const text = part.text.trimEnd()
if (text.length > 0) {
content[index] = { ...part, text }
break
}
// Remove the blank suffix and any cache marker belonging to it.
content.splice(index)
}
if (content.length > 0) {
result[result.length - 1] = { ...message, content }
break
}
result.pop()
}
return result
}
const escapeSystemUpdateText = (text: string) =>
text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
@@ -0,0 +1,154 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js"
import { BedrockConverse } from "../../src/protocols/bedrock-converse.js"
import { compileRequest } from "../../src/route/client.js"
import { Auth } from "../../src/route/auth.js"
import { it } from "../lib/effect.js"
const bedrockRoute = BedrockConverse.route.with({
endpoint: { baseURL: "https://bedrock.test" },
auth: Auth.bearer("test"),
})
for (const route of [AnthropicMessages.route, bedrockRoute]) {
const bedrock = route.id === "bedrock-converse"
const model = route.model({
id: bedrock ? "global.anthropic.claude-haiku-4-5-20251001-v1:0" : "claude-haiku-4-5-20251001",
})
const text = (value: string) => (bedrock ? { text: value } : { type: "text", text: value })
const cache = new CacheHint({ type: "ephemeral" })
it.effect(`${route.id} trims only the effective assistant prefill suffix`, () =>
Effect.gen(function* () {
const messages = [
Message.user("Keep the formatting. "),
Message.assistant("Historical text. \n"),
Message.user("Continue."),
Message.assistant([
{ type: "text", text: " Leading and middle " },
{ type: "text", text: "spacing \n" },
{ type: "text", text: " \n", cache },
{ type: "text", text: "", cache },
]),
Message.assistant(""),
Message.assistant([]),
]
const before = structuredClone(messages)
const request = LLM.request({ model, messages, cache: "none" })
const prepared = yield* compileRequest(request)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [text("Keep the formatting. ")] },
{ role: "assistant", content: [text("Historical text. \n")] },
{ role: "user", content: [text("Continue.")] },
{ role: "assistant", content: [text(" Leading and middle "), text("spacing")] },
])
expect((yield* compileRequest(request)).body).toEqual(prepared.body)
expect(structuredClone(messages)).toEqual(before)
}),
)
it.effect(`${route.id} preserves a surviving prefill cache marker`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user("Continue."), Message.assistant([{ type: "text", text: " Answer \t", cache }])],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [text("Continue.")] },
{
role: "assistant",
content: bedrock
? [text(" Answer"), { cachePoint: { type: "default" } }]
: [{ type: "text", text: " Answer", cache_control: { type: "ephemeral" } }],
},
])
}),
)
it.effect(`${route.id} drops blank-only terminal assistants and their cache markers`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Continue. "),
Message.assistant("Prefix \n"),
Message.assistant([{ type: "text", text: " \n\t", cache }]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [text("Continue. ")] },
{ role: "assistant", content: [text("Prefix")] },
])
}),
)
it.effect(`${route.id} preserves signed reasoning and does not trim text before a tool call`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Look it up."),
Message.assistant([
{ type: "reasoning", text: " Signed thinking \n", encrypted: "sig_1" },
{ type: "text", text: "", cache },
{ type: "text", text: "Calling the tool. \n" },
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
]),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [text("Look it up.")] },
{
role: "assistant",
content: [
bedrock
? { reasoningContent: { reasoningText: { text: " Signed thinking \n", signature: "sig_1" } } }
: { type: "thinking", thinking: " Signed thinking \n", signature: "sig_1" },
text("Calling the tool. \n"),
bedrock
? { toolUse: { toolUseId: "call_1", name: "lookup", input: {} } }
: { type: "tool_use", id: "call_1", name: "lookup", input: {} },
],
},
])
}),
)
}
it.effect("Bedrock drops empty historical assistant text while retaining non-empty whitespace", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: bedrockRoute.model({ id: "global.anthropic.claude-haiku-4-5-20251001-v1:0" }),
messages: [
Message.user("First."),
Message.assistant([{ type: "text", text: "", cache: new CacheHint({ type: "ephemeral" }) }]),
Message.user("Second."),
Message.assistant([
{ type: "text", text: "" },
{ type: "text", text: " \n\t" },
{ type: "text", text: "READY \n" },
]),
Message.user("Continue."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ text: "First." }, { text: "Second." }] },
{ role: "assistant", content: [{ text: " \n\t" }, { text: "READY \n" }] },
{ role: "user", content: [{ text: "Continue." }] },
])
}),
)