diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 4358bec1c..ea7853346 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -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, diff --git a/packages/ai/test/openai-responses-history-payload.test.ts b/packages/ai/test/openai-responses-history-payload.test.ts index 78a59f19e..2870ba583 100644 --- a/packages/ai/test/openai-responses-history-payload.test.ts +++ b/packages/ai/test/openai-responses-history-payload.test.ts @@ -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" }] }, ]); diff --git a/packages/ai/test/openai-responses-system-prompt.test.ts b/packages/ai/test/openai-responses-system-prompt.test.ts new file mode 100644 index 000000000..bc17507f9 --- /dev/null +++ b/packages/ai/test/openai-responses-system-prompt.test.ts @@ -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> { + let captured: Record = {}; + const fetchMock = vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + captured = typeof init?.body === "string" ? (JSON.parse(init.body) as Record) : {}; + 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); + }); + }); +});