Skip to content

Commit 0c7eda5

Browse files
committed
feat(responses): add cancel endpoint for background responses
Add POST /v1/responses/{response_id}/cancel endpoint to cancel queued or in-progress background responses. The implementation includes: - CancelResponseRequest model and protocol method - Task tracking infrastructure to map response_id to asyncio.Task - Updated background worker to handle CancelledError gracefully - Proper status transitions and terminal state protection (409 for completed/failed/incomplete responses) - Idempotent cancellation support - Integration tests covering all cancel scenarios (queued, in-progress, idempotent, terminal states, not found) The cancelled status already existed in the schema but had no API to trigger it. This follows the same pattern as the Batches API cancel implementation. Signed-off-by: Charlie Doern <cdoern@redhat.com>
1 parent bf94583 commit 0c7eda5

11 files changed

Lines changed: 670 additions & 5 deletions

File tree

client-sdks/stainless/openapi.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4053,6 +4053,45 @@ paths:
40534053
summary: Get service version
40544054
description: Get the version of the service.
40554055
operationId: version_v1alpha_admin_version_get
4056+
/v1/responses/{response_id}/cancel:
4057+
post:
4058+
responses:
4059+
'200':
4060+
description: The updated response object with status 'cancelled'.
4061+
content:
4062+
application/json:
4063+
schema:
4064+
$ref: '#/components/schemas/OpenAIResponseObject'
4065+
'400':
4066+
$ref: '#/components/responses/BadRequest400'
4067+
description: Bad Request
4068+
'429':
4069+
$ref: '#/components/responses/TooManyRequests429'
4070+
description: Too Many Requests
4071+
'500':
4072+
$ref: '#/components/responses/InternalServerError500'
4073+
description: Internal Server Error
4074+
default:
4075+
$ref: '#/components/responses/DefaultError'
4076+
description: Default Response
4077+
'404':
4078+
description: Response not found.
4079+
'409':
4080+
description: 'Conflict: Cannot cancel response in terminal state.'
4081+
tags:
4082+
- Agents
4083+
summary: Cancel a response that is in progress.
4084+
description: Cancel a response that is queued or in progress.
4085+
operationId: cancel_openai_response_v1_responses__response_id__cancel_post
4086+
parameters:
4087+
- name: response_id
4088+
in: path
4089+
required: true
4090+
schema:
4091+
type: string
4092+
description: The ID of the OpenAI response to cancel.
4093+
title: Response Id
4094+
description: The ID of the OpenAI response to cancel.
40564095
/v1alpha/post-training/jobs/{job_uuid}/status:
40574096
get:
40584097
responses:

docs/static/stainless-llama-stack-spec.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4053,6 +4053,45 @@ paths:
40534053
summary: Get service version
40544054
description: Get the version of the service.
40554055
operationId: version_v1alpha_admin_version_get
4056+
/v1/responses/{response_id}/cancel:
4057+
post:
4058+
responses:
4059+
'200':
4060+
description: The updated response object with status 'cancelled'.
4061+
content:
4062+
application/json:
4063+
schema:
4064+
$ref: '#/components/schemas/OpenAIResponseObject'
4065+
'400':
4066+
$ref: '#/components/responses/BadRequest400'
4067+
description: Bad Request
4068+
'429':
4069+
$ref: '#/components/responses/TooManyRequests429'
4070+
description: Too Many Requests
4071+
'500':
4072+
$ref: '#/components/responses/InternalServerError500'
4073+
description: Internal Server Error
4074+
default:
4075+
$ref: '#/components/responses/DefaultError'
4076+
description: Default Response
4077+
'404':
4078+
description: Response not found.
4079+
'409':
4080+
description: 'Conflict: Cannot cancel response in terminal state.'
4081+
tags:
4082+
- Agents
4083+
summary: Cancel a response that is in progress.
4084+
description: Cancel a response that is queued or in progress.
4085+
operationId: cancel_openai_response_v1_responses__response_id__cancel_post
4086+
parameters:
4087+
- name: response_id
4088+
in: path
4089+
required: true
4090+
schema:
4091+
type: string
4092+
description: The ID of the OpenAI response to cancel.
4093+
title: Response Id
4094+
description: The ID of the OpenAI response to cancel.
40564095
/v1alpha/post-training/jobs/{job_uuid}/status:
40574096
get:
40584097
responses:

src/llama_stack/providers/inline/agents/meta_reference/agents.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from llama_stack.providers.utils.responses.responses_store import ResponsesStore
1313
from llama_stack_api import (
1414
Agents,
15+
CancelResponseRequest,
1516
Connectors,
1617
Conversations,
1718
CreateResponseRequest,
@@ -173,3 +174,10 @@ async def delete_openai_response(
173174
) -> OpenAIDeleteResponseObject:
174175
assert self.openai_responses_impl is not None, "OpenAI responses not initialized"
175176
return await self.openai_responses_impl.delete_openai_response(request.response_id)
177+
178+
async def cancel_openai_response(
179+
self,
180+
request: CancelResponseRequest,
181+
) -> OpenAIResponseObject:
182+
assert self.openai_responses_impl is not None, "OpenAI responses not initialized"
183+
return await self.openai_responses_impl.cancel_openai_response(request.response_id)

src/llama_stack/providers/inline/agents/meta_reference/responses/openai_responses.py

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ def __init__(
118118
self.connectors_api = connectors_api
119119
self._background_queue: asyncio.Queue = asyncio.Queue(maxsize=BACKGROUND_QUEUE_MAX_SIZE)
120120
self._background_worker_tasks: set[asyncio.Task] = set()
121+
self._background_response_tasks: dict[str, asyncio.Task] = {}
122+
self._background_response_tasks_lock = asyncio.Lock()
121123

122124
async def initialize(self) -> None:
123125
"""No-op: background workers are started lazily on first use.
@@ -137,21 +139,51 @@ async def _ensure_workers_started(self) -> None:
137139

138140
async def shutdown(self) -> None:
139141
"""Stop background worker pool."""
142+
# Cancel all in-progress response tasks
143+
async with self._background_response_tasks_lock:
144+
for task in self._background_response_tasks.values():
145+
task.cancel()
146+
response_task_list = list(self._background_response_tasks.values())
147+
148+
# Cancel worker tasks
140149
for task in self._background_worker_tasks:
141150
task.cancel()
142-
await asyncio.gather(*self._background_worker_tasks, return_exceptions=True)
151+
152+
# Wait for all tasks to complete
153+
all_tasks = list(self._background_worker_tasks) + response_task_list
154+
await asyncio.gather(*all_tasks, return_exceptions=True)
143155

144156
async def _background_worker(self) -> None:
145157
"""Worker coroutine that pulls items from the queue and processes them."""
146158
while True:
147159
kwargs = await self._background_queue.get()
148-
try:
149-
await asyncio.wait_for(
160+
response_id = kwargs["response_id"]
161+
162+
# Create a task for this specific response so we can cancel it
163+
processing_task = asyncio.create_task(
164+
asyncio.wait_for(
150165
self._run_background_response_loop(**kwargs),
151166
timeout=BACKGROUND_RESPONSE_TIMEOUT_SECONDS,
152167
)
168+
)
169+
170+
# Track the task
171+
async with self._background_response_tasks_lock:
172+
self._background_response_tasks[response_id] = processing_task
173+
174+
try:
175+
await processing_task
176+
except asyncio.CancelledError:
177+
# Response was cancelled via cancel_openai_response
178+
logger.info(f"Background response {response_id} was cancelled")
179+
try:
180+
existing = await self.responses_store.get_response_object(response_id)
181+
if existing.status != "cancelled":
182+
existing.status = "cancelled"
183+
await self.responses_store.update_response_object(existing)
184+
except Exception:
185+
logger.exception(f"Failed to update response {response_id} with cancelled status")
153186
except TimeoutError:
154-
response_id = kwargs["response_id"]
155187
logger.exception(
156188
f"Background response {response_id} timed out after {BACKGROUND_RESPONSE_TIMEOUT_SECONDS}s"
157189
)
@@ -169,7 +201,6 @@ async def _background_worker(self) -> None:
169201
"Client polling this response will not see the failure."
170202
)
171203
except Exception as e:
172-
response_id = kwargs["response_id"]
173204
logger.exception(f"Error processing background response {response_id}")
174205
try:
175206
existing = await self.responses_store.get_response_object(response_id)
@@ -185,6 +216,9 @@ async def _background_worker(self) -> None:
185216
"Client polling this response will not see the failure."
186217
)
187218
finally:
219+
# Remove from tracking
220+
async with self._background_response_tasks_lock:
221+
self._background_response_tasks.pop(response_id, None)
188222
self._background_queue.task_done()
189223

190224
async def _prepend_previous_response(
@@ -1095,6 +1129,61 @@ async def _create_streaming_response(
10951129
async def delete_openai_response(self, response_id: str) -> OpenAIDeleteResponseObject:
10961130
return await self.responses_store.delete_response_object(response_id)
10971131

1132+
async def cancel_openai_response(
1133+
self,
1134+
response_id: str,
1135+
) -> OpenAIResponseObject:
1136+
"""Cancel a response that is queued or in progress.
1137+
1138+
Args:
1139+
response_id: The ID of the response to cancel
1140+
1141+
Returns:
1142+
The updated response object with status "cancelled"
1143+
1144+
Raises:
1145+
ResponseNotFoundError: If the response doesn't exist (automatically from store)
1146+
ConflictError: If the response is already in a terminal state
1147+
"""
1148+
from llama_stack_api import ConflictError
1149+
1150+
# Get current response state
1151+
response = await self.responses_store.get_response_object(response_id)
1152+
1153+
# Preserve the background field
1154+
was_background = response.background
1155+
1156+
# If already cancelled, return current state (idempotent)
1157+
if response.status == "cancelled":
1158+
cancelled_response = response.to_response_object()
1159+
# Ensure background field is preserved
1160+
if was_background:
1161+
cancelled_response.background = was_background
1162+
return cancelled_response
1163+
1164+
# Cannot cancel responses in terminal states
1165+
if response.status in ["completed", "failed", "incomplete"]:
1166+
raise ConflictError(f"Cannot cancel response '{response_id}' with status '{response.status}'")
1167+
1168+
# Update status to cancelled in database
1169+
response.status = "cancelled"
1170+
await self.responses_store.update_response_object(response)
1171+
1172+
# If the response is currently being processed, cancel the task
1173+
async with self._background_response_tasks_lock:
1174+
task = self._background_response_tasks.get(response_id)
1175+
if task:
1176+
task.cancel()
1177+
# Note: task removal handled in worker's finally block
1178+
1179+
# Return updated response
1180+
response_with_input = await self.responses_store.get_response_object(response_id)
1181+
cancelled_response = response_with_input.to_response_object()
1182+
# Ensure background field is preserved
1183+
if was_background:
1184+
cancelled_response.background = was_background
1185+
return cancelled_response
1186+
10981187
async def _sync_response_to_conversation(
10991188
self, conversation_id: str, input: str | list[OpenAIResponseInput] | None, output_items: list[ConversationItem]
11001189
) -> None:

src/llama_stack_api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
# Import all public API symbols
5454
from .agents import (
5555
Agents,
56+
CancelResponseRequest,
5657
CreateResponseRequest,
5758
DeleteResponseRequest,
5859
ListResponseInputItemsRequest,
@@ -611,6 +612,7 @@
611612
"Agents",
612613
"AggregationFunctionType",
613614
# Agents Request Models
615+
"CancelResponseRequest",
614616
"CreateResponseRequest",
615617
"DeleteResponseRequest",
616618
"ListResponseInputItemsRequest",

src/llama_stack_api/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from . import fastapi_routes
1515
from .api import Agents
1616
from .models import (
17+
CancelResponseRequest,
1718
CreateResponseRequest,
1819
DeleteResponseRequest,
1920
ListResponseInputItemsRequest,
@@ -27,6 +28,7 @@
2728

2829
__all__ = [
2930
"Agents",
31+
"CancelResponseRequest",
3032
"CreateResponseRequest",
3133
"DeleteResponseRequest",
3234
"ListResponseInputItemsRequest",

src/llama_stack_api/agents/api.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717

1818
from .models import (
19+
CancelResponseRequest,
1920
CreateResponseRequest,
2021
DeleteResponseRequest,
2122
ListResponseInputItemsRequest,
@@ -50,3 +51,8 @@ async def delete_openai_response(
5051
self,
5152
request: DeleteResponseRequest,
5253
) -> OpenAIDeleteResponseObject: ...
54+
55+
async def cancel_openai_response(
56+
self,
57+
request: CancelResponseRequest,
58+
) -> OpenAIResponseObject: ...

src/llama_stack_api/agents/fastapi_routes.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
from .api import Agents
4141
from .models import (
42+
CancelResponseRequest,
4243
CreateResponseRequest,
4344
DeleteResponseRequest,
4445
ListResponseInputItemsRequest,
@@ -83,6 +84,7 @@ async def sse_generator(event_gen):
8384
# Automatically generate dependency functions from Pydantic models
8485
get_retrieve_response_request = create_path_dependency(RetrieveResponseRequest)
8586
get_delete_response_request = create_path_dependency(DeleteResponseRequest)
87+
get_cancel_response_request = create_path_dependency(CancelResponseRequest)
8688
get_list_responses_request = create_query_dependency(ListResponsesRequest)
8789

8890

@@ -281,4 +283,20 @@ async def delete_openai_response(
281283
) -> OpenAIDeleteResponseObject:
282284
return await impl.delete_openai_response(request)
283285

286+
@router.post(
287+
"/responses/{response_id}/cancel",
288+
response_model=OpenAIResponseObject,
289+
summary="Cancel a response that is in progress.",
290+
description="Cancel a response that is queued or in progress.",
291+
responses={
292+
200: {"description": "The updated response object with status 'cancelled'."},
293+
404: {"description": "Response not found."},
294+
409: {"description": "Conflict: Cannot cancel response in terminal state."},
295+
},
296+
)
297+
async def cancel_openai_response(
298+
request: Annotated[CancelResponseRequest, Depends(get_cancel_response_request)],
299+
) -> OpenAIResponseObject:
300+
return await impl.cancel_openai_response(request)
301+
284302
return router

src/llama_stack_api/agents/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,11 @@ class DeleteResponseRequest(BaseModel):
235235
model_config = ConfigDict(extra="forbid")
236236

237237
response_id: str = Field(..., min_length=1, description="The ID of the OpenAI response to delete.")
238+
239+
240+
class CancelResponseRequest(BaseModel):
241+
"""Request model for canceling a response."""
242+
243+
model_config = ConfigDict(extra="forbid")
244+
245+
response_id: str = Field(..., min_length=1, description="The ID of the OpenAI response to cancel.")

0 commit comments

Comments
 (0)