Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 74 additions & 11 deletions unstract/sdk1/src/unstract/sdk1/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ def complete(self, prompt: str, **kwargs: object) -> dict[str, object]:
)

response_text = response["choices"][0]["message"]["content"]
finish_reason = response["choices"][0].get("finish_reason")

# Handle refusal or empty content from the LLM provider
if response_text is None:
self._raise_for_empty_response(finish_reason)

self._record_usage(
self.kwargs["model"], messages, response.get("usage"), "complete"
Expand Down Expand Up @@ -306,17 +311,9 @@ def stream_complete(
"stream_complete",
)

text = chunk["choices"][0]["delta"].get("content", "")

if text:
if callback_manager and hasattr(callback_manager, "on_stream"):
callback_manager.on_stream(text)

# Yield LLMResponseCompat for backward compatibility
# with code expecting .delta
stream_response = LLMResponseCompat(text)
stream_response.delta = text
yield stream_response
response = self._process_stream_chunk(chunk, callback_manager)
if response is not None:
yield response

except LLMError:
# Already wrapped LLMError, re-raise as is
Expand Down Expand Up @@ -362,6 +359,11 @@ async def acomplete(self, prompt: str, **kwargs: object) -> dict[str, object]:
**completion_kwargs,
)
response_text = response["choices"][0]["message"]["content"]
finish_reason = response["choices"][0].get("finish_reason")

# Handle refusal or empty content from the LLM provider
if response_text is None:
self._raise_for_empty_response(finish_reason)

self._record_usage(
self.kwargs["model"], messages, response.get("usage"), "acomplete"
Expand Down Expand Up @@ -470,6 +472,67 @@ def _record_usage(
kwargs={"provider": self.adapter.get_provider(), **self.platform_kwargs},
)

def _raise_for_empty_response(self, finish_reason: str | None) -> None:
"""Raise an appropriate error when the LLM response content is None.

This typically happens when the LLM provider refuses to generate a
response (e.g. Anthropic's safety filters) or returns an empty response.

Args:
finish_reason: The finish_reason from the LLM response.

Raises:
LLMError: With a descriptive message based on the finish_reason.
"""
if finish_reason == "refusal":
raise LLMError(
message=(
"The LLM refused to generate a response due to safety "
"restrictions. Please review your prompt and try again."
),
status_code=400,
)
raise LLMError(
message=(
f"The LLM returned an empty response "
f"(finish_reason: {finish_reason}). This may indicate "
f"the model could not generate content for the given prompt."
),
status_code=500,
)

def _process_stream_chunk(
self,
chunk: dict[str, object],
callback_manager: object | None,
) -> LLMResponseCompat | None:
"""Process a single streaming chunk and return a response if content.

Args:
chunk: A streaming chunk from litellm.
callback_manager: Optional callback manager for stream events.

Returns:
LLMResponseCompat with the text chunk, or None if no content.

Raises:
LLMError: If the chunk indicates a refusal.
"""
finish_reason = chunk["choices"][0].get("finish_reason")
if finish_reason == "refusal":
self._raise_for_empty_response(finish_reason)

text = chunk["choices"][0]["delta"].get("content", "")
if not text:
return None

if callback_manager and hasattr(callback_manager, "on_stream"):
callback_manager.on_stream(text)

stream_response = LLMResponseCompat(text)
stream_response.delta = text
return stream_response

def _post_process_response(
self,
response_text: str,
Expand Down
4 changes: 3 additions & 1 deletion unstract/sdk1/src/unstract/sdk1/utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,12 @@ def __init__(self, text: str) -> None:

def __str__(self) -> str:
"""Return text for string operations like join()."""
return self.text
return self.text or ""

def __repr__(self) -> str:
"""Return detailed representation with text preview."""
if self.text is None:
return "LLMResponseCompat(text=None)"
text_preview = self.text[:50] + "..." if len(self.text) > 50 else self.text
return f"LLMResponseCompat(text={text_preview!r})"

Expand Down