From 5b1e8450e710cc383e45823c6da93ca7be315791 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:47:18 -0500 Subject: [PATCH] feat(ai): preserve streamed refusals as text (#43343) --- packages/ai/src/protocols/open-responses.ts | 22 ++++ packages/ai/src/protocols/openai-chat.ts | 16 ++- packages/ai/test/provider/openai-chat.test.ts | 68 ++++++++++++ .../openai-compatible-responses.test.ts | 45 ++++++++ .../ai/test/provider/openai-responses.test.ts | 100 ++++++++++++++++++ 5 files changed, 249 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 9abe9aeb77..412e57d6d6 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -298,6 +298,20 @@ export const Event = Schema.StructWithRest( ) export type Event = Schema.Schema.Type +const RefusalEvent = Schema.Union([ + Schema.Struct({ + type: Schema.tag("response.refusal.delta"), + item_id: Schema.String, + delta: Schema.String, + }), + Schema.Struct({ + type: Schema.tag("response.refusal.done"), + item_id: Schema.String, + refusal: Schema.String, + }), +]) +const isRefusalEvent = Schema.is(RefusalEvent) + export interface Extension { readonly id: string readonly name: string @@ -1073,6 +1087,14 @@ export const step = (state: ParserState, event: Event) => { : onOutputTextDone(state, event, event.item_id), ) } + if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") { + if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`) + return Effect.succeed( + event.type === "response.refusal.delta" + ? onOutputTextDelta(state, event, event.item_id) + : onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id), + ) + } if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") { if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`) return Effect.succeed(onReasoningDelta(state, event, event.item_id)) diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index ac5f229ee0..c4e7eabe92 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js" import { ToolStream } from "./utils/tool-stream.js" const ADAPTER = "openai-chat" -const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"]) +const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"]) export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/chat/completions" @@ -194,6 +194,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type const reasoning = reasoningDelta(delta, state.reasoningField) const hasLateContent = Boolean(delta?.content) || + Boolean(delta?.refusal) || reasoning !== undefined || (Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) || toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments)) @@ -728,7 +730,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => else if ( reasoningDetailsObserved && !lifecycle.reasoning.has("reasoning-0") && - (Boolean(delta?.content) || toolDeltas.length > 0) + (Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0) ) lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata) const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0") @@ -743,6 +745,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) => lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content) } + if (delta?.refusal) { + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined), + ) + lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal) + } + // Compatible providers may omit indexes. Prefer durable identity, then use // batch position for parallel deltas or the latest call for sparse chunks. for (const [position, tool] of toolDeltas.entries()) { diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 3fc672aa12..1c0e54bda3 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -664,6 +664,74 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("preserves streamed refusals as ordinary assistant text", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + deltaChunk({ role: "assistant", refusal: "I can't" }), + deltaChunk({ refusal: " help with that." }), + deltaChunk({}, "stop"), + ), + ), + ), + ) + + expect(response.text).toBe("I can't help with that.") + expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" }) + expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }]) + + const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }]) + }), + ) + + it.effect("orders metadata-only reasoning before refusal output", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { choices: [{ delta: { reasoning_details: [] } }] }, + deltaChunk({ refusal: "I can't help with that." }), + deltaChunk({}, "stop"), + ), + ), + ), + ) + + expect(response.message.content).toEqual([ + { type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } }, + { + type: "text", + text: "I can't help with that.", + }, + ]) + }), + ) + + it.effect("joins content and refusal deltas into ordinary assistant text", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + deltaChunk({ refusal: "No." }), + deltaChunk({ content: " Alternative." }), + deltaChunk({ refusal: " Still no." }), + deltaChunk({}, "stop"), + ), + ), + ), + ) + + expect(response.text).toBe("No. Alternative. Still no.") + expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"]) + expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"]) + }), + ) + it.effect("parses and replays OpenAI-compatible reasoning fields", () => Effect.gen(function* () { const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const diff --git a/packages/ai/test/provider/openai-compatible-responses.test.ts b/packages/ai/test/provider/openai-compatible-responses.test.ts index 5462812cd9..11fc68ef99 100644 --- a/packages/ai/test/provider/openai-compatible-responses.test.ts +++ b/packages/ai/test/provider/openai-compatible-responses.test.ts @@ -118,6 +118,51 @@ describe("Open Responses-compatible route", () => { }), ) + it.effect("preserves standard refusal content as ordinary assistant text", () => + Effect.gen(function* () { + const model = configure({ + apiKey: "test-key", + baseURL: "https://responses.example.test/v1", + provider: "example", + }).model("example-model") + const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_refusal", content: [] }, + }, + { + type: "response.refusal.done", + item_id: "msg_refusal", + refusal: "I can't help with that.", + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_refusal", + content: [{ type: "refusal", refusal: "I can't help with that." }], + }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }]) + + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + expect(prepared.body.input).toEqual([ + { role: "assistant", content: [{ type: "output_text", text: "I can't help with that." }] }, + ]) + }), + ) + it.effect("reads standard Open Responses options", () => Effect.gen(function* () { const model = configure({ diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index ed138dffa3..9c89fec02a 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -1505,6 +1505,106 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("preserves standard refusal content as ordinary assistant text", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_refusal", content: [] }, + }, + { + type: "response.content_part.added", + item_id: "msg_refusal", + output_index: 0, + content_index: 0, + part: { type: "refusal", refusal: "" }, + }, + { + type: "response.refusal.delta", + item_id: "msg_refusal", + output_index: 0, + content_index: 0, + delta: "I can't", + }, + { + type: "response.refusal.delta", + item_id: "msg_refusal", + output_index: 0, + content_index: 0, + delta: " help with that.", + }, + { + type: "response.refusal.done", + item_id: "msg_refusal", + output_index: 0, + content_index: 0, + refusal: "I can't help with that.", + }, + { + type: "response.content_part.done", + item_id: "msg_refusal", + output_index: 0, + content_index: 0, + part: { type: "refusal", refusal: "I can't help with that." }, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_refusal", + phase: "final_answer", + content: [{ type: "refusal", refusal: "I can't help with that." }], + }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.text).toBe("I can't help with that.") + expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined }) + expect(response.message.content).toEqual([ + { + type: "text", + text: "I can't help with that.", + providerMetadata: { openai: { phase: "final_answer" } }, + }, + ]) + + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] })) + expect(prepared.body.input).toEqual([ + { + role: "assistant", + content: [{ type: "output_text", text: "I can't help with that." }], + phase: "final_answer", + }, + ]) + }), + ) + + it.effect("rejects malformed refusal events", () => + Effect.gen(function* () { + const events = [ + { type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" }, + { type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 }, + { type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 }, + ] + for (const event of events) { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(event))), + Effect.flip, + ) + expect(error.reason._tag).toBe("InvalidProviderOutput") + } + }), + ) + it.effect("preserves and replays assistant message phases", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe(