mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-06 02:47:56 +00:00
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:
parent
7137c1902f
commit
6af1f2f18a
2 changed files with 466 additions and 6 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 || ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue