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:
Can Bölük
2026-05-25 13:36:16 +03:00
committed by GitHub
3 changed files with 189 additions and 6 deletions
+18 -3
View File
@@ -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);
});
});
});