Files
oh-my-pi/packages/ai/test/azure-openai-responses-stream.test.ts
2026-07-25 20:47:33 +00:00

432 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "bun:test";
import {
type AzureOpenAIResponsesOptions,
streamAzureOpenAIResponses,
} from "@oh-my-pi/pi-ai/providers/azure-openai-responses";
import type { Context, FetchImpl, Model, ModelSpec, Tool } from "@oh-my-pi/pi-ai/types";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
const azureModel: Model<"azure-openai-responses"> = buildModel({
id: "gpt-5-mini",
name: "GPT-5 Mini",
api: "azure-openai-responses",
provider: "azure",
baseUrl: "https://example.openai.azure.com/openai/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400000,
maxTokens: 128000,
});
function createAbortedSignal(): AbortSignal {
const controller = new AbortController();
controller.abort();
return controller.signal;
}
function createSseResponse(events: unknown[]): Response {
const sse = `${events.map(event => `data: ${JSON.stringify(event)}`).join("\n\n")}\n\n`;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(sse));
controller.close();
},
});
return new Response(stream, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
function createAssistantMessage(text: string, textSignature?: string) {
return {
role: "assistant" as const,
content: [{ type: "text" as const, text, ...(textSignature ? { textSignature } : {}) }],
api: "azure-openai-responses" as const,
provider: "azure" as const,
model: "gpt-5-mini",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
}
async function captureAzurePayload(
context: Context,
model: Model<"azure-openai-responses"> = azureModel,
options: Partial<AzureOpenAIResponsesOptions> = {},
): Promise<Record<string, unknown>> {
const { promise, resolve } = Promise.withResolvers<Record<string, unknown>>();
streamAzureOpenAIResponses(model, context, {
apiKey: "test-key",
azureBaseUrl: model.baseUrl,
azureApiVersion: "v1",
...options,
signal: createAbortedSignal(),
onPayload: payload => resolve(payload as Record<string, unknown>),
});
return promise;
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("azure openai responses streaming", () => {
it("serializes each system prompt as an Azure Responses system input item for non-reasoning models", async () => {
const payload = await captureAzurePayload({
systemPrompt: ["First instruction", "", "Second instruction"],
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
});
expect(payload.input).toEqual([
{ role: "system", content: "First instruction" },
{ role: "system", content: "Second instruction" },
{ role: "user", content: [{ type: "input_text", text: "Say hello" }] },
]);
});
it("sends an async onPayload replacement body", async () => {
let capturedBody: Record<string, unknown> | undefined;
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
capturedBody = typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : undefined;
return createSseResponse([
{
type: "response.completed",
response: {
status: "completed",
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
input_tokens_details: { cached_tokens: 0 },
},
},
},
]);
});
const result = await streamAzureOpenAIResponses(
azureModel,
{ messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }] },
{
apiKey: "test-key",
fetch: fetchMock as unknown as typeof fetch,
azureBaseUrl: azureModel.baseUrl,
azureApiVersion: "v1",
onPayload: async payload => ({
...(payload as Record<string, unknown>),
input: [{ role: "user", content: [{ type: "input_text", text: "replacement" }] }],
}),
},
).result();
expect(result.stopReason).toBe("stop");
expect(capturedBody?.input).toEqual([{ role: "user", content: [{ type: "input_text", text: "replacement" }] }]);
});
it("uses developer role for Azure Responses reasoning model system prompts", async () => {
const reasoningModel: Model<"azure-openai-responses"> = buildModel({
...azureModel,
reasoning: true,
compat: azureModel.compatConfig,
} as ModelSpec<"azure-openai-responses">);
const payload = await captureAzurePayload(
{
systemPrompt: ["Reasoning instruction", "Second instruction"],
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
},
reasoningModel,
);
expect(payload.input).toEqual([
{ role: "developer", content: "Reasoning instruction" },
{ role: "developer", content: "Second instruction" },
{ role: "user", content: [{ type: "input_text", text: "Say hello" }] },
]);
});
it("keeps Azure Responses prompt_cache_key separate from Anthropic cache controls", async () => {
const payload = await captureAzurePayload(
{
systemPrompt: ["Cache-stable instruction"],
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
},
azureModel,
{ sessionId: "azure-session" },
);
expect(payload.prompt_cache_key).toBe("azure-session");
expect(payload.prompt_cache_retention).toBeUndefined();
expect(payload.cache_control).toBeUndefined();
});
it("rewrites oneOf tool schemas to anyOf for Azure Responses", async () => {
const tool: Tool = {
name: "choose",
description: "choose a branch",
parameters: {
type: "object",
properties: {
item: {
oneOf: [
{
type: "object",
properties: { kind: { const: "a" }, value: { type: "string" } },
required: ["kind", "value"],
additionalProperties: false,
},
{
type: "object",
properties: { kind: { const: "b" }, count: { type: "integer" } },
required: ["kind", "count"],
additionalProperties: false,
},
],
},
},
required: ["item"],
},
};
const payload = await captureAzurePayload({
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
tools: [tool],
});
const tools = payload.tools as Array<{ parameters: { properties: { item: Record<string, unknown> } } }>;
expect(tools[0].parameters.properties.item.oneOf).toBeUndefined();
expect(Array.isArray(tools[0].parameters.properties.item.anyOf)).toBe(true);
});
it("serializes computer and its forced choice as a function on unsupported models", async () => {
const computer: Tool = {
name: "computer",
description: "Control the desktop",
parameters: { type: "object", properties: {} },
native: { type: "computer" },
};
const read: Tool = {
name: "read_file",
description: "Read a file",
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
};
const payload = await captureAzurePayload(
{
messages: [{ role: "user", content: "Inspect", timestamp: Date.now() }],
tools: [computer, read],
},
azureModel,
{ toolChoice: { type: "computer" } },
);
expect(payload.tools).toEqual([
expect.objectContaining({ type: "function", name: "computer" }),
expect.objectContaining({ type: "function", name: "read_file" }),
]);
expect(JSON.stringify(payload.tools)).not.toContain('{"type":"computer"}');
expect(payload.tool_choice).toEqual({ type: "function", name: "computer" });
});
it("serializes native GA computer and forced choice for a supported GPT-5.4 Azure model", async () => {
const supportedModel: Model<"azure-openai-responses"> = buildModel({
id: "gpt-5.4",
name: "GPT-5.4",
api: "azure-openai-responses",
provider: "azure",
baseUrl: azureModel.baseUrl,
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400_000,
maxTokens: 128_000,
});
const computer: Tool = {
name: "computer",
description: "Control the desktop",
parameters: { type: "object", properties: {} },
native: { type: "computer" },
};
const nativeItem = {
type: "message" as const,
role: "user" as const,
content: [
{ type: "input_text" as const, text: "Inspect" },
{ type: "input_image" as const, file_id: "file_azure_screen_雪", detail: "auto" as const },
{ type: "input_file" as const, file_id: "file_azure_context_电脑" },
],
};
const payload = await captureAzurePayload(
{
messages: [
{
role: "user",
content: "Inspect",
providerPayload: { type: "openaiResponsesHistory", items: [nativeItem], dt: true },
timestamp: Date.now(),
},
],
tools: [computer],
},
supportedModel,
{
toolChoice: { type: "function", name: "computer" },
include: ["computer_call_output.output.image_url", "reasoning.encrypted_content"],
},
);
expect(supportedModel.supportsComputerUse).toBe(true);
expect(payload.tools).toEqual([{ type: "computer" }]);
expect(payload.tool_choice).toEqual({ type: "computer" });
expect(payload.input).toEqual([nativeItem]);
expect(payload.include).toEqual(["computer_call_output.output.image_url", "reasoning.encrypted_content"]);
expect(JSON.stringify(payload)).not.toContain("display_width");
expect(JSON.stringify(payload)).not.toContain("display_height");
const gatewayPayload = await captureAzurePayload(
{
messages: [{ role: "user", content: "Inspect", timestamp: Date.now() }],
tools: [computer],
},
supportedModel,
{
azureBaseUrl: "https://gateway.example/openai/v1",
toolChoice: { type: "function", name: "computer" },
},
);
expect(gatewayPayload.tools).toMatchObject([
{
type: "function",
name: "computer",
description: "Control the desktop",
parameters: { type: "object", properties: {} },
strict: false,
},
]);
expect(gatewayPayload.tool_choice).toEqual({ type: "function", name: "computer" });
});
it("surfaces nested response.failed provider errors", async () => {
const fetchMock: FetchImpl = vi.fn(async () =>
createSseResponse([
{
type: "response.failed",
response: {
error: { code: "server_error", message: "backend exploded" },
},
},
]),
);
const result = await streamAzureOpenAIResponses(
azureModel,
{ messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }] },
{ apiKey: "test-key", azureBaseUrl: azureModel.baseUrl, azureApiVersion: "v1", fetch: fetchMock },
).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("server_error: backend exploded");
});
it("surfaces response.failed incomplete reasons", async () => {
const fetchMock: FetchImpl = vi.fn(async () =>
createSseResponse([
{
type: "response.failed",
response: {
incomplete_details: { reason: "max_output_tokens" },
},
},
]),
);
const result = await streamAzureOpenAIResponses(
azureModel,
{ messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }] },
{ apiKey: "test-key", azureBaseUrl: azureModel.baseUrl, azureApiVersion: "v1", fetch: fetchMock },
).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("incomplete: max_output_tokens");
});
it("surfaces response.completed failed status_details errors", async () => {
const fetchMock: FetchImpl = vi.fn(async () =>
createSseResponse([
{
type: "response.completed",
response: {
status: "failed",
status_details: {
error: { code: "server_error", message: "backend exploded late" },
},
},
},
]),
);
const result = await streamAzureOpenAIResponses(
azureModel,
{ messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }] },
{ apiKey: "test-key", azureBaseUrl: azureModel.baseUrl, azureApiVersion: "v1", fetch: fetchMock },
).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("server_error: backend exploded late");
});
it("preserves assistant message phase when rebuilding fallback replay history", async () => {
const payload = await captureAzurePayload({
messages: [
{ role: "user", content: "first user", timestamp: Date.now() },
createAssistantMessage(
"Commentary answer",
JSON.stringify({ v: 1, id: "msg_commentary", phase: "final_answer" }),
),
{ role: "user", content: "follow-up", timestamp: Date.now() },
],
});
expect(payload.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "first user" }] },
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Commentary answer", annotations: [] }],
status: "completed",
id: "msg_commentary",
phase: "final_answer",
},
{ role: "user", content: [{ type: "input_text", text: "follow-up" }] },
]);
});
it("keeps legacy plain-string text signatures when rebuilding fallback replay history", async () => {
const payload = await captureAzurePayload({
messages: [
{ role: "user", content: "first user", timestamp: Date.now() },
createAssistantMessage("Legacy answer", "msg_legacy"),
{ role: "user", content: "follow-up", timestamp: Date.now() },
],
});
expect(payload.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "first user" }] },
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Legacy answer", annotations: [] }],
status: "completed",
id: "msg_legacy",
},
{ role: "user", content: [{ type: "input_text", text: "follow-up" }] },
]);
});
});