fix(azure): support Responses text stream events

Co-authored-by: thomas.krohnfuss <thomas.krohnfuss@hsu.hamburg>
This commit is contained in:
Vincent Koc
2026-06-09 16:18:19 +09:00
parent d12b7b0551
commit b08e1109c6
4 changed files with 444 additions and 11 deletions
+68 -1
View File
@@ -25,7 +25,12 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"
type OpenAICompletionsOutput = Parameters<typeof testing.processOpenAICompletionsStream>[1];
type OpenAIResponsesOutput = Parameters<typeof testing.processResponsesStream>[1];
type CapturedStreamEvent = { type?: string; delta?: string; partial?: unknown };
type CapturedStreamEvent = {
type?: string;
delta?: string;
content?: string;
partial?: unknown;
};
function createDeepSeekCompletionsModel(): Model<"openai-completions"> {
return {
@@ -1967,6 +1972,68 @@ describe("openai transport stream", () => {
expect(output.content).toEqual([{ type: "text", text: "ab" }]);
});
it("handles Azure Responses text content and text delta events", async () => {
const model = createAzureResponsesModel();
const output = createResponsesAssistantOutput(model);
const events: CapturedStreamEvent[] = [];
await testing.processResponsesStream(
streamChunks([
{
type: "response.output_item.added",
item: {
type: "message",
role: "assistant",
id: "msg_azure_text",
content: [],
status: "in_progress",
},
},
{ type: "response.text.delta", delta: "Hello" },
{ type: "response.text.delta", delta: " from Azure!" },
{
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: "msg_azure_text",
content: [{ type: "text", text: "Hello from Azure!" }],
status: "completed",
},
},
{
type: "response.completed",
response: {
id: "resp_azure_text",
status: "completed",
usage: {
input_tokens: 4,
output_tokens: 3,
total_tokens: 7,
},
},
},
]),
output,
{ push: (event) => events.push(event as CapturedStreamEvent) },
model,
);
expect(events).toMatchObject([
{ type: "text_start" },
{ type: "text_delta", delta: "Hello" },
{ type: "text_delta", delta: " from Azure!" },
{ type: "text_end", content: "Hello from Azure!" },
]);
expect(output.content).toMatchObject([{ type: "text", text: "Hello from Azure!" }]);
expectRecordFields(output.usage, {
input: 4,
output: 3,
totalTokens: 7,
});
expect(output.responseId).toBe("resp_azure_text");
});
it("skips null and non-object OpenAI-compatible stream chunks", async () => {
const model = {
id: "glm-5",
+6 -2
View File
@@ -1571,7 +1571,11 @@ async function processResponsesStream(
partial: output,
});
}
} else if (type === "response.output_text.delta" || type === "response.refusal.delta") {
} else if (
type === "response.output_text.delta" ||
type === "response.text.delta" ||
type === "response.refusal.delta"
) {
if (currentItem?.type === "message" && currentBlock?.type === "text") {
currentBlock.text = `${stringifyUnknown(currentBlock.text)}${stringifyUnknown(event.delta)}`;
stream.push({
@@ -1623,7 +1627,7 @@ async function processResponsesStream(
currentBlock.text = content
.map((part) => {
const contentPart = part as { type?: string; text?: string; refusal?: string };
return contentPart.type === "output_text"
return contentPart.type === "output_text" || contentPart.type === "text"
? (contentPart.text ?? "")
: (contentPart.refusal ?? "");
})
@@ -1,13 +1,40 @@
// OpenAI Responses shared tests cover tool conversion and response item mapping.
import type { Tool as OpenAIResponsesTool } from "openai/resources/responses/responses.js";
import { describe, expect, it } from "vitest";
import type { AssistantMessage, Context, Model, Tool } from "../types.js";
import type { AssistantMessage, AssistantMessageEvent, Context, Model, Tool } from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { convertResponsesMessages, processResponsesStream } from "./openai-responses-shared.js";
import {
createResponsesAssistantOutput,
convertResponsesMessages,
type OpenAIResponsesStreamEvent,
processResponsesStream,
} from "./openai-responses-shared.js";
import { convertResponsesTools } from "./openai-responses-tools.js";
type ResponsesFunctionTool = Extract<OpenAIResponsesTool, { type: "function" }>;
async function* streamResponsesEvents(
events: readonly OpenAIResponsesStreamEvent[],
): AsyncGenerator<OpenAIResponsesStreamEvent> {
for (const event of events) {
yield event;
}
}
function createCapturedAssistantMessageEventStream(): {
stream: AssistantMessageEventStream;
events: AssistantMessageEvent[];
} {
const stream = new AssistantMessageEventStream();
const events: AssistantMessageEvent[] = [];
const push = stream.push.bind(stream);
stream.push = (event) => {
events.push(event);
push(event);
};
return { stream, events };
}
function expectResponsesFunctionTool(tool: OpenAIResponsesTool | undefined): ResponsesFunctionTool {
expect(tool).toHaveProperty("type", "function");
return tool as ResponsesFunctionTool;
@@ -481,3 +508,284 @@ describe("processResponsesStream", () => {
]);
});
});
describe("Azure OpenAI Responses content type support", () => {
const azureModel = {
id: "gpt-5.5",
name: "GPT-5.5 (Azure)",
api: "azure-openai-responses",
provider: "azure",
baseUrl: "https://test.openai.azure.com/openai/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"azure-openai-responses">;
it("supports Azure 'text' content type in addition to 'output_text'", () => {
const input = convertResponsesMessages(
azureModel,
{
systemPrompt: "system",
messages: [
{
role: "assistant",
api: azureModel.api,
provider: azureModel.provider,
model: azureModel.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
content: [
{
type: "text",
text: "Azure response with text content type",
textSignature: JSON.stringify({
v: 1,
id: "msg_azure_text",
}),
},
],
},
],
} satisfies Context,
new Set(["azure", "azure-openai-responses"]),
{ includeSystemPrompt: false },
);
const assistantMessage = input.find(
(item) => item && typeof item === "object" && "role" in item && item.role === "assistant",
);
expect(assistantMessage).toMatchObject({
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: "Azure response with text content type",
annotations: [],
},
],
});
});
it("processResponsesStream handles Azure 'text' content type streaming events", async () => {
const azureEvents: OpenAIResponsesStreamEvent[] = [
{
type: "response.output_item.added",
output_index: 0,
sequence_number: 1,
item: {
type: "message",
role: "assistant",
id: "msg_azure_1",
content: [],
status: "in_progress",
},
},
{
type: "response.content_part.added",
content_index: 0,
item_id: "msg_azure_1",
output_index: 0,
sequence_number: 2,
part: {
type: "text",
text: "",
},
},
{
type: "response.text.delta",
delta: "Hello",
},
{
type: "response.text.delta",
delta: " from",
},
{
type: "response.text.delta",
delta: " Azure!",
},
{
type: "response.output_item.done",
output_index: 0,
sequence_number: 6,
item: {
type: "message",
role: "assistant",
id: "msg_azure_1",
content: [
{
type: "text",
text: "Hello from Azure!",
},
],
status: "completed",
},
},
{
type: "response.completed",
sequence_number: 7,
response: {
id: "resp_azure_123",
created_at: 1,
output_text: "Hello from Azure!",
error: null,
incomplete_details: null,
instructions: null,
metadata: null,
model: azureModel.id,
object: "response",
output: [],
parallel_tool_calls: false,
temperature: null,
tool_choice: "auto",
tools: [],
top_p: null,
status: "completed",
usage: {
input_tokens: 10,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 5,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 15,
},
},
},
];
const { stream, events } = createCapturedAssistantMessageEventStream();
const output = createResponsesAssistantOutput(azureModel, "azure-openai-responses");
await processResponsesStream(streamResponsesEvents(azureEvents), output, stream, azureModel);
expect(
events.map((event) =>
event.type === "text_delta"
? { type: event.type, delta: event.delta }
: event.type === "text_end"
? { type: event.type, content: event.content }
: { type: event.type },
),
).toEqual([
{ type: "text_start" },
{ type: "text_delta", delta: "Hello" },
{ type: "text_delta", delta: " from" },
{ type: "text_delta", delta: " Azure!" },
{ type: "text_end", content: "Hello from Azure!" },
]);
expect(output.content).toHaveLength(1);
expect(output.content[0]).toMatchObject({
type: "text",
text: "Hello from Azure!",
});
expect(output.usage).toMatchObject({
input: 10,
output: 5,
totalTokens: 15,
});
expect(output.stopReason).toBe("stop");
});
it("processResponsesStream handles Azure text deltas without a content_part.added event", async () => {
const azureEvents: OpenAIResponsesStreamEvent[] = [
{
type: "response.output_item.added",
output_index: 0,
sequence_number: 1,
item: {
type: "message",
role: "assistant",
id: "msg_azure_without_part",
content: [],
status: "in_progress",
},
},
{
type: "response.text.delta",
delta: "No explicit",
},
{
type: "response.text.delta",
delta: " part",
},
{
type: "response.output_item.done",
output_index: 0,
sequence_number: 4,
item: {
type: "message",
role: "assistant",
id: "msg_azure_without_part",
content: [
{
type: "text",
text: "No explicit part",
},
],
status: "completed",
},
},
{
type: "response.completed",
sequence_number: 5,
response: {
id: "resp_azure_without_part",
created_at: 1,
output_text: "No explicit part",
error: null,
incomplete_details: null,
instructions: null,
metadata: null,
model: azureModel.id,
object: "response",
output: [],
parallel_tool_calls: false,
temperature: null,
tool_choice: "auto",
tools: [],
top_p: null,
status: "completed",
usage: {
input_tokens: 3,
input_tokens_details: { cached_tokens: 0 },
output_tokens: 3,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: 6,
},
},
},
];
const { stream, events } = createCapturedAssistantMessageEventStream();
const output = createResponsesAssistantOutput(azureModel, "azure-openai-responses");
await processResponsesStream(streamResponsesEvents(azureEvents), output, stream, azureModel);
expect(
events.map((event) =>
event.type === "text_delta"
? event.delta
: event.type === "text_end"
? `[END:${event.content}]`
: event.type,
),
).toEqual(["text_start", "No explicit", " part", "[END:No explicit part]"]);
expect(output.content[0]).toMatchObject({
type: "text",
text: "No explicit part",
});
});
});
+60 -6
View File
@@ -43,6 +43,33 @@ import { transformMessages } from "./transform-messages.js";
type ReplayableResponseOutputMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
type ReplayableResponseReasoningItem = Omit<ResponseReasoningItem, "id"> & { id?: string };
type AzureResponsesTextContentPart = { type: "text"; text: string };
type ResponsesTextContentPart =
| ResponseOutputMessage["content"][number]
| AzureResponsesTextContentPart;
type ResponsesStreamOutputMessage = Omit<ResponseOutputMessage, "content"> & {
content: ResponsesTextContentPart[];
};
type ResponsesContentPartAddedEvent = Extract<
ResponseStreamEvent,
{ type: "response.content_part.added" }
>;
type ResponsesOutputItemDoneEvent = Extract<
ResponseStreamEvent,
{ type: "response.output_item.done" }
>;
type AzureResponsesContentPartAddedEvent = Omit<ResponsesContentPartAddedEvent, "part"> & {
part: AzureResponsesTextContentPart;
};
type AzureResponsesOutputItemDoneEvent = Omit<ResponsesOutputItemDoneEvent, "item"> & {
item: ResponsesStreamOutputMessage;
};
export type OpenAIResponsesStreamEvent =
| ResponseStreamEvent
| AzureResponsesContentPartAddedEvent
| AzureResponsesOutputItemDoneEvent
| { type: "response.text.delta"; delta: string };
function normalizeResponsesReasoningReplayItem(params: {
item: ReplayableResponseReasoningItem;
@@ -526,14 +553,17 @@ export async function runResponsesStreamLifecycle<TApi extends Api>(params: {
// =============================================================================
export async function processResponsesStream<TApi extends Api>(
openaiStream: AsyncIterable<ResponseStreamEvent>,
openaiStream: AsyncIterable<OpenAIResponsesStreamEvent>,
output: AssistantMessage,
stream: AssistantMessageEventStream,
model: Model<TApi>,
options?: OpenAIResponsesStreamOptions,
): Promise<void> {
let currentItem: ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | null =
null;
let currentItem:
| ResponseReasoningItem
| ResponsesStreamOutputMessage
| ResponseFunctionToolCall
| null = null;
let currentBlock: ThinkingContent | TextContent | (ToolCall & { partialJson: string }) | null =
null;
const blocks = output.content;
@@ -614,8 +644,13 @@ export async function processResponsesStream<TApi extends Api>(
} else if (event.type === "response.content_part.added") {
if (currentItem?.type === "message") {
currentItem.content = currentItem.content || [];
// Filter out ReasoningText, only accept output_text and refusal
if (event.part.type === "output_text" || event.part.type === "refusal") {
// Accept output_text, text (Azure), and refusal content parts
// Azure OpenAI Responses may return "text" instead of "output_text"
if (
event.part.type === "output_text" ||
event.part.type === "text" ||
event.part.type === "refusal"
) {
currentItem.content.push(event.part);
}
}
@@ -636,6 +671,24 @@ export async function processResponsesStream<TApi extends Api>(
});
}
}
} else if (event.type === "response.text.delta") {
// Azure OpenAI Responses may emit "text" events instead of "output_text"
if (currentItem?.type === "message" && currentBlock?.type === "text") {
currentItem.content = currentItem.content || [];
let lastPart = currentItem.content[currentItem.content.length - 1];
if (lastPart?.type !== "text") {
lastPart = { type: "text", text: "" };
currentItem.content.push(lastPart);
}
currentBlock.text += event.delta;
lastPart.text += event.delta;
stream.push({
type: "text_delta",
contentIndex: blockIndex(),
delta: event.delta,
partial: output,
});
}
} else if (event.type === "response.refusal.delta") {
if (currentItem?.type === "message" && currentBlock?.type === "text") {
if (!currentItem.content || currentItem.content.length === 0) {
@@ -705,8 +758,9 @@ export async function processResponsesStream<TApi extends Api>(
});
currentBlock = null;
} else if (item.type === "message" && currentBlock?.type === "text") {
// Support both OpenAI "output_text" and Azure "text" content types
currentBlock.text = item.content
.map((c) => (c.type === "output_text" ? c.text : c.refusal))
.map((c) => (c.type === "output_text" || c.type === "text" ? c.text : c.refusal))
.join("");
currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
stream.push({