Compare commits

...
3 Commits
Author SHA1 Message Date
Aiden Cline 2254e5be54 fix(ai): replay refusals as text 2026-08-19 00:10:53 -05:00
Aiden Cline e33e7d8837 refactor(ai): validate refusal replay state 2026-08-18 23:14:26 -05:00
Aiden Cline 7ad9ccf5e6 feat(ai): preserve streamed refusals 2026-08-18 22:59:17 -05:00
5 changed files with 289 additions and 11 deletions
+58 -9
View File
@@ -254,7 +254,10 @@ export const Event = Schema.StructWithRest(
type: Schema.String,
delta: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
refusal: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
content_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -688,6 +691,35 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
const refusalID = (event: Event) => `refusal:${event.item_id}:${event.content_index}`
const messageMetadata = (
state: ParserState,
itemID: string,
phase: MessagePhase | null | undefined = state.messagePhases[itemID],
) => (phase === undefined ? undefined : providerMetadata(state, { phase }))
const onRefusalDelta = (state: ParserState, event: Event): StepResult => {
if (!event.item_id || !event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const id = refusalID(event)
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, messageMetadata(state, event.item_id))
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
const onRefusalDone = (state: ParserState, event: Event): StepResult => {
if (!event.item_id) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const id = refusalID(event)
const metadata = messageMetadata(state, event.item_id)
const started =
state.lifecycle.text.has(id) || event.refusal === undefined
? state.lifecycle
: Lifecycle.textDelta(Lifecycle.textStart(state.lifecycle, events, id, metadata), events, id, event.refusal)
if (state.messageItems.has(event.item_id)) return [{ ...state, lifecycle: started }, events]
return [{ ...state, lifecycle: Lifecycle.textEnd(started, events, id, metadata) }, events]
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
@@ -890,21 +922,24 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemID = item.id
const itemPhase = state.messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const phase = itemPhase === undefined ? state.messagePhases[itemID] : itemPhase
const events: LLMEvent[] = []
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Array.from(state.lifecycle.text)
.filter((id) => id.startsWith(`refusal:${itemID}:`))
.reduce(
(lifecycle, id) => Lifecycle.textEnd(lifecycle, events, id, messageMetadata(state, itemID, phase)),
Lifecycle.textEnd(state.lifecycle, events, itemID, metadata),
)
const messageItems = new Set(state.messageItems)
messageItems.delete(item.id)
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
messageItems.delete(itemID)
const { [itemID]: _phase, ...messagePhases } = state.messagePhases
return [
{
...state,
lifecycle: Lifecycle.textEnd(
state.lifecycle,
events,
item.id,
phase === undefined ? undefined : providerMetadata(state, { phase }),
),
lifecycle,
messageItems,
messagePhases,
},
@@ -1026,6 +1061,20 @@ 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 (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.output_index === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing output_index`)
if (event.content_index === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing content_index`)
if (event.type === "response.refusal.delta" && event.delta === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing delta`)
if (event.type === "response.refusal.done" && event.refusal === undefined)
return ProviderShared.eventError(state.id, `${event.type} is missing refusal`)
return Effect.succeed(
event.type === "response.refusal.delta" ? onRefusalDelta(state, event) : onRefusalDone(state, event),
)
}
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))
+14 -2
View File
@@ -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<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.StructWithRest(
Schema.Struct({
content: optionalNull(Schema.String),
refusal: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
@@ -709,6 +710,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
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()) {
@@ -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
@@ -118,6 +118,53 @@ 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",
output_index: 0,
content_index: 0,
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 options from the Open Responses namespace", () =>
Effect.gen(function* () {
const model = configure({
@@ -1481,6 +1481,108 @@ 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 refusal events without standard content coordinates", () =>
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", content_index: 0, delta: "missing output" },
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, delta: "missing content" },
{ 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(