Files
pi_harness/packages/ai/test/openai-responses-copilot-provider.test.ts
T
Alexey Zaytsev eacaa130ab ai: OpenAI and Codex forced tool calls (#6588)
* feat(ai): support forced OpenAI Codex tool calls

* feat(ai): support OpenAI Responses tool choice
2026-07-13 09:48:33 +02:00

350 lines
9.7 KiB
TypeScript

import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest";
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
import { getModel } from "../src/compat.ts";
import type { Model } from "../src/types.ts";
type CapturedHeaders = Headers | string[][] | Record<string, string | readonly string[]> | undefined;
function getHeader(headers: CapturedHeaders, name: string): string | null {
if (!headers) return null;
if (headers instanceof Headers) return headers.get(name);
const lowerName = name.toLowerCase();
if (Array.isArray(headers)) {
const match = headers.find(([key]) => key?.toLowerCase() === lowerName);
return match?.[1] ?? null;
}
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lowerName) return typeof value === "string" ? value : value.join(", ");
}
return null;
}
async function captureOpenAIResponseHeaders(
options: Parameters<typeof streamOpenAIResponses>[2],
model: Model<"openai-responses"> = getModel("openai", "gpt-5.4"),
): Promise<{ sessionId: string | null; clientRequestId: string | null }> {
const captured = { sessionId: null as string | null, clientRequestId: null as string | null };
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
captured.sessionId = getHeader(init?.headers, "session_id");
captured.clientRequestId = getHeader(init?.headers, "x-client-request-id");
return new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
});
});
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test-key", ...options },
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
return captured;
}
describe("openai-responses provider defaults", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("omits reasoning when no reasoning is requested", async () => {
const model = getModel("github-copilot", "gpt-5-mini");
let capturedPayload: unknown;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{
apiKey: "test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
},
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
expect(capturedPayload).not.toBeNull();
expect(capturedPayload).not.toMatchObject({
reasoning: expect.anything(),
});
});
it("forwards required tool choice", async () => {
let capturedPayload: unknown;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
getModel("openai", "gpt-5.4"),
{
messages: [
{
role: "user",
content: "Do not call ping. Respond with text instead.",
timestamp: Date.now(),
},
],
tools: [
{
name: "ping",
description: "Ping",
parameters: Type.Object({ value: Type.String() }),
},
],
},
{
apiKey: "test-key",
toolChoice: "required",
onPayload: (payload) => {
capturedPayload = payload;
},
},
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
expect(capturedPayload).toMatchObject({
tool_choice: "required",
tools: [expect.objectContaining({ name: "ping" })],
});
});
it.each([
"gpt-5.1",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
] as const)("sends none reasoning effort for OpenAI %s when no reasoning is requested", async (modelId) => {
const model = getModel("openai", modelId);
let capturedPayload: unknown;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{
apiKey: "test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
},
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
expect(capturedPayload).toMatchObject({
reasoning: { effort: "none" },
});
});
it.each(["gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5-pro", "gpt-5.2-pro", "gpt-5.4-pro", "gpt-5.5-pro"] as const)(
"omits reasoning effort for OpenAI %s when off is unsupported",
async (modelId) => {
const model = getModel("openai", modelId);
let capturedPayload: unknown;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{
apiKey: "test-key",
onPayload: (payload) => {
capturedPayload = payload;
},
},
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
expect(capturedPayload).not.toMatchObject({
reasoning: expect.anything(),
});
},
);
it("sets cache-affinity headers for official OpenAI Responses requests with a sessionId", async () => {
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" });
expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" });
});
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
const sessionId = "x".repeat(67);
let capturedPayload: { prompt_cache_key?: string } | undefined;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("data: [DONE]\n\n", {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
getModel("openai", "gpt-5.4"),
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{
apiKey: "test-key",
sessionId,
onPayload: (payload) => {
capturedPayload = payload as { prompt_cache_key?: string };
},
},
);
for await (const event of stream) {
if (event.type === "done" || event.type === "error") break;
}
expect(capturedPayload?.prompt_cache_key).toBe("x".repeat(64));
});
it("sets cache-affinity headers for proxy OpenAI Responses requests with a sessionId", async () => {
const proxyModel: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "opencode",
baseUrl: "https://proxy.example.com/v1",
};
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel);
expect(captured).toEqual({ sessionId: "session-123", clientRequestId: "session-123" });
});
it("can omit the session_id header while preserving other cache-affinity headers", async () => {
const proxyModel: Model<"openai-responses"> = {
...getModel("openai", "gpt-5.4"),
provider: "opencode",
baseUrl: "https://proxy.example.com/v1",
compat: { sendSessionIdHeader: false },
};
const captured = await captureOpenAIResponseHeaders({ sessionId: "session-123" }, proxyModel);
expect(captured).toEqual({ sessionId: null, clientRequestId: "session-123" });
});
it("lets explicit headers override the default OpenAI cache-affinity headers", async () => {
const captured = await captureOpenAIResponseHeaders({
sessionId: "session-123",
headers: {
session_id: "override-session",
"x-client-request-id": "override-request",
},
});
expect(captured).toEqual({ sessionId: "override-session", clientRequestId: "override-request" });
});
it("omits OpenAI cache-affinity headers when cacheRetention is none", async () => {
const captured = await captureOpenAIResponseHeaders({ cacheRetention: "none", sessionId: "session-123" });
expect(captured).toEqual({ sessionId: null, clientRequestId: null });
});
it.each([
["gpt-5.4", "priority", 2],
["gpt-5.5", "priority", 2.5],
["gpt-5.5", "flex", 0.5],
] as const)("applies %s %s service-tier cost multiplier", async (modelId, serviceTier, multiplier) => {
const model = getModel("openai", modelId);
const tokenCount = 100_000;
const tokenScale = tokenCount / 1_000_000;
const sse = `${[
`data: ${JSON.stringify({
type: "response.completed",
response: {
status: "completed",
service_tier: serviceTier,
usage: {
input_tokens: tokenCount,
output_tokens: tokenCount,
total_tokens: tokenCount * 2,
input_tokens_details: { cached_tokens: 0 },
},
},
})}`,
].join("\n\n")}\n\n`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(sse, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "sys",
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
},
{ apiKey: "test-key", serviceTier },
);
const result = await stream.result();
expect(result.usage.cost.input).toBe(model.cost.input * multiplier * tokenScale);
expect(result.usage.cost.output).toBe(model.cost.output * multiplier * tokenScale);
expect(result.usage.cost.total).toBe((model.cost.input + model.cost.output) * multiplier * tokenScale);
});
});