Merge pull request #1285 from can1357/farm/d8e3cc99/openai-responses-send-system-prompts-via
fix(openai-responses): use canonical instructions field for non-developer-role endpoints
This commit is contained in:
@@ -391,10 +391,24 @@ function buildParams(
|
||||
const messages: ResponseInput = [...conversationMessages];
|
||||
|
||||
const systemPrompts = normalizeSystemPrompts(context.systemPrompt);
|
||||
let systemInstructions: string | undefined;
|
||||
if (systemPrompts.length > 0) {
|
||||
const role: "developer" | "system" =
|
||||
model.reasoning && supportsDeveloperRole(resolvedBaseUrl ?? model) ? "developer" : "system";
|
||||
messages.unshift(...systemPrompts.map(systemPrompt => ({ role, content: systemPrompt })));
|
||||
const needsDeveloperRole = model.reasoning && supportsDeveloperRole(resolvedBaseUrl ?? model);
|
||||
if (needsDeveloperRole) {
|
||||
// Reasoning models on known OpenAI-compatible endpoints require the
|
||||
// `developer` role. Send all system prompts inline in `input`.
|
||||
messages.unshift(
|
||||
...systemPrompts.map(systemPrompt => ({ role: "developer" as const, content: systemPrompt })),
|
||||
);
|
||||
} else {
|
||||
// All other endpoints (including third-party /v1/responses proxies) use
|
||||
// the canonical top-level `instructions` field so that proxies that
|
||||
// reject `input[{role:"system"}]` work out of the box.
|
||||
systemInstructions = systemPrompts[0];
|
||||
if (systemPrompts.length > 1) {
|
||||
messages.unshift(...systemPrompts.slice(1).map(p => ({ role: "system" as const, content: p })));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
|
||||
@@ -402,6 +416,7 @@ function buildParams(
|
||||
const params: OpenAIResponsesSamplingParams = {
|
||||
model: model.id,
|
||||
input: messages,
|
||||
instructions: systemInstructions,
|
||||
stream: true,
|
||||
prompt_cache_key: promptCacheKey,
|
||||
prompt_cache_retention: promptCacheKey ? getPromptCacheRetention(model.baseUrl, cacheRetention) : undefined,
|
||||
|
||||
@@ -322,7 +322,7 @@ describe("OpenAI responses history payload", () => {
|
||||
expect(payload.prompt_cache_key).toBe("session-abc");
|
||||
});
|
||||
|
||||
it("falls back to system instructions for OpenAI-compatible endpoints without developer-role support", async () => {
|
||||
it("uses canonical instructions field for endpoints without developer-role support", async () => {
|
||||
const model = {
|
||||
...getOpenAIReasoningModel("openai", "gpt-5-mini"),
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
@@ -330,10 +330,10 @@ describe("OpenAI responses history payload", () => {
|
||||
const payload = (await captureResponsesPayload(model, {
|
||||
systemPrompt: ["stable instructions", "second instructions"],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
})) as { input?: unknown[] };
|
||||
})) as { input?: unknown[]; instructions?: string };
|
||||
|
||||
expect(payload.instructions).toBe("stable instructions");
|
||||
expect(payload.input).toEqual([
|
||||
{ role: "system", content: "stable instructions" },
|
||||
{ role: "system", content: "second instructions" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "hi" }] },
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { getBundledModel } from "../src/models";
|
||||
import { streamOpenAIResponses } from "../src/providers/openai-responses";
|
||||
import type { Context, Model } from "../src/types";
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
// Non-reasoning model on api.openai.com (canonical path)
|
||||
const gpt4oMiniModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-responses">;
|
||||
// Reasoning model on api.openai.com (developer-role path)
|
||||
const o4MiniModel = getBundledModel("openai", "o4-mini") as Model<"openai-responses">;
|
||||
|
||||
function createSseResponse(): Response {
|
||||
const events = [
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
||||
},
|
||||
{ type: "response.content_part.added", part: { type: "output_text", text: "" } },
|
||||
{ type: "response.output_text.delta", delta: "Hi" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Hi" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
status: "completed",
|
||||
usage: { input_tokens: 3, output_tokens: 1, total_tokens: 4, input_tokens_details: { cached_tokens: 0 } },
|
||||
},
|
||||
},
|
||||
];
|
||||
const payload = `${events.map(e => `data: ${JSON.stringify(e)}`).join("\n\n")}\n\n`;
|
||||
return new Response(payload, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
async function captureRequestBody(
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
): Promise<Record<string, unknown>> {
|
||||
let captured: Record<string, unknown> = {};
|
||||
const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
captured = typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : {};
|
||||
return createSseResponse();
|
||||
});
|
||||
global.fetch = Object.assign(fetchMock, { preconnect: originalFetch.preconnect }) as typeof fetch;
|
||||
|
||||
const stream = streamOpenAIResponses(model, context, { apiKey: "test-key" });
|
||||
for await (const event of stream) {
|
||||
if (event.type === "done" || event.type === "error") break;
|
||||
}
|
||||
return captured;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("openai-responses system prompt routing", () => {
|
||||
describe("non-reasoning model (canonical instructions field)", () => {
|
||||
it("sends single system prompt as top-level instructions", async () => {
|
||||
const context: Context = {
|
||||
systemPrompt: ["You are a helpful assistant."],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const body = await captureRequestBody(gpt4oMiniModel, context);
|
||||
|
||||
expect(body.instructions).toBe("You are a helpful assistant.");
|
||||
const input = body.input as Array<{ role: string }>;
|
||||
expect(input.every(m => m.role !== "system")).toBe(true);
|
||||
});
|
||||
|
||||
it("sends first of multiple system prompts as instructions, extras as input[role=system]", async () => {
|
||||
const context: Context = {
|
||||
systemPrompt: ["Primary prompt.", "Secondary prompt."],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const body = await captureRequestBody(gpt4oMiniModel, context);
|
||||
|
||||
expect(body.instructions).toBe("Primary prompt.");
|
||||
const input = body.input as Array<{ role: string; content: string }>;
|
||||
const systemMessages = input.filter(m => m.role === "system");
|
||||
expect(systemMessages).toEqual([{ role: "system", content: "Secondary prompt." }]);
|
||||
});
|
||||
|
||||
it("omits instructions field when there is no system prompt", async () => {
|
||||
const context: Context = {
|
||||
systemPrompt: undefined,
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const body = await captureRequestBody(gpt4oMiniModel, context);
|
||||
|
||||
expect(body.instructions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses instructions for custom proxy base URL (third-party /v1/responses compatibility)", async () => {
|
||||
const proxyModel: Model<"openai-responses"> = {
|
||||
...gpt4oMiniModel,
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
};
|
||||
const context: Context = {
|
||||
systemPrompt: ["You are a proxy assistant."],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const body = await captureRequestBody(proxyModel, context);
|
||||
|
||||
expect(body.instructions).toBe("You are a proxy assistant.");
|
||||
const input = body.input as Array<{ role: string }>;
|
||||
expect(input.every(m => m.role !== "system")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoning model on known OpenAI endpoints (developer role)", () => {
|
||||
it("sends all system prompts as input[role=developer] for api.openai.com", async () => {
|
||||
const context: Context = {
|
||||
systemPrompt: ["Developer prompt."],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const body = await captureRequestBody(o4MiniModel, context);
|
||||
|
||||
expect(body.instructions).toBeUndefined();
|
||||
const input = body.input as Array<{ role: string; content: string }>;
|
||||
const devMessages = input.filter(m => m.role === "developer");
|
||||
expect(devMessages).toEqual([{ role: "developer", content: "Developer prompt." }]);
|
||||
});
|
||||
|
||||
it("sends multiple system prompts as input[role=developer] for api.openai.com", async () => {
|
||||
const context: Context = {
|
||||
systemPrompt: ["First.", "Second."],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const body = await captureRequestBody(o4MiniModel, context);
|
||||
|
||||
expect(body.instructions).toBeUndefined();
|
||||
const input = body.input as Array<{ role: string; content: string }>;
|
||||
const devMessages = input.filter(m => m.role === "developer");
|
||||
expect(devMessages).toEqual([
|
||||
{ role: "developer", content: "First." },
|
||||
{ role: "developer", content: "Second." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoning model on custom proxy (instructions path)", () => {
|
||||
it("uses instructions for reasoning model on non-official endpoint", async () => {
|
||||
const proxyModel: Model<"openai-responses"> = {
|
||||
...o4MiniModel,
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
};
|
||||
const context: Context = {
|
||||
systemPrompt: ["Proxy reasoning prompt."],
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
};
|
||||
const body = await captureRequestBody(proxyModel, context);
|
||||
|
||||
expect(body.instructions).toBe("Proxy reasoning prompt.");
|
||||
const input = body.input as Array<{ role: string }>;
|
||||
expect(input.every(m => m.role !== "developer" && m.role !== "system")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user