Skip to content

Commit 3a7306f

Browse files
feat: Use Structured Errors in Responses and Conversations API (#4879)
## Summary Replace ValueError/Exception with LlamaStackError subclasses in Responses and Conversations APIs for predictable HTTP status codes (400, 404, 500, 503). **Responses API** - `InvalidParameterError` (400) for parameter validation: conversation ID format, mutually exclusive `previous_response_id` + `conversation`, prompt variables, temperature range, MCP Authorization in headers - `InternalServerError` (500) for stream failures: `response.failed` in non-streaming mode, multiple terminal events, stream never reaching terminal state - `ServiceNotEnabledError` (503) for guardrails without Safety API - `ResponseNotFoundError` (404) replaces `ValueError` for missing responses - Remove dead `max_tool_calls < 1` guard (Pydantic enforces at API boundary) **Conversations API** - `InvalidParameterError` (400) for invalid `conversation_id` format and empty `conversation_id`/`item_id` - `ConversationItemNotFoundError` (404) for missing items in retrieve/delete - Replace `InvalidConversationIdError` with `InvalidParameterError` for consistency **Other** - MCP auth test catches both `llama_stack_client.BadRequestError` and `openai.BadRequestError` - `logger.error` with `extra=` context on failure paths for operator debugging - Docstring improvements for `ResourceNotFoundError`, `InvalidParameterError`, `ServiceNotEnabledError` ## Stack This is PR 2/4 in the error message consistency series (split from #3913). Depends on #4878 (included in this branch). 1. Error types foundation (#4878) — merge first 2. **This PR** — Responses and Conversations API error handling 3. Record/replay error support (#record-replay-errors) 4. Integration tests (#error-integration-tests) ## Test plan - [x] Unit tests updated for structured error types (`InvalidParameterError`, `ServiceNotEnabledError`, `InternalServerError`, `ResponseNotFoundError`, `ConversationItemNotFoundError`) - [x] `test_errors.py`: HTTP status code tests for all new error types (400, 404, 500, 503) - [x] `test_conversations.py`: expect `InvalidParameterError` for invalid ID format and empty params - [x] `test_mcp_authentication.py`: expect `BadRequestError` from both OpenAI and llama-stack clients - [x] Unit test for `response.failed` → `InternalServerError` in non-streaming mode - [x] All pre-commit hooks pass (ruff, mypy, API spec codegen, license checks) - [x] CI passes --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5042310 commit 3a7306f

13 files changed

Lines changed: 336 additions & 87 deletions

File tree

src/llama_stack/core/conversations/conversations.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
from llama_stack.core.storage.sqlstore.authorized_sqlstore import AuthorizedSqlStore
1515
from llama_stack.core.storage.sqlstore.sqlstore import sqlstore_impl
1616
from llama_stack.log import get_logger
17-
from llama_stack_api.common.errors import ConversationNotFoundError
17+
from llama_stack_api import (
18+
ConversationItemNotFoundError,
19+
ConversationNotFoundError,
20+
InvalidParameterError,
21+
ServiceNotEnabledError,
22+
)
1823
from llama_stack_api.conversations import (
1924
AddItemsRequest,
2025
Conversation,
@@ -65,7 +70,7 @@ def __init__(self, config: ConversationServiceConfig, deps: dict[Any, Any]):
6570
# Use conversations store reference from run config
6671
conversations_ref = config.config.storage.stores.conversations
6772
if not conversations_ref:
68-
raise ValueError("storage.stores.conversations must be configured in run config")
73+
raise ServiceNotEnabledError("storage.stores.conversations")
6974

7075
base_sql_store = sqlstore_impl(conversations_ref)
7176
self.sql_store = AuthorizedSqlStore(base_sql_store, self.policy)
@@ -176,9 +181,7 @@ async def openai_delete_conversation(self, request: DeleteConversationRequest) -
176181
def _validate_conversation_id(self, conversation_id: str) -> None:
177182
"""Validate conversation ID format."""
178183
if not conversation_id.startswith("conv_"):
179-
raise ValueError(
180-
f"Invalid 'conversation_id': '{conversation_id}'. Expected an ID that begins with 'conv_'."
181-
)
184+
raise InvalidParameterError("conversation_id", conversation_id, "Conversation ID must begin with 'conv_'.")
182185

183186
def _get_or_generate_item_id(self, item: ConversationItem, item_dict: dict) -> str:
184187
"""Get existing item ID or generate one if missing."""
@@ -242,29 +245,25 @@ async def add_items(self, conversation_id: str, request: AddItemsRequest) -> Con
242245
async def retrieve(self, request: RetrieveItemRequest) -> ConversationItem:
243246
"""Retrieve a conversation item."""
244247
if not request.conversation_id:
245-
raise ValueError(
246-
f"Expected a non-empty value for `conversation_id` but received {request.conversation_id!r}"
247-
)
248+
raise InvalidParameterError("conversation_id", request.conversation_id, "Must be a non-empty string.")
248249
if not request.item_id:
249-
raise ValueError(f"Expected a non-empty value for `item_id` but received {request.item_id!r}")
250+
raise InvalidParameterError("item_id", request.item_id, "Must be a non-empty string.")
250251

251252
# Get item from conversation_items table
252253
record = await self.sql_store.fetch_one(
253254
table="conversation_items", where={"id": request.item_id, "conversation_id": request.conversation_id}
254255
)
255256

256257
if record is None:
257-
raise ValueError(f"Item {request.item_id} not found in conversation {request.conversation_id}")
258+
raise ConversationItemNotFoundError(request.item_id, request.conversation_id)
258259

259260
adapter: TypeAdapter[ConversationItem] = TypeAdapter(ConversationItem)
260261
return adapter.validate_python(record["item_data"])
261262

262263
async def list_items(self, request: ListItemsRequest) -> ConversationItemList:
263264
"""List items in the conversation."""
264265
if not request.conversation_id:
265-
raise ValueError(
266-
f"Expected a non-empty value for `conversation_id` but received {request.conversation_id!r}"
267-
)
266+
raise InvalidParameterError("conversation_id", request.conversation_id, "Must be a non-empty string.")
268267

269268
# check if conversation exists
270269
await self.get_conversation(GetConversationRequest(conversation_id=request.conversation_id))
@@ -300,11 +299,9 @@ async def list_items(self, request: ListItemsRequest) -> ConversationItemList:
300299
async def openai_delete_conversation_item(self, request: DeleteItemRequest) -> ConversationItemDeletedResource:
301300
"""Delete a conversation item."""
302301
if not request.conversation_id:
303-
raise ValueError(
304-
f"Expected a non-empty value for `conversation_id` but received {request.conversation_id!r}"
305-
)
302+
raise InvalidParameterError("conversation_id", request.conversation_id, "Must be a non-empty string.")
306303
if not request.item_id:
307-
raise ValueError(f"Expected a non-empty value for `item_id` but received {request.item_id!r}")
304+
raise InvalidParameterError("item_id", request.item_id, "Must be a non-empty string.")
308305

309306
_ = await self._get_validated_conversation(request.conversation_id)
310307

@@ -313,7 +310,7 @@ async def openai_delete_conversation_item(self, request: DeleteItemRequest) -> C
313310
)
314311

315312
if record is None:
316-
raise ValueError(f"Item {request.item_id} not found in conversation {request.conversation_id}")
313+
raise ConversationItemNotFoundError(request.item_id, request.conversation_id)
317314

318315
await self.sql_store.delete(
319316
table="conversation_items", where={"id": request.item_id, "conversation_id": request.conversation_id}

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

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
Conversations,
2626
Files,
2727
Inference,
28-
InvalidConversationIdError,
28+
InternalServerError,
29+
InvalidParameterError,
2930
ListItemsRequest,
3031
ListOpenAIResponseInputItem,
3132
ListOpenAIResponseObject,
@@ -54,6 +55,7 @@
5455
ResponseItemInclude,
5556
ResponseTruncation,
5657
Safety,
58+
ServiceNotEnabledError,
5759
ToolGroups,
5860
ToolRuntime,
5961
VectorIO,
@@ -304,7 +306,11 @@ async def _prepend_prompt(
304306
# Validate that all provided variables exist in the prompt
305307
for name in openai_response_prompt.variables.keys():
306308
if name not in cur_prompt_variables:
307-
raise ValueError(f"Variable {name} not found in prompt {openai_response_prompt.id}")
309+
raise InvalidParameterError(
310+
"prompt.variables",
311+
name,
312+
f"Variable not defined in prompt '{openai_response_prompt.id}'.",
313+
)
308314

309315
# Separate text and media variables
310316
text_substitutions = {}
@@ -581,29 +587,31 @@ async def create_openai_response(
581587
if isinstance(tool, OpenAIResponseInputToolMCP) and tool.headers:
582588
for key in tool.headers.keys():
583589
if key.lower() == "authorization":
584-
raise ValueError(
585-
"Authorization header cannot be passed via 'headers'. "
586-
"Please use the 'authorization' parameter instead."
590+
raise InvalidParameterError(
591+
f"tools[server_label={tool.server_label!r}].headers",
592+
key,
593+
"Authorization credentials must be passed via the 'authorization' parameter, not 'headers'.",
587594
)
588595

589596
guardrail_ids = extract_guardrail_ids(guardrails) if guardrails else []
590597

591598
# Validate that Safety API is available if guardrails are requested
592599
if guardrail_ids and self.safety_api is None:
593-
raise ValueError(
594-
"Cannot process guardrails: Safety API is not configured.\n\n"
595-
"To use guardrails, ensure the Safety API is configured in your stack, or remove "
596-
"the 'guardrails' parameter from your request."
600+
raise ServiceNotEnabledError(
601+
"Safety API",
602+
provider_specific_message="Ensure the Safety API is enabled in your stack, otherwise remove the 'guardrails' parameter from your request.",
597603
)
598604

599605
if conversation is not None:
600606
if previous_response_id is not None:
601-
raise ValueError(
602-
"Mutually exclusive parameters: 'previous_response_id' and 'conversation'. Ensure you are only providing one of these parameters."
607+
raise InvalidParameterError(
608+
"previous_response_id, conversation",
609+
"previous_response_id and conversation are both provided",
610+
"Provide only one of these parameters.",
603611
)
604612

605613
if not conversation.startswith("conv_"):
606-
raise InvalidConversationIdError(conversation)
614+
raise InvalidParameterError("conversation", conversation, "Expected an ID that begins with 'conv_'.")
607615

608616
if max_tool_calls is not None and max_tool_calls < 1:
609617
raise ValueError(f"Invalid {max_tool_calls=}; should be >= 1")
@@ -668,32 +676,51 @@ async def create_openai_response(
668676
final_response = None
669677
final_event_type = None
670678
failed_response = None
671-
672679
async for stream_chunk in stream_gen:
673680
match stream_chunk.type:
674681
case "response.completed" | "response.incomplete":
675682
if final_response is not None:
676-
raise ValueError(
677-
"The response stream produced multiple terminal responses! "
678-
f"Earlier response from {final_event_type}"
683+
logger.error(
684+
"The response stream produced multiple terminal events, when it should produce exactly one.",
685+
extra={
686+
"response_id": stream_chunk.response.id,
687+
"first_terminal_event": final_event_type,
688+
"second_terminal_event": stream_chunk.type,
689+
"model": model,
690+
"conversation": conversation,
691+
"previous_response_id": previous_response_id,
692+
},
679693
)
694+
raise InternalServerError()
680695
final_response = stream_chunk.response
681696
final_event_type = stream_chunk.type
682697
case "response.failed":
683698
failed_response = stream_chunk.response
699+
error_message = (
700+
failed_response.error.message
701+
if failed_response.error
702+
else "response failed but no error message was provided"
703+
)
704+
logger.error(
705+
"response creation failed",
706+
extra={
707+
"error_message": error_message,
708+
"response_id": failed_response.id,
709+
"model": model,
710+
},
711+
)
712+
# Surface the provider message — it may be actionable (e.g. context window exceeded)
713+
# and is already visible to callers in streaming mode via the response.failed event.
714+
raise InternalServerError(error_message)
684715
case _:
685716
pass # Other event types don't have .response
686717

687-
if failed_response is not None:
688-
error_message = (
689-
failed_response.error.message
690-
if failed_response and failed_response.error
691-
else "Response stream failed without error details"
692-
)
693-
raise RuntimeError(f"OpenAI response failed: {error_message}")
694-
695718
if final_response is None:
696-
raise ValueError("The response stream never reached a terminal state")
719+
logger.error(
720+
"The response stream never reached a terminal state",
721+
extra={"model": model, "conversation": conversation, "previous_response_id": previous_response_id},
722+
)
723+
raise InternalServerError()
697724
# Set background=False for non-background responses
698725
final_response.background = False
699726
return final_response

src/llama_stack/providers/utils/responses/responses_store.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from llama_stack.core.storage.sqlstore.sqlstore import sqlstore_impl
1111
from llama_stack.log import get_logger
1212
from llama_stack_api import (
13+
InvalidParameterError,
1314
ListOpenAIResponseInputItem,
1415
ListOpenAIResponseObject,
1516
OpenAIDeleteResponseObject,
@@ -18,6 +19,7 @@
1819
OpenAIResponseObject,
1920
OpenAIResponseObjectWithInput,
2021
Order,
22+
ResponseInputItemNotFoundError,
2123
ResponseNotFoundError,
2224
)
2325
from llama_stack_api.internal.sqlstore import ColumnDefinition, ColumnType
@@ -193,7 +195,7 @@ async def get_response_object(self, response_id: str) -> _OpenAIResponseObjectWi
193195
if not row:
194196
# SecureSqlStore will return None if record doesn't exist OR access is denied
195197
# This provides security by not revealing whether the record exists
196-
raise ResponseNotFoundError(response_id)
198+
raise ResponseNotFoundError(response_id) from None
197199

198200
return _OpenAIResponseObjectWithInputAndMessages(**row["response_object"])
199201

@@ -268,7 +270,11 @@ async def list_response_input_items(
268270
if include:
269271
raise NotImplementedError("Include is not supported yet")
270272
if before and after:
271-
raise ValueError("Cannot specify both 'before' and 'after' parameters")
273+
raise InvalidParameterError(
274+
"before/after",
275+
f"before={before!r}, after={after!r}",
276+
"Cannot specify both 'before' and 'after' parameters",
277+
)
272278

273279
response_with_input_and_messages = await self.get_response_object(response_id)
274280
items = response_with_input_and_messages.input
@@ -289,9 +295,9 @@ async def list_response_input_items(
289295
break
290296

291297
if after and start_index == 0:
292-
raise ValueError(f"Input item with id '{after}' not found for response '{response_id}'")
298+
raise ResponseInputItemNotFoundError(after, response_id)
293299
if before and end_index == len(items):
294-
raise ValueError(f"Input item with id '{before}' not found for response '{response_id}'")
300+
raise ResponseInputItemNotFoundError(before, response_id)
295301

296302
items = items[start_index:end_index]
297303

src/llama_stack_api/__init__.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,18 @@
9898
ConflictError,
9999
ConnectorNotFoundError,
100100
ConnectorToolNotFoundError,
101+
ConversationItemNotFoundError,
101102
ConversationNotFoundError,
102103
DatasetNotFoundError,
103-
InvalidConversationIdError,
104+
InternalServerError,
105+
InvalidParameterError,
104106
ModelNotFoundError,
105107
ModelTypeError,
106108
OpenAIFileObjectNotFoundError,
107109
ResourceNotFoundError,
110+
ResponseInputItemNotFoundError,
108111
ResponseNotFoundError,
112+
ServiceNotEnabledError,
109113
TokenValidationError,
110114
ToolGroupNotFoundError,
111115
UnsupportedModelError,
@@ -653,6 +657,7 @@
653657
"Connector",
654658
"ConnectorNotFoundError",
655659
"ConnectorToolNotFoundError",
660+
"ConversationItemNotFoundError",
656661
"ConversationNotFoundError",
657662
"ConnectorInput",
658663
"Connectors",
@@ -736,11 +741,12 @@
736741
"InsertChunksRequest",
737742
"Inspect",
738743
"InspectProviderRequest",
744+
"InternalServerError",
739745
"Admin",
740746
"Int4QuantizationConfig",
741747
"InterleavedContent",
742748
"InterleavedContentItem",
743-
"InvalidConversationIdError",
749+
"InvalidParameterError",
744750
"is_generic_list",
745751
"is_type_optional",
746752
"is_type_union",
@@ -1017,6 +1023,8 @@
10171023
"RerankResponse",
10181024
"Resource",
10191025
"ResourceNotFoundError",
1026+
"ResponseInputItemNotFoundError",
1027+
"ResponseNotFoundError",
10201028
"ResourceType",
10211029
"ResponseFormat",
10221030
"ResponseFormatType",
@@ -1059,6 +1067,7 @@
10591067
"SchemaInfo",
10601068
"SchemaOptions",
10611069
"SearchRankingOptions",
1070+
"ServiceNotEnabledError",
10621071
"Shield",
10631072
"ShieldInput",
10641073
"ShieldStore",

0 commit comments

Comments
 (0)