fix(sse): accept assistant refusal content parts in chatgpt-session

A `{ type: "refusal", refusal: "…" }` part is legal inside an assistant
message in the chat-completions spec, so a client replaying its own
conversation history sends it back. The unsupported-part guard rejected it
with a 400 and failed the whole request.

Fold a refusal part into the flattened content the way a text part is folded,
reading its `refusal` field. A refusal part whose payload is missing or is not
a string still falls through to the existing rejection — it is never dropped
in silence — and every other unrecognised part keeps throwing as before.
This commit is contained in:
diegosouzapw
2026-09-02 15:10:53 -03:00
parent 3f1bbe4b06
commit 844a817729
2 changed files with 55 additions and 0 deletions

View File

@@ -42,6 +42,14 @@ function textFromContent(content: unknown): string {
chunks.push(typed.text);
continue;
}
// `{ type: "refusal", refusal: "…" }` is a legal ASSISTANT content part in the
// chat-completions spec, and a client replaying its own conversation history sends it back
// verbatim. It is text as far as the adapter is concerned. A `refusal` part whose payload is
// missing or not a string falls through to the rejection below rather than being dropped.
if (typed && typed.type === "refusal" && typeof typed.refusal === "string") {
chunks.push(typed.refusal);
continue;
}
if (typed && (typed.type === "image_url" || typed.type === "image")) {
throw new ChatGptSessionInputError(
"vision_unsupported",

View File

@@ -134,3 +134,50 @@ test("rejects any content part that is neither text nor an image", () => {
);
}
});
test("an assistant refusal part is folded into the mapped content as text", () => {
const parsed = buildParsedRequest({
route,
messages: [
{ role: "user", content: "one" },
{
role: "assistant",
content: [
{ type: "refusal", refusal: "I can't help with that." },
{ type: "text", text: "Here is something else." },
],
},
{ role: "user", content: "three" },
],
stream: true,
});
assert.equal(parsed.context.messages.length, 3);
assert.deepEqual(parsed.context.messages[1], {
role: "assistant",
content: [{ type: "text", text: "I can't help with that.\nHere is something else." }],
timestamp: parsed.context.messages[1].timestamp,
});
});
test("a refusal part whose refusal field is not a string is still rejected", () => {
for (const part of [
{ type: "refusal" },
{ type: "refusal", refusal: null },
{ type: "refusal", refusal: { text: "nope" } },
{ type: "refusal", text: "wrong field" },
]) {
assert.throws(
() =>
buildParsedRequest({
route,
messages: [
{ role: "user", content: "one" },
{ role: "assistant", content: [part] },
],
stream: false,
}),
(error: unknown) =>
error instanceof ChatGptSessionInputError && error.code === "unsupported_content_part"
);
}
});