feat(sse): wire AbortSignal through Task -> providers to terminate SSE on cancel

This commit is contained in:
Roo Code 2025-11-21 23:21:31 +00:00
parent fa764ba7f1
commit 3f627cfe2b
7 changed files with 81 additions and 30 deletions

View file

@ -89,6 +89,11 @@ export interface ApiHandlerCreateMessageMetadata {
* Used by providers to determine whether to include native tool definitions.
*/
toolProtocol?: ToolProtocol
/**
* Abort controller signal to cancel the underlying HTTP request/stream.
* Providers that support request cancellation must forward this to their SDK client.
*/
abortSignal?: AbortSignal
}
export interface ApiHandler {

View file

@ -126,23 +126,29 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
case "claude-haiku-4-5-20251001":
case "claude-3-haiku-20240307":
betas.push("prompt-caching-2024-07-31")
return { headers: { "anthropic-beta": betas.join(",") } }
return {
headers: { "anthropic-beta": betas.join(",") },
...(metadata?.abortSignal ? { signal: metadata.abortSignal } : {}),
}
default:
return undefined
return metadata?.abortSignal ? { signal: metadata.abortSignal } : undefined
}
})(),
)
break
}
default: {
stream = (await this.client.messages.create({
model: modelId,
max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS,
temperature,
system: [{ text: systemPrompt, type: "text" }],
messages,
stream: true,
})) as any
stream = (await this.client.messages.create(
{
model: modelId,
max_tokens: maxTokens ?? ANTHROPIC_DEFAULT_MAX_TOKENS,
temperature,
system: [{ text: systemPrompt, type: "text" }],
messages,
stream: true,
},
metadata?.abortSignal ? { signal: metadata.abortSignal } : undefined,
)) as any
break
}
}

View file

@ -101,7 +101,21 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
}
try {
return this.client.chat.completions.create(params, requestOptions)
// Merge any caller-provided requestOptions with an AbortSignal from metadata (if present)
const effectiveRequestOptions: OpenAI.RequestOptions | undefined = (() => {
const base: OpenAI.RequestOptions = requestOptions ? { ...requestOptions } : {}
if (metadata?.abortSignal) {
;(base as any).signal = metadata.abortSignal
}
return Object.keys(base).length > 0 ? base : undefined
})()
// IMPORTANT: Only pass a second argument when options are actually defined
// to preserve test expectations that assert single-arg invocation.
if (effectiveRequestOptions) {
return this.client.chat.completions.create(params, effectiveRequestOptions)
}
return this.client.chat.completions.create(params)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}

View file

@ -173,10 +173,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
let stream
try {
stream = await this.client.chat.completions.create(
requestOptions,
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
const baseOptions = isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}
const effectiveOptions = metadata?.abortSignal
? { ...baseOptions, signal: metadata.abortSignal }
: baseOptions
stream = await this.client.chat.completions.create(requestOptions, effectiveOptions as any)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
@ -284,10 +285,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
let response
try {
response = await this.client.chat.completions.create(
requestOptions,
this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
const baseOptions = this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}
const effectiveOptions = metadata?.abortSignal
? { ...baseOptions, signal: metadata.abortSignal }
: baseOptions
response = await this.client.chat.completions.create(requestOptions, effectiveOptions as any)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
@ -403,10 +405,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
let stream
try {
stream = await this.client.chat.completions.create(
requestOptions,
methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
{
const baseOptions = methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}
const effectiveOptions = metadata?.abortSignal
? { ...baseOptions, signal: metadata.abortSignal }
: baseOptions
stream = await this.client.chat.completions.create(requestOptions, effectiveOptions as any)
}
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
@ -435,10 +440,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
let response
try {
response = await this.client.chat.completions.create(
requestOptions,
methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
{
const baseOptions = methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {}
const effectiveOptions = metadata?.abortSignal
? { ...baseOptions, signal: metadata.abortSignal }
: baseOptions
response = await this.client.chat.completions.create(requestOptions, effectiveOptions as any)
}
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}

View file

@ -195,7 +195,14 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
let stream
try {
stream = await this.client.chat.completions.create(completionParams)
// Forward AbortSignal so Cancel terminates SSE immediately
const effectiveOptions = metadata?.abortSignal ? ({ signal: metadata.abortSignal } as any) : undefined
// Preserve tests that expect single-arg invocation when no options are provided
if (effectiveOptions) {
stream = await this.client.chat.completions.create(completionParams, effectiveOptions)
} else {
stream = await this.client.chat.completions.create(completionParams)
}
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}

View file

@ -106,7 +106,15 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
try {
this.client.apiKey = getSessionToken()
return this.client.chat.completions.create(rooParams, requestOptions)
// Merge AbortSignal into request options so Cancel can terminate SSE
const effectiveRequestOptions: OpenAI.RequestOptions | undefined = (() => {
const base: OpenAI.RequestOptions = requestOptions ? { ...requestOptions } : {}
if (metadata?.abortSignal) {
;(base as any).signal = metadata.abortSignal
}
return Object.keys(base).length > 0 ? base : undefined
})()
return this.client.chat.completions.create(rooParams, effectiveRequestOptions)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}

View file

@ -3271,11 +3271,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.currentRequestAbortController = new AbortController()
const abortSignal = this.currentRequestAbortController.signal
// Include abort signal so providers can terminate the underlying HTTP/SSE stream immediately
const metadataWithSignal: ApiHandlerCreateMessageMetadata = { ...metadata, abortSignal }
// The provider accepts reasoning items alongside standard messages; cast to the expected parameter type.
const stream = this.api.createMessage(
systemPrompt,
cleanConversationHistory as unknown as Anthropic.Messages.MessageParam[],
metadata,
metadataWithSignal,
)
const iterator = stream[Symbol.asyncIterator]()