fix: enable prompt enhancement for GPT-5 and Codex Mini models

- Implement completePromptViaResponsesApi to collect streaming responses
- Add SSE fallback for robustness when SDK fails
- Support all GPT-5 variants (gpt-5, gpt-5-mini, gpt-5-nano) and Codex Mini
- Add comprehensive test coverage for new functionality
- Fixes #7334
This commit is contained in:
Roo Code 2025-08-22 19:13:38 +00:00
parent 7137c1902f
commit 6af1f2f18a
2 changed files with 466 additions and 6 deletions

View file

@ -470,6 +470,182 @@ describe("OpenAiNativeHandler", () => {
})
})
it("should complete prompt successfully with GPT-5 model via streaming collection", async () => {
// Mock fetch for Responses API
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Enhanced "}\n\n'),
)
controller.enqueue(
new TextEncoder().encode('data: {"type":"response.text.delta","delta":"prompt "}\n\n'),
)
controller.enqueue(
new TextEncoder().encode('data: {"type":"response.text.delta","delta":"response"}\n\n'),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as any
handler = new OpenAiNativeHandler({
apiModelId: "gpt-5-2025-08-07",
openAiNativeApiKey: "test-api-key",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Enhanced prompt response")
// Verify the request was made with correct parameters
expect(mockFetch).toHaveBeenCalledWith(
"https://api.openai.com/v1/responses",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"Content-Type": "application/json",
Authorization: "Bearer test-api-key",
Accept: "text/event-stream",
}),
body: expect.stringContaining('"input":"User: Test prompt"'),
}),
)
const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body)
expect(requestBody).toMatchObject({
model: "gpt-5-2025-08-07",
stream: true,
temperature: 1,
})
// Clean up
delete (global as any).fetch
})
it("should complete prompt successfully with GPT-5-mini model", async () => {
// Mock fetch for Responses API
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"response.output_item.added","item":{"type":"text","text":"Mini response"}}\n\n',
),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as any
handler = new OpenAiNativeHandler({
apiModelId: "gpt-5-mini-2025-08-07",
openAiNativeApiKey: "test-api-key",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Mini response")
// Clean up
delete (global as any).fetch
})
it("should complete prompt successfully with GPT-5-nano model", async () => {
// Mock fetch for Responses API
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"type":"response.output_text.delta","delta":"Nano "}\n\n'),
)
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"response.output_text.delta","delta":"response"}\n\n',
),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as any
handler = new OpenAiNativeHandler({
apiModelId: "gpt-5-nano-2025-08-07",
openAiNativeApiKey: "test-api-key",
})
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Nano response")
// Clean up
delete (global as any).fetch
})
it("should handle GPT-5 completePrompt with reasoning response", async () => {
// Mock fetch for Responses API with reasoning
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
// Include reasoning in the response
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"response.reasoning.delta","delta":"Let me think about this..."}\n\n',
),
)
controller.enqueue(
new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Final answer"}\n\n'),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as any
handler = new OpenAiNativeHandler({
apiModelId: "gpt-5-2025-08-07",
openAiNativeApiKey: "test-api-key",
reasoningEffort: "high",
})
const result = await handler.completePrompt("Complex prompt")
// Should return the text content (reasoning is collected but text takes priority)
expect(result).toBe("Final answer")
// Clean up
delete (global as any).fetch
})
it("should handle GPT-5 completePrompt API errors", async () => {
// Mock fetch with error response
const mockFetch = vitest.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => JSON.stringify({ error: { message: "Invalid API key" } }),
})
global.fetch = mockFetch as any
handler = new OpenAiNativeHandler({
apiModelId: "gpt-5-2025-08-07",
openAiNativeApiKey: "invalid-key",
})
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(
"Failed to complete prompt via GPT-5 API: GPT-5 API request failed (401): Invalid API key",
)
// Clean up
delete (global as any).fetch
})
it("should complete prompt successfully with o1 model", async () => {
handler = new OpenAiNativeHandler({
apiModelId: "o1",
@ -1679,16 +1855,49 @@ describe("GPT-5 streaming event coverage (additional)", () => {
delete (global as any).fetch
})
it("should handle codex-mini-latest non-streaming completion", async () => {
it("should handle codex-mini-latest non-streaming completion via streaming collection", async () => {
// Mock fetch for Responses API that will be used for non-streaming completion
const mockFetch = vitest.fn().mockResolvedValue({
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"response.output_text.delta","delta":"def hello_world():"}\n\n',
),
)
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"response.output_text.delta","delta":"\\n print(\\"Hello, World!\\")"}\n\n',
),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as any
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "codex-mini-latest",
})
// Codex Mini now uses the same Responses API as GPT-5, which doesn't support non-streaming
await expect(handler.completePrompt("Write a hello world function in Python")).rejects.toThrow(
"completePrompt is not supported for codex-mini-latest. Use createMessage (Responses API) instead.",
// Codex Mini now collects the streaming response for non-streaming completion
const result = await handler.completePrompt("Write a hello world function in Python")
expect(result).toBe('def hello_world():\n print("Hello, World!")')
// Verify the request was made with correct parameters
expect(mockFetch).toHaveBeenCalledWith(
"https://api.openai.com/v1/responses",
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"input":"User: Write a hello world function in Python"'),
}),
)
// Clean up
delete (global as any).fetch
})
it("should handle codex-mini-latest API errors", async () => {

View file

@ -1248,8 +1248,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
const isResponsesApi = this.isResponsesApiModel(id)
if (isResponsesApi) {
// Models that use the Responses API (GPT-5 and Codex Mini) don't support non-streaming completion
throw new Error(`completePrompt is not supported for ${id}. Use createMessage (Responses API) instead.`)
// For GPT-5 and Codex Mini models, use the Responses API with streaming
// and collect the stream into a complete response
return await this.completePromptViaResponsesApi(prompt)
}
const params: any = {
@ -1276,4 +1277,254 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
throw error
}
}
/**
* Handles non-streaming completion for models that use the Responses API (GPT-5 and Codex Mini)
* by collecting the streaming response into a complete string.
*/
private async completePromptViaResponsesApi(prompt: string): Promise<string> {
const model = this.getModel()
const { verbosity } = model
// Resolve reasoning effort for GPT-5
const reasoningEffort = this.getGpt5ReasoningEffort(model)
// Format the prompt for the Responses API
// Use a simple format for single prompts without conversation history
const formattedInput = `User: ${prompt}`
// Build request body for the Responses API
interface Gpt5RequestBody {
model: string
input: string
stream: boolean
reasoning?: { effort: ReasoningEffortWithMinimal; summary?: "auto" }
text?: { verbosity: VerbosityLevel }
temperature?: number
max_output_tokens?: number
}
const requestBody: Gpt5RequestBody = {
model: model.id,
input: formattedInput,
stream: true, // We still use streaming but collect the results
...(reasoningEffort && {
reasoning: {
effort: reasoningEffort,
...(this.options.enableGpt5ReasoningSummary ? { summary: "auto" as const } : {}),
},
}),
text: { verbosity: (verbosity || "medium") as VerbosityLevel },
temperature: this.options.modelTemperature ?? GPT5_DEFAULT_TEMPERATURE,
...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}),
}
let collectedText = ""
let collectedReasoning = ""
try {
// Try using the SDK first
const stream = (await (this.client as any).responses.create(requestBody)) as AsyncIterable<any>
if (typeof (stream as any)[Symbol.asyncIterator] !== "function") {
// Fall back to SSE if SDK doesn't return an AsyncIterable
return await this.completePromptViaResponsesApiSSE(requestBody)
}
// Collect the streaming response
for await (const event of stream) {
// Process text deltas
if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") {
if (event?.delta) {
collectedText += event.delta
}
}
// Process reasoning deltas (include them in the response for enhanced prompts)
else if (
event?.type === "response.reasoning.delta" ||
event?.type === "response.reasoning_text.delta" ||
event?.type === "response.reasoning_summary.delta" ||
event?.type === "response.reasoning_summary_text.delta"
) {
if (event?.delta) {
collectedReasoning += event.delta
}
}
// Handle output item additions
else if (event?.type === "response.output_item.added") {
const item = event?.item
if (item) {
if (item.type === "text" && item.text) {
collectedText += item.text
} else if (item.type === "reasoning" && item.text) {
collectedReasoning += item.text
} else if (item.type === "message" && Array.isArray(item.content)) {
for (const content of item.content) {
if ((content?.type === "text" || content?.type === "output_text") && content?.text) {
collectedText += content.text
}
}
}
}
}
// Handle complete responses in initial event
else if (event.response && event.response.output && Array.isArray(event.response.output)) {
for (const outputItem of event.response.output) {
if (outputItem.type === "text" && outputItem.content) {
for (const content of outputItem.content) {
if (content.type === "text" && content.text) {
collectedText += content.text
}
}
}
// Handle reasoning summaries
if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) {
for (const summary of outputItem.summary) {
if (summary?.type === "summary_text" && typeof summary.text === "string") {
collectedReasoning += summary.text
}
}
}
}
}
}
} catch (sdkErr: any) {
// Fall back to SSE implementation if SDK fails
return await this.completePromptViaResponsesApiSSE(requestBody)
}
// Return the collected text (and optionally include reasoning if present)
// For prompt enhancement, we typically want just the enhanced prompt text
return collectedText || collectedReasoning || ""
}
/**
* SSE fallback for non-streaming completion via Responses API
*/
private async completePromptViaResponsesApiSSE(requestBody: any): Promise<string> {
const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com"
const url = `${baseUrl}/v1/responses`
let collectedText = ""
let collectedReasoning = ""
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
Accept: "text/event-stream",
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
const errorText = await response.text()
let errorMessage = `GPT-5 API request failed (${response.status})`
try {
const errorJson = JSON.parse(errorText)
if (errorJson.error?.message) {
errorMessage += `: ${errorJson.error.message}`
}
} catch {
errorMessage += `: ${errorText}`
}
throw new Error(errorMessage)
}
if (!response.body) {
throw new Error("GPT-5 Responses API error: No response body")
}
// Process the SSE stream
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6).trim()
if (data === "[DONE]") {
continue
}
try {
const parsed = JSON.parse(data)
// Process text deltas
if (parsed.type === "response.text.delta" || parsed.type === "response.output_text.delta") {
if (parsed.delta) {
collectedText += parsed.delta
}
}
// Process reasoning deltas
else if (
parsed.type === "response.reasoning.delta" ||
parsed.type === "response.reasoning_text.delta" ||
parsed.type === "response.reasoning_summary.delta" ||
parsed.type === "response.reasoning_summary_text.delta"
) {
if (parsed.delta) {
collectedReasoning += parsed.delta
}
}
// Handle output item additions
else if (parsed.type === "response.output_item.added") {
if (parsed.item) {
if (parsed.item.type === "text" && parsed.item.text) {
collectedText += parsed.item.text
} else if (parsed.item.type === "reasoning" && parsed.item.text) {
collectedReasoning += parsed.item.text
}
}
}
// Handle complete responses
else if (
parsed.response &&
parsed.response.output &&
Array.isArray(parsed.response.output)
) {
for (const outputItem of parsed.response.output) {
if (outputItem.type === "text" && outputItem.content) {
for (const content of outputItem.content) {
if (content.type === "text" && content.text) {
collectedText += content.text
}
}
}
}
}
} catch (e) {
// Ignore JSON parsing errors
if (!(e instanceof SyntaxError)) {
throw e
}
}
}
}
}
reader.releaseLock()
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to complete prompt via GPT-5 API: ${error.message}`)
}
throw new Error("Unexpected error completing prompt via GPT-5 API")
}
// Return the collected text
return collectedText || collectedReasoning || ""
}
}