Fix GPT-5 Responses API issues with condensing and image support (#7067)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
This commit is contained in:
Daniel 2025-08-27 22:30:43 -05:00 committed by GitHub
parent d4a16f469c
commit 2204457cc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 856 additions and 1000 deletions

View file

@ -55,6 +55,8 @@ export const modelInfoSchema = z.object({
// Capability flag to indicate whether the model supports an output verbosity parameter
supportsVerbosity: z.boolean().optional(),
supportsReasoningBudget: z.boolean().optional(),
// Capability flag to indicate whether the model supports temperature parameter
supportsTemperature: z.boolean().optional(),
requiredReasoningBudget: z.boolean().optional(),
supportsReasoningEffort: z.boolean().optional(),
supportedParameters: z.array(modelParametersSchema).optional(),

View file

@ -31,6 +31,7 @@ export const openAiNativeModels = {
description: "GPT-5: The best model for coding and agentic tasks across domains",
// supportsVerbosity is a new capability; ensure ModelInfo includes it
supportsVerbosity: true,
supportsTemperature: false,
},
"gpt-5-mini-2025-08-07": {
maxTokens: 128000,
@ -44,6 +45,7 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.03,
description: "GPT-5 Mini: A faster, more cost-efficient version of GPT-5 for well-defined tasks",
supportsVerbosity: true,
supportsTemperature: false,
},
"gpt-5-nano-2025-08-07": {
maxTokens: 128000,
@ -57,6 +59,7 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.01,
description: "GPT-5 Nano: Fastest, most cost-efficient version of GPT-5",
supportsVerbosity: true,
supportsTemperature: false,
},
"gpt-4.1": {
maxTokens: 32_768,
@ -66,6 +69,7 @@ export const openAiNativeModels = {
inputPrice: 2,
outputPrice: 8,
cacheReadsPrice: 0.5,
supportsTemperature: true,
},
"gpt-4.1-mini": {
maxTokens: 32_768,
@ -75,6 +79,7 @@ export const openAiNativeModels = {
inputPrice: 0.4,
outputPrice: 1.6,
cacheReadsPrice: 0.1,
supportsTemperature: true,
},
"gpt-4.1-nano": {
maxTokens: 32_768,
@ -84,6 +89,7 @@ export const openAiNativeModels = {
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.025,
supportsTemperature: true,
},
o3: {
maxTokens: 100_000,
@ -95,6 +101,7 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.5,
supportsReasoningEffort: true,
reasoningEffort: "medium",
supportsTemperature: false,
},
"o3-high": {
maxTokens: 100_000,
@ -105,6 +112,7 @@ export const openAiNativeModels = {
outputPrice: 8.0,
cacheReadsPrice: 0.5,
reasoningEffort: "high",
supportsTemperature: false,
},
"o3-low": {
maxTokens: 100_000,
@ -115,6 +123,7 @@ export const openAiNativeModels = {
outputPrice: 8.0,
cacheReadsPrice: 0.5,
reasoningEffort: "low",
supportsTemperature: false,
},
"o4-mini": {
maxTokens: 100_000,
@ -126,6 +135,7 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.275,
supportsReasoningEffort: true,
reasoningEffort: "medium",
supportsTemperature: false,
},
"o4-mini-high": {
maxTokens: 100_000,
@ -136,6 +146,7 @@ export const openAiNativeModels = {
outputPrice: 4.4,
cacheReadsPrice: 0.275,
reasoningEffort: "high",
supportsTemperature: false,
},
"o4-mini-low": {
maxTokens: 100_000,
@ -146,6 +157,7 @@ export const openAiNativeModels = {
outputPrice: 4.4,
cacheReadsPrice: 0.275,
reasoningEffort: "low",
supportsTemperature: false,
},
"o3-mini": {
maxTokens: 100_000,
@ -157,6 +169,7 @@ export const openAiNativeModels = {
cacheReadsPrice: 0.55,
supportsReasoningEffort: true,
reasoningEffort: "medium",
supportsTemperature: false,
},
"o3-mini-high": {
maxTokens: 100_000,
@ -167,6 +180,7 @@ export const openAiNativeModels = {
outputPrice: 4.4,
cacheReadsPrice: 0.55,
reasoningEffort: "high",
supportsTemperature: false,
},
"o3-mini-low": {
maxTokens: 100_000,
@ -177,6 +191,7 @@ export const openAiNativeModels = {
outputPrice: 4.4,
cacheReadsPrice: 0.55,
reasoningEffort: "low",
supportsTemperature: false,
},
o1: {
maxTokens: 100_000,
@ -186,6 +201,7 @@ export const openAiNativeModels = {
inputPrice: 15,
outputPrice: 60,
cacheReadsPrice: 7.5,
supportsTemperature: false,
},
"o1-preview": {
maxTokens: 32_768,
@ -195,6 +211,7 @@ export const openAiNativeModels = {
inputPrice: 15,
outputPrice: 60,
cacheReadsPrice: 7.5,
supportsTemperature: false,
},
"o1-mini": {
maxTokens: 65_536,
@ -204,6 +221,7 @@ export const openAiNativeModels = {
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
supportsTemperature: false,
},
"gpt-4o": {
maxTokens: 16_384,
@ -213,6 +231,7 @@ export const openAiNativeModels = {
inputPrice: 2.5,
outputPrice: 10,
cacheReadsPrice: 1.25,
supportsTemperature: true,
},
"gpt-4o-mini": {
maxTokens: 16_384,
@ -222,6 +241,7 @@ export const openAiNativeModels = {
inputPrice: 0.15,
outputPrice: 0.6,
cacheReadsPrice: 0.075,
supportsTemperature: true,
},
"codex-mini-latest": {
maxTokens: 16_384,
@ -231,6 +251,7 @@ export const openAiNativeModels = {
inputPrice: 1.5,
outputPrice: 6,
cacheReadsPrice: 0,
supportsTemperature: false,
description:
"Codex Mini: Cloud-based software engineering agent powered by codex-1, a version of o3 optimized for coding tasks. Trained with reinforcement learning to generate human-style code, adhere to instructions, and iteratively run tests.",
},

10
pnpm-lock.yaml generated
View file

@ -683,8 +683,8 @@ importers:
specifier: ^0.5.17
version: 0.5.17
openai:
specifier: ^5.0.0
version: 5.5.1(ws@8.18.3)(zod@3.25.61)
specifier: ^5.12.2
version: 5.12.2(ws@8.18.3)(zod@3.25.61)
os-name:
specifier: ^6.0.0
version: 6.1.0
@ -7939,8 +7939,8 @@ packages:
resolution: {integrity: sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==}
engines: {node: '>=18'}
openai@5.5.1:
resolution: {integrity: sha512-5i19097mGotHA1eFsM6Tjd/tJ8uo9sa5Ysv4Q6bKJ2vtN6rc0MzMrUefXnLXYAJcmMQrC1Efhj0AvfIkXrQamw==}
openai@5.12.2:
resolution: {integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==}
hasBin: true
peerDependencies:
ws: ^8.18.0
@ -18191,7 +18191,7 @@ snapshots:
is-inside-container: 1.0.0
is-wsl: 3.1.0
openai@5.5.1(ws@8.18.3)(zod@3.25.61):
openai@5.12.2(ws@8.18.3)(zod@3.25.61):
optionalDependencies:
ws: 8.18.3
zod: 3.25.61

View file

@ -56,6 +56,14 @@ export interface ApiHandlerCreateMessageMetadata {
* Used to enforce "skip once" after a condense operation.
*/
suppressPreviousResponseId?: boolean
/**
* Controls whether the response should be stored for 30 days in OpenAI's Responses API.
* When true (default), responses are stored and can be referenced in future requests
* using the previous_response_id for efficient conversation continuity.
* Set to false to opt out of response storage for privacy or compliance reasons.
* @default true
*/
store?: boolean
}
export interface ApiHandler {

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,9 @@ export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>
// GPT-5 specific types
// Constants for model identification
const GPT5_MODEL_PREFIX = "gpt-5"
export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: OpenAI
@ -35,8 +38,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
private responseIdPromise: Promise<string | undefined> | undefined
private responseIdResolver: ((value: string | undefined) => void) | undefined
// Event types handled by the shared GPT-5 event processor to avoid duplication
private readonly gpt5CoreHandledTypes = new Set<string>([
// Event types handled by the shared event processor to avoid duplication
private readonly coreHandledEventTypes = new Set<string>([
"response.text.delta",
"response.output_text.delta",
"response.reasoning.delta",
@ -60,7 +63,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey })
}
private normalizeGpt5Usage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined {
private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined {
if (!usage) return undefined
const totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0
@ -103,114 +106,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const model = this.getModel()
let id: "o3-mini" | "o3" | "o4-mini" | undefined
if (model.id.startsWith("o3-mini")) {
id = "o3-mini"
} else if (model.id.startsWith("o3")) {
id = "o3"
} else if (model.id.startsWith("o4-mini")) {
id = "o4-mini"
}
if (id) {
yield* this.handleReasonerMessage(model, id, systemPrompt, messages)
} else if (model.id.startsWith("o1")) {
yield* this.handleO1FamilyMessage(model, systemPrompt, messages)
} else if (this.isResponsesApiModel(model.id)) {
// Both GPT-5 and Codex Mini use the v1/responses endpoint
yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata)
} else {
yield* this.handleDefaultModelMessage(model, systemPrompt, messages)
}
}
private async *handleO1FamilyMessage(
model: OpenAiNativeModel,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
// o1 supports developer prompt with formatting
// o1-preview and o1-mini only support user messages
const isOriginalO1 = model.id === "o1"
const { reasoning } = this.getModel()
const response = await this.client.chat.completions.create({
model: model.id,
messages: [
{
role: isOriginalO1 ? "developer" : "user",
content: isOriginalO1 ? `Formatting re-enabled\n${systemPrompt}` : systemPrompt,
},
...convertToOpenAiMessages(messages),
],
stream: true,
stream_options: { include_usage: true },
...(reasoning && reasoning),
})
yield* this.handleStreamResponse(response, model)
}
private async *handleReasonerMessage(
model: OpenAiNativeModel,
family: "o3-mini" | "o3" | "o4-mini",
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const { reasoning } = this.getModel()
const stream = await this.client.chat.completions.create({
model: family,
messages: [
{
role: "developer",
content: `Formatting re-enabled\n${systemPrompt}`,
},
...convertToOpenAiMessages(messages),
],
stream: true,
stream_options: { include_usage: true },
...(reasoning && reasoning),
})
yield* this.handleStreamResponse(stream, model)
}
private async *handleDefaultModelMessage(
model: OpenAiNativeModel,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const { reasoning, verbosity } = this.getModel()
// Prepare the request parameters
const params: any = {
model: model.id,
temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
...(reasoning && reasoning),
}
// Add verbosity only if the model supports it
if (verbosity && model.info.supportsVerbosity) {
params.verbosity = verbosity
}
const stream = await this.client.chat.completions.create(params)
if (typeof (stream as any)[Symbol.asyncIterator] !== "function") {
throw new Error(
"OpenAI SDK did not return an AsyncIterable for streaming response. Please check SDK version and usage.",
)
}
yield* this.handleStreamResponse(
stream as unknown as AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
model,
)
// Use Responses API for ALL models
yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata)
}
private async *handleResponsesApiMessage(
@ -219,20 +117,24 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
// Prefer the official SDK Responses API with streaming; fall back to fetch-based SSE if needed.
const { verbosity } = this.getModel()
// Use Responses API for ALL models
const { verbosity, reasoning } = this.getModel()
// Both GPT-5 and Codex Mini use the same v1/responses endpoint format
// Resolve reasoning effort (supports "minimal" for GPT5)
const reasoningEffort = this.getGpt5ReasoningEffort(model)
// Resolve reasoning effort for models that support it
const reasoningEffort = this.getReasoningEffort(model)
// Wait for any pending response ID from a previous request to be available
// This handles the race condition with fast nano model responses
let effectivePreviousResponseId = metadata?.previousResponseId
// Only allow fallback to pending/last response id when not explicitly suppressed
if (!metadata?.suppressPreviousResponseId) {
// Check if we should suppress previous response ID (e.g., after condense or message edit)
if (metadata?.suppressPreviousResponseId) {
// Clear the stored lastResponseId to prevent it from being used in future requests
this.lastResponseId = undefined
effectivePreviousResponseId = undefined
} else {
// Only try to get fallback response IDs if not suppressing
// If we have a pending response ID promise, wait for it to resolve
if (!effectivePreviousResponseId && this.responseIdPromise) {
try {
@ -250,52 +152,102 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
// Fall back to the last known response ID if still not available
if (!effectivePreviousResponseId) {
if (!effectivePreviousResponseId && this.lastResponseId) {
effectivePreviousResponseId = this.lastResponseId
}
}
// Format input and capture continuity id
const { formattedInput, previousResponseId } = this.prepareGpt5Input(systemPrompt, messages, metadata)
const requestPreviousResponseId = effectivePreviousResponseId ?? previousResponseId
const { formattedInput, previousResponseId } = this.prepareStructuredInput(systemPrompt, messages, metadata)
const requestPreviousResponseId = effectivePreviousResponseId || previousResponseId
// Create a new promise for this request's response ID
this.responseIdPromise = new Promise<string | undefined>((resolve) => {
this.responseIdResolver = resolve
})
// Build request body
const requestBody = this.buildRequestBody(
model,
formattedInput,
requestPreviousResponseId,
systemPrompt,
verbosity,
reasoningEffort,
metadata,
)
// Make the request
yield* this.executeRequest(requestBody, model, metadata)
}
private buildRequestBody(
model: OpenAiNativeModel,
formattedInput: any,
requestPreviousResponseId: string | undefined,
systemPrompt: string,
verbosity: any,
reasoningEffort: ReasoningEffortWithMinimal | undefined,
metadata?: ApiHandlerCreateMessageMetadata,
): any {
// Build a request body (also used for fallback)
// Ensure we explicitly pass max_output_tokens for GPT5 based on Roo's reserved model response calculation
// so requests do not default to very large limits (e.g., 120k).
interface Gpt5RequestBody {
model: string
input: string
input: Array<{ role: "user" | "assistant"; content: any[] }>
stream: boolean
reasoning?: { effort: ReasoningEffortWithMinimal; summary?: "auto" }
text?: { verbosity: VerbosityLevel }
temperature?: number
max_output_tokens?: number
previous_response_id?: string
store?: boolean
instructions?: string
}
const requestBody: Gpt5RequestBody = {
const body: Gpt5RequestBody = {
model: model.id,
input: formattedInput,
stream: true,
store: metadata?.store !== false, // Default to true unless explicitly set to false
// Always include instructions (system prompt) for Responses API.
// Unlike Chat Completions, system/developer roles in input have no special semantics here.
// The official way to set system behavior is the top-level `instructions` field.
instructions: systemPrompt,
...(reasoningEffort && {
reasoning: {
effort: reasoningEffort,
...(this.options.enableGpt5ReasoningSummary ? { summary: "auto" as const } : {}),
},
}),
text: { verbosity: (verbosity || "medium") as VerbosityLevel },
temperature: this.options.modelTemperature ?? GPT5_DEFAULT_TEMPERATURE,
// Explicitly include the calculated max output tokens for GPT5.
// Only include temperature if the model supports it
...(model.info.supportsTemperature !== false && {
temperature:
this.options.modelTemperature ??
(model.id.startsWith(GPT5_MODEL_PREFIX)
? GPT5_DEFAULT_TEMPERATURE
: OPENAI_NATIVE_DEFAULT_TEMPERATURE),
}),
// Explicitly include the calculated max output tokens.
// Use the per-request reserved output computed by Roo (params.maxTokens from getModelParams).
...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}),
...(requestPreviousResponseId && { previous_response_id: requestPreviousResponseId }),
}
// Include text.verbosity only when the model explicitly supports it
if (model.info.supportsVerbosity === true) {
body.text = { verbosity: (verbosity || "medium") as VerbosityLevel }
}
return body
}
private async *executeRequest(
requestBody: any,
model: OpenAiNativeModel,
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
try {
// Use the official SDK
const stream = (await (this.client as any).responses.create(requestBody)) as AsyncIterable<any>
@ -307,7 +259,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
for await (const event of stream) {
for await (const outChunk of this.processGpt5Event(event, model)) {
for await (const outChunk of this.processEvent(event, model)) {
yield outChunk
}
}
@ -320,9 +272,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
if (is400Error && requestBody.previous_response_id && isPreviousResponseError) {
// Log the error and retry without the previous_response_id
console.warn(
`[GPT-5] Previous response ID not found (${requestBody.previous_response_id}), retrying without it`,
)
// Remove the problematic previous_response_id and retry
const retryRequestBody = { ...requestBody }
@ -344,7 +293,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
for await (const event of retryStream) {
for await (const outChunk of this.processGpt5Event(event, model)) {
for await (const outChunk of this.processEvent(event, model)) {
yield outChunk
}
}
@ -361,52 +310,85 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
}
private formatInputForResponsesAPI(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
// Format the conversation for the Responses API input field
// Use Developer role format for GPT-5 (aligning with o1/o3 Developer role usage per GPT-5 Responses guidance)
// This ensures consistent instruction handling across reasoning models
let formattedInput = `Developer: ${systemPrompt}\n\n`
private formatFullConversation(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): any {
// Format the entire conversation history for the Responses API using structured format
// This supports both text and images
const formattedMessages: any[] = []
// Do NOT embed the system prompt as a developer message in the Responses API input.
// The Responses API treats roles as free-form; use the top-level `instructions` field instead.
// Process each message
for (const message of messages) {
const role = message.role === "user" ? "User" : "Assistant"
const role = message.role === "user" ? "user" : "assistant"
const content: any[] = []
// Handle text content
if (typeof message.content === "string") {
formattedInput += `${role}: ${message.content}\n\n`
// For user messages, use input_text; for assistant messages, use output_text
if (role === "user") {
content.push({ type: "input_text", text: message.content })
} else {
content.push({ type: "output_text", text: message.content })
}
} else if (Array.isArray(message.content)) {
// Handle content blocks
const textContent = message.content
.filter((block) => block.type === "text")
.map((block) => (block as any).text)
.join("\n")
if (textContent) {
formattedInput += `${role}: ${textContent}\n\n`
// For array content with potential images, format properly
for (const block of message.content) {
if (block.type === "text") {
// For user messages, use input_text; for assistant messages, use output_text
if (role === "user") {
content.push({ type: "input_text", text: (block as any).text })
} else {
content.push({ type: "output_text", text: (block as any).text })
}
} else if (block.type === "image") {
const image = block as Anthropic.Messages.ImageBlockParam
// Format image with proper data URL - images are always input_image
const imageUrl = `data:${image.source.media_type};base64,${image.source.data}`
content.push({ type: "input_image", image_url: imageUrl })
}
}
}
}
return formattedInput.trim()
}
private formatSingleMessageForResponsesAPI(message: Anthropic.Messages.MessageParam): string {
// Format a single message for the Responses API when using previous_response_id
const role = message.role === "user" ? "User" : "Assistant"
// Handle text content
if (typeof message.content === "string") {
return `${role}: ${message.content}`
} else if (Array.isArray(message.content)) {
// Handle content blocks
const textContent = message.content
.filter((block) => block.type === "text")
.map((block) => (block as any).text)
.join("\n")
if (textContent) {
return `${role}: ${textContent}`
if (content.length > 0) {
formattedMessages.push({ role, content })
}
}
return ""
return formattedMessages
}
private formatSingleStructuredMessage(message: Anthropic.Messages.MessageParam): any {
// Format a single message for the Responses API when using previous_response_id
// When using previous_response_id, we only send the latest user message
const role = message.role === "user" ? "user" : "assistant"
if (typeof message.content === "string") {
// For simple string content, return structured format with proper type
return {
role,
content: [{ type: "input_text", text: message.content }],
}
} else if (Array.isArray(message.content)) {
// Extract text and image content from blocks
const content: any[] = []
for (const block of message.content) {
if (block.type === "text") {
// User messages use input_text
content.push({ type: "input_text", text: (block as any).text })
} else if (block.type === "image") {
const image = block as Anthropic.Messages.ImageBlockParam
const imageUrl = `data:${image.source.media_type};base64,${image.source.data}`
content.push({ type: "input_image", image_url: imageUrl })
}
}
if (content.length > 0) {
return { role, content }
}
}
return null
}
private async *makeGpt5ResponsesAPIRequest(
@ -456,9 +438,6 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
if (response.status === 400 && requestBody.previous_response_id && isPreviousResponseError) {
// Log the error and retry without the previous_response_id
console.warn(
`[GPT-5 SSE] Previous response ID not found (${requestBody.previous_response_id}), retrying without it`,
)
// Remove the problematic previous_response_id and retry
const retryRequestBody = { ...requestBody }
@ -482,32 +461,32 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
if (!retryResponse.ok) {
// If retry also fails, throw the original error
throw new Error(`GPT-5 API retry failed (${retryResponse.status})`)
throw new Error(`Responses API retry failed (${retryResponse.status})`)
}
if (!retryResponse.body) {
throw new Error("GPT-5 Responses API error: No response body from retry request")
throw new Error("Responses API error: No response body from retry request")
}
// Handle the successful retry response
yield* this.handleGpt5StreamResponse(retryResponse.body, model)
yield* this.handleStreamResponse(retryResponse.body, model)
return
}
// Provide user-friendly error messages based on status code
switch (response.status) {
case 400:
errorMessage = "Invalid request to GPT-5 API. Please check your input parameters."
errorMessage = "Invalid request to Responses API. Please check your input parameters."
break
case 401:
errorMessage = "Authentication failed. Please check your OpenAI API key."
break
case 403:
errorMessage = "Access denied. Your API key may not have access to GPT-5 models."
errorMessage = "Access denied. Your API key may not have access to this endpoint."
break
case 404:
errorMessage =
"GPT-5 API endpoint not found. The model may not be available yet or requires a different configuration."
"Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration."
break
case 429:
errorMessage = "Rate limit exceeded. Please try again later."
@ -518,7 +497,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
errorMessage = "OpenAI service error. Please try again later."
break
default:
errorMessage = `GPT-5 API error (${response.status})`
errorMessage = `Responses API error (${response.status})`
}
// Append details if available
@ -530,73 +509,74 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
if (!response.body) {
throw new Error("GPT-5 Responses API error: No response body")
throw new Error("Responses API error: No response body")
}
// Handle streaming response
yield* this.handleGpt5StreamResponse(response.body, model)
yield* this.handleStreamResponse(response.body, model)
} catch (error) {
if (error instanceof Error) {
// Re-throw with the original error message if it's already formatted
if (error.message.includes("GPT-5")) {
if (error.message.includes("Responses API")) {
throw error
}
// Otherwise, wrap it with context
throw new Error(`Failed to connect to GPT-5 API: ${error.message}`)
throw new Error(`Failed to connect to Responses API: ${error.message}`)
}
// Handle non-Error objects
throw new Error(`Unexpected error connecting to GPT-5 API`)
throw new Error(`Unexpected error connecting to Responses API`)
}
}
/**
* Prepares the input and conversation continuity parameters for a GPT-5 API call.
* Prepares the input and conversation continuity parameters for a Responses API call.
* Decides whether to send full conversation or just the latest message based on previousResponseId.
*
* - If a `previousResponseId` is available (either from metadata or the handler's state),
* it formats only the most recent user message for the input and returns the response ID
* to maintain conversation context.
* - Otherwise, it formats the entire conversation history (system prompt + messages) for the input.
*
* @returns An object containing the formatted input string and the previous response ID (if used).
* @returns An object containing the formatted input and the previous response ID (if used).
*/
private prepareGpt5Input(
private prepareStructuredInput(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): { formattedInput: string; previousResponseId?: string } {
// Respect explicit suppression signal for continuity (e.g. immediately after condense)
const isFirstMessage = messages.length === 1 && messages[0].role === "user"
const allowFallback = !metadata?.suppressPreviousResponseId
): { formattedInput: any; previousResponseId?: string } {
// Note: suppressPreviousResponseId is handled in handleResponsesApiMessage
// This method now only handles formatting based on whether we have a previous response ID
const previousResponseId =
metadata?.previousResponseId ?? (allowFallback && !isFirstMessage ? this.lastResponseId : undefined)
// Check for previous response ID from metadata or fallback to lastResponseId
const isFirstMessage = messages.length === 1 && messages[0].role === "user"
const previousResponseId = metadata?.previousResponseId ?? (!isFirstMessage ? this.lastResponseId : undefined)
if (previousResponseId) {
// When using previous_response_id, only send the latest user message
const lastUserMessage = [...messages].reverse().find((msg) => msg.role === "user")
const formattedInput = lastUserMessage ? this.formatSingleMessageForResponsesAPI(lastUserMessage) : ""
return { formattedInput, previousResponseId }
if (lastUserMessage) {
const formattedMessage = this.formatSingleStructuredMessage(lastUserMessage)
// formatSingleStructuredMessage now always returns an object with role and content
if (formattedMessage) {
return { formattedInput: [formattedMessage], previousResponseId }
}
}
return { formattedInput: [], previousResponseId }
} else {
const formattedInput = this.formatInputForResponsesAPI(systemPrompt, messages)
// Format full conversation history (returns an array of structured messages)
const formattedInput = this.formatFullConversation(systemPrompt, messages)
return { formattedInput }
}
}
/**
* Handles the streaming response from the GPT-5 Responses API.
* Handles the streaming response from the Responses API.
*
* This function iterates through the Server-Sent Events (SSE) stream, parses each event,
* and yields structured data chunks (`ApiStream`). It handles a wide variety of event types,
* including text deltas, reasoning, usage data, and various status/tool events.
*
* The following event types are intentionally ignored as they are not currently consumed
* by the client application:
* - Audio events (`response.audio.*`)
* - Most tool call events (e.g., `response.function_call_arguments.*`, `response.mcp_call.*`, etc.)
* as the client does not yet support rendering these tool interactions.
* - Status events (`response.created`, `response.in_progress`, etc.) as they are informational
* and do not affect the final output.
*/
private async *handleGpt5StreamResponse(body: ReadableStream<Uint8Array>, model: OpenAiNativeModel): ApiStream {
private async *handleStreamResponse(body: ReadableStream<Uint8Array>, model: OpenAiNativeModel): ApiStream {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
@ -629,8 +609,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
// Delegate standard event types to the shared processor to avoid duplication
if (parsed?.type && this.gpt5CoreHandledTypes.has(parsed.type)) {
for await (const outChunk of this.processGpt5Event(parsed, model)) {
if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) {
for await (const outChunk of this.processEvent(parsed, model)) {
// Track whether we've emitted any content so fallback handling can decide appropriately
if (outChunk.type === "text" || outChunk.type === "reasoning") {
hasContent = true
@ -670,7 +650,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
// Check for usage in the complete response
if (parsed.response.usage) {
const usageData = this.normalizeGpt5Usage(parsed.response.usage, model)
const usageData = this.normalizeUsage(parsed.response.usage, model)
if (usageData) {
yield usageData
}
@ -910,7 +890,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Response failed
if (parsed.error || parsed.message) {
throw new Error(
`GPT-5 response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`,
`Response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`,
)
}
} else if (parsed.type === "response.completed" || parsed.type === "response.done") {
@ -990,7 +970,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
} else if (parsed.usage) {
// Handle usage if it arrives in a separate, non-completed event
const usageData = this.normalizeGpt5Usage(parsed.usage, model)
const usageData = this.normalizeUsage(parsed.usage, model)
if (usageData) {
yield usageData
}
@ -1026,19 +1006,18 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// This can happen in certain edge cases and shouldn't break the flow
} catch (error) {
if (error instanceof Error) {
throw new Error(`Error processing GPT-5 response stream: ${error.message}`)
throw new Error(`Error processing response stream: ${error.message}`)
}
throw new Error("Unexpected error processing GPT-5 response stream")
throw new Error("Unexpected error processing response stream")
} finally {
reader.releaseLock()
}
}
/**
* Shared processor for GPT5 Responses API events.
* Used by both the official SDK streaming path and (optionally) by the SSE fallback.
* Shared processor for Responses API events.
*/
private async *processGpt5Event(event: any, model: OpenAiNativeModel): ApiStream {
private async *processEvent(event: any, model: OpenAiNativeModel): ApiStream {
// Persist response id for conversation continuity when available
if (event?.response?.id) {
this.resolveResponseId(event.response.id)
@ -1096,7 +1075,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Completion events that may carry usage
if (event?.type === "response.done" || event?.type === "response.completed") {
const usage = event?.response?.usage || event?.usage || undefined
const usageData = this.normalizeGpt5Usage(usage, model)
const usageData = this.normalizeUsage(usage, model)
if (usageData) {
yield usageData
}
@ -1110,87 +1089,30 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
if (event?.usage) {
const usageData = this.normalizeGpt5Usage(event.usage, model)
const usageData = this.normalizeUsage(event.usage, model)
if (usageData) {
yield usageData
}
}
}
private getGpt5ReasoningEffort(model: OpenAiNativeModel): ReasoningEffortWithMinimal | undefined {
private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortWithMinimal | undefined {
const { reasoning, info } = model
// Check if reasoning effort is configured
if (reasoning && "reasoning_effort" in reasoning) {
const effort = reasoning.reasoning_effort as string
// Support all effort levels including "minimal" for GPT-5
// Support all effort levels
if (effort === "minimal" || effort === "low" || effort === "medium" || effort === "high") {
return effort as ReasoningEffortWithMinimal
}
}
// Centralize default: use the model's default from types if available; otherwise undefined
// Use the model's default from types if available
return info.reasoningEffort as ReasoningEffortWithMinimal | undefined
}
private isGpt5Model(modelId: string): boolean {
return modelId.startsWith("gpt-5")
}
private isResponsesApiModel(modelId: string): boolean {
// Both GPT-5 and Codex Mini use the v1/responses endpoint
return modelId.startsWith("gpt-5") || modelId === "codex-mini-latest"
}
private async *handleStreamResponse(
stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>,
model: OpenAiNativeModel,
): ApiStream {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
}
}
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
const inputTokens = usage?.prompt_tokens || 0
const outputTokens = usage?.completion_tokens || 0
// Extract cache tokens from prompt_tokens_details
// According to OpenAI API, cached_tokens represents tokens read from cache
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || undefined
// Cache write tokens are not typically reported in the standard streaming response
// They would be in cache_creation_input_tokens if available
const cacheWriteTokens = (usage as any)?.cache_creation_input_tokens || undefined
const totalCost = calculateApiCostOpenAI(
info,
inputTokens,
outputTokens,
cacheWriteTokens || 0,
cacheReadTokens || 0,
)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
// Removed isResponsesApiModel method as ALL models now use the Responses API
override getModel() {
const modelId = this.options.apiModelId
@ -1205,18 +1127,18 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
modelId: id,
model: info,
settings: this.options,
defaultTemperature: this.isGpt5Model(id) ? GPT5_DEFAULT_TEMPERATURE : OPENAI_NATIVE_DEFAULT_TEMPERATURE,
defaultTemperature: id.startsWith(GPT5_MODEL_PREFIX)
? GPT5_DEFAULT_TEMPERATURE
: OPENAI_NATIVE_DEFAULT_TEMPERATURE,
})
// For models using the Responses API (GPT-5 and Codex Mini), ensure we support reasoning effort
if (this.isResponsesApiModel(id)) {
const effort =
(this.options.reasoningEffort as ReasoningEffortWithMinimal | undefined) ??
(info.reasoningEffort as ReasoningEffortWithMinimal | undefined)
// For models using the Responses API, ensure we support reasoning effort
const effort =
(this.options.reasoningEffort as ReasoningEffortWithMinimal | undefined) ??
(info.reasoningEffort as ReasoningEffortWithMinimal | undefined)
if (effort) {
;(params.reasoning as any) = { reasoning_effort: effort }
}
if (effort) {
;(params.reasoning as any) = { reasoning_effort: effort }
}
// The o3 models are named like "o3-mini-[reasoning-effort]", which are
@ -1225,7 +1147,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
/**
* Gets the last GPT-5 response ID captured from the Responses API stream.
* Gets the last response ID captured from the Responses API stream.
* Used for maintaining conversation continuity across requests.
* @returns The response ID, or undefined if not available yet
*/
@ -1234,9 +1156,9 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
/**
* Sets the last GPT-5 response ID for conversation continuity.
* Sets the last response ID for conversation continuity.
* Typically only used in tests or special flows.
* @param responseId The GPT-5 response ID to store
* @param responseId The response ID to store
*/
setResponseId(responseId: string): void {
this.lastResponseId = responseId
@ -1244,31 +1166,74 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
async completePrompt(prompt: string): Promise<string> {
try {
const { id, temperature, reasoning, verbosity } = this.getModel()
const isResponsesApi = this.isResponsesApiModel(id)
const model = this.getModel()
const { verbosity, reasoning } = model
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.`)
// Resolve reasoning effort for models that support it
const reasoningEffort = this.getReasoningEffort(model)
// Build request body for Responses API
const requestBody: any = {
model: model.id,
input: [
{
role: "user",
content: [{ type: "input_text", text: prompt }],
},
],
stream: false, // Non-streaming for completePrompt
store: false, // Don't store prompt completions
}
const params: any = {
model: id,
messages: [{ role: "user", content: prompt }],
// Add reasoning if supported
if (reasoningEffort) {
requestBody.reasoning = {
effort: reasoningEffort,
...(this.options.enableGpt5ReasoningSummary ? { summary: "auto" as const } : {}),
}
}
// Add temperature if supported
if (temperature !== undefined) {
params.temperature = temperature
// Only include temperature if the model supports it
if (model.info.supportsTemperature !== false) {
requestBody.temperature =
this.options.modelTemperature ??
(model.id.startsWith(GPT5_MODEL_PREFIX)
? GPT5_DEFAULT_TEMPERATURE
: OPENAI_NATIVE_DEFAULT_TEMPERATURE)
}
// Add reasoning parameters for models that support them
if (reasoning) {
Object.assign(params, reasoning)
// Include max_output_tokens if available
if (model.maxTokens) {
requestBody.max_output_tokens = model.maxTokens
}
const response = await this.client.chat.completions.create(params)
return response.choices[0]?.message.content || ""
// Include text.verbosity only when the model explicitly supports it
if (model.info.supportsVerbosity === true) {
requestBody.text = { verbosity: (verbosity || "medium") as VerbosityLevel }
}
// Make the non-streaming request
const response = await (this.client as any).responses.create(requestBody)
// Extract text from the response
if (response?.output && Array.isArray(response.output)) {
for (const outputItem of response.output) {
if (outputItem.type === "message" && outputItem.content) {
for (const content of outputItem.content) {
if (content.type === "output_text" && content.text) {
return content.text
}
}
}
}
}
// Fallback: check for direct text in response
if (response?.text) {
return response.text
}
return ""
} catch (error) {
if (error instanceof Error) {
throw new Error(`OpenAI Native completion error: ${error.message}`)

View file

@ -103,6 +103,7 @@ import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
import { AutoApprovalHandler } from "./AutoApprovalHandler"
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
@ -590,6 +591,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
public async overwriteClineMessages(newMessages: ClineMessage[]) {
this.clineMessages = newMessages
// If deletion or history truncation leaves a condense_context as the last message,
// ensure the next API call suppresses previous_response_id so the condensed context is respected.
try {
const last = this.clineMessages.at(-1)
if (last && last.type === "say" && last.say === "condense_context") {
this.skipPrevResponseIdOnce = true
}
} catch {
// non-fatal
}
restoreTodoListForTask(this)
await this.saveClineMessages()
}
@ -785,14 +798,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
console.log(
`[Task#${this.taskId}] pWaitFor askResponse(${type}) -> blocking (isStatusMutable = ${isStatusMutable}, statusMutationTimeouts = ${statusMutationTimeouts.length})`,
)
// Wait for askResponse to be set
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
console.log(`[Task#${this.taskId}] pWaitFor askResponse(${type}) -> unblocked (${this.askResponse})`)
if (this.lastMessageTs !== askTs) {
// Could happen if we send multiple asks in a row i.e. with
// command_output. It's important that when we know an ask could
@ -871,17 +879,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const systemPrompt = await this.getSystemPrompt()
// Get condensing configuration
// Using type assertion to handle the case where Phase 1 hasn't been implemented yet
const state = await this.providerRef.deref()?.getState()
const customCondensingPrompt = state ? (state as any).customCondensingPrompt : undefined
const condensingApiConfigId = state ? (state as any).condensingApiConfigId : undefined
const listApiConfigMeta = state ? (state as any).listApiConfigMeta : undefined
// These properties may not exist in the state type yet, but are used for condensing configuration
const customCondensingPrompt = state?.customCondensingPrompt
const condensingApiConfigId = state?.condensingApiConfigId
const listApiConfigMeta = state?.listApiConfigMeta
// Determine API handler to use
let condensingApiHandler: ApiHandler | undefined
if (condensingApiConfigId && listApiConfigMeta && Array.isArray(listApiConfigMeta)) {
// Using type assertion for the id property to avoid implicit any
const matchingConfig = listApiConfigMeta.find((config: any) => config.id === condensingApiConfigId)
// Find matching config by ID
const matchingConfig = listApiConfigMeta.find((config) => config.id === condensingApiConfigId)
if (matchingConfig) {
const profile = await this.providerRef.deref()?.providerSettingsManager.getProfile({
id: condensingApiConfigId,
@ -923,6 +931,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return
}
await this.overwriteApiConversationHistory(messages)
// Set flag to skip previous_response_id on the next API call after manual condense
this.skipPrevResponseIdOnce = true
const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens }
await this.say(
"condense_context",
@ -1000,7 +1012,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
lastMessage.partial = false
lastMessage.progressStatus = progressStatus
if (options.metadata) {
;(lastMessage as any).metadata = options.metadata
// Add metadata to the message
const messageWithMetadata = lastMessage as ClineMessage & ClineMessageWithMetadata
if (!messageWithMetadata.metadata) {
messageWithMetadata.metadata = {}
}
Object.assign(messageWithMetadata.metadata, options.metadata)
}
// Instead of streaming partialMessage events, we do a save
@ -1098,7 +1115,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
console.log(`[subtasks] task ${this.taskId}.${this.instanceId} starting`)
// Task starting
await this.initiateTaskLoop([
{
@ -1137,6 +1154,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
private async resumeTaskFromHistory() {
// Resuming task from history
if (this.enableTaskBridge) {
try {
this.bridgeService = this.bridgeService || ExtensionBridgeService.getInstance()
@ -1153,6 +1172,18 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const modifiedClineMessages = await this.getSavedClineMessages()
// Check for any stored GPT-5 response IDs in the message history
const gpt5Messages = modifiedClineMessages.filter(
(m): m is ClineMessage & ClineMessageWithMetadata =>
m.type === "say" &&
m.say === "text" &&
!!(m as ClineMessageWithMetadata).metadata?.gpt5?.previous_response_id,
)
if (gpt5Messages.length > 0) {
const lastGpt5Message = gpt5Messages[gpt5Messages.length - 1]
// The lastGpt5Message contains the previous_response_id that can be used for continuity
}
// Remove any resume messages that may have been added before
const lastRelevantMessageIndex = findLastIndex(
modifiedClineMessages,
@ -1382,12 +1413,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
console.log(`[subtasks] task ${this.taskId}.${this.instanceId} resuming from history item`)
// Task resuming from history item
await this.initiateTaskLoop(newUserContent)
}
public dispose(): void {
// Disposing task
console.log(`[Task] disposing task ${this.taskId}.${this.instanceId}`)
// Remove all event listeners to prevent memory leaks
@ -1458,7 +1490,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
public async abortTask(isAbandoned = false) {
console.log(`[subtasks] aborting task ${this.taskId}.${this.instanceId}`)
// Aborting task
// Will stop any autonomously running promises.
if (isAbandoned) {
@ -2331,8 +2363,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
let condensingApiHandler: ApiHandler | undefined
if (condensingApiConfigId && listApiConfigMeta && Array.isArray(listApiConfigMeta)) {
// Using type assertion for the id property to avoid implicit any.
const matchingConfig = listApiConfigMeta.find((config: any) => config.id === condensingApiConfigId)
// Find matching config by ID
const matchingConfig = listApiConfigMeta.find((config) => config.id === condensingApiConfigId)
if (matchingConfig) {
const profile = await this.providerRef.deref()?.providerSettingsManager.getProfile({
@ -2455,25 +2487,29 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Find the last assistant message that has a previous_response_id stored
const idx = findLastIndex(
this.clineMessages,
(m) =>
(m): m is ClineMessage & ClineMessageWithMetadata =>
m.type === "say" &&
(m as any).say === "text" &&
(m as any).metadata?.gpt5?.previous_response_id,
m.say === "text" &&
!!(m as ClineMessageWithMetadata).metadata?.gpt5?.previous_response_id,
)
if (idx !== -1) {
// Use the previous_response_id from the last assistant message for this request
previousResponseId = ((this.clineMessages[idx] as any).metadata.gpt5.previous_response_id ||
undefined) as string | undefined
const message = this.clineMessages[idx] as ClineMessage & ClineMessageWithMetadata
previousResponseId = message.metadata?.gpt5?.previous_response_id
}
} else if (this.skipPrevResponseIdOnce) {
// Skipping previous_response_id due to recent condense operation - will send full conversation context
}
} catch {
} catch (error) {
console.error(`[Task#${this.taskId}] Error retrieving GPT-5 response ID:`, error)
// non-fatal
}
const metadata: ApiHandlerCreateMessageMetadata = {
mode: mode,
taskId: this.taskId,
...(previousResponseId ? { previousResponseId } : {}),
// Only include previousResponseId if we're NOT suppressing it
...(previousResponseId && !this.skipPrevResponseIdOnce ? { previousResponseId } : {}),
// If a condense just occurred, explicitly suppress continuity fallback for the next call
...(this.skipPrevResponseIdOnce ? { suppressPreviousResponseId: true } : {}),
}
@ -2650,22 +2686,28 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const modelId = this.api.getModel().id
if (!modelId || !modelId.startsWith("gpt-5")) return
const lastResponseId: string | undefined = (this.api as any)?.getLastResponseId?.()
// Check if the API handler has a getLastResponseId method (OpenAiNativeHandler specific)
const handler = this.api as ApiHandler & { getLastResponseId?: () => string | undefined }
const lastResponseId = handler.getLastResponseId?.()
const idx = findLastIndex(
this.clineMessages,
(m) => m.type === "say" && (m as any).say === "text" && m.partial !== true,
(m) => m.type === "say" && m.say === "text" && m.partial !== true,
)
if (idx !== -1) {
const msg = this.clineMessages[idx] as any
msg.metadata = msg.metadata ?? {}
msg.metadata.gpt5 = {
const msg = this.clineMessages[idx] as ClineMessage & ClineMessageWithMetadata
if (!msg.metadata) {
msg.metadata = {}
}
const gpt5Metadata: Gpt5Metadata = {
...(msg.metadata.gpt5 ?? {}),
previous_response_id: lastResponseId,
instructions: this.lastUsedInstructions,
reasoning_summary: (reasoningMessage ?? "").trim() || undefined,
}
msg.metadata.gpt5 = gpt5Metadata
}
} catch {
} catch (error) {
console.error(`[Task#${this.taskId}] Error persisting GPT-5 metadata:`, error)
// Non-fatal error in metadata persistence
}
}

View file

@ -1615,6 +1615,69 @@ describe("Cline", () => {
})
})
describe("Conversation continuity after condense and deletion", () => {
it("should set suppressPreviousResponseId when last message is condense_context", async () => {
// Arrange: create task
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "initial task",
startTask: false,
})
// Ensure provider state returns required fields for attemptApiRequest
mockProvider.getState = vi.fn().mockResolvedValue({
apiConfiguration: mockApiConfig,
})
// Simulate deletion that leaves a condense_context as the last message
const condenseMsg = {
ts: Date.now(),
type: "say" as const,
say: "condense_context" as const,
contextCondense: {
summary: "summarized",
cost: 0.001,
prevContextTokens: 1200,
newContextTokens: 400,
},
}
await task.overwriteClineMessages([condenseMsg])
// Spy and return a minimal successful stream to exercise attemptApiRequest
const mockStream = {
async *[Symbol.asyncIterator]() {
yield { type: "text", text: "ok" }
},
async next() {
return { done: true, value: { type: "text", text: "ok" } }
},
async return() {
return { done: true, value: undefined }
},
async throw(e: any) {
throw e
},
[Symbol.asyncDispose]: async () => {},
} as AsyncGenerator<ApiStreamChunk>
const createMessageSpy = vi.spyOn(task.api, "createMessage").mockReturnValue(mockStream)
// Act: initiate an API request
const iterator = task.attemptApiRequest(0)
await iterator.next() // read first chunk to ensure call happened
// Assert: metadata includes suppressPreviousResponseId set to true
expect(createMessageSpy).toHaveBeenCalled()
const callArgs = createMessageSpy.mock.calls[0]
// Args: [systemPrompt, cleanConversationHistory, metadata]
const metadata = callArgs?.[2]
expect(metadata?.suppressPreviousResponseId).toBe(true)
// The skip flag should be reset after the call
expect((task as any).skipPrevResponseIdOnce).toBe(false)
})
})
describe("abortTask", () => {
it("should set abort flag and emit TaskAborted event", async () => {
const task = new Task({

37
src/core/task/types.ts Normal file
View file

@ -0,0 +1,37 @@
/**
* Type definitions for Task-related metadata
*/
/**
* GPT-5 specific metadata stored with assistant messages
* for maintaining conversation continuity across requests
*/
export interface Gpt5Metadata {
/**
* The response ID from the previous GPT-5 API response
* Used to maintain conversation continuity in subsequent requests
*/
previous_response_id?: string
/**
* The system instructions/prompt used for this response
* Stored to track what instructions were active when the response was generated
*/
instructions?: string
/**
* The reasoning summary from GPT-5's reasoning process
* Contains the model's internal reasoning if reasoning mode was enabled
*/
reasoning_summary?: string
}
/**
* Extended ClineMessage type with GPT-5 metadata
*/
export interface ClineMessageWithMetadata {
metadata?: {
gpt5?: Gpt5Metadata
[key: string]: any
}
}

View file

@ -462,7 +462,7 @@
"node-cache": "^5.1.2",
"node-ipc": "^12.0.0",
"ollama": "^0.5.17",
"openai": "^5.0.0",
"openai": "^5.12.2",
"os-name": "^6.0.0",
"p-limit": "^6.2.0",
"p-wait-for": "^5.0.2",

View file

@ -743,11 +743,13 @@ const ApiOptions = ({
fuzzyMatchThreshold={apiConfiguration.fuzzyMatchThreshold}
onChange={(field, value) => setApiConfigurationField(field, value)}
/>
<TemperatureControl
value={apiConfiguration.modelTemperature}
onChange={handleInputChange("modelTemperature", noTransform)}
maxValue={2}
/>
{selectedModelInfo?.supportsTemperature !== false && (
<TemperatureControl
value={apiConfiguration.modelTemperature}
onChange={handleInputChange("modelTemperature", noTransform)}
maxValue={2}
/>
)}
<RateLimitSecondsControl
value={apiConfiguration.rateLimitSeconds || 0}
onChange={(value) => setApiConfigurationField("rateLimitSeconds", value)}