Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions openhands-sdk/openhands/sdk/llm/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .classifier import (
is_context_window_exceeded,
is_prompt_cache_too_small,
looks_like_auth_error,
looks_like_malformed_conversation_history_error,
)
Expand Down Expand Up @@ -48,6 +49,7 @@
"OperationCancelled",
# Helpers
"is_context_window_exceeded",
"is_prompt_cache_too_small",
"looks_like_auth_error",
"looks_like_malformed_conversation_history_error",
"map_provider_exception",
Expand Down
24 changes: 24 additions & 0 deletions openhands-sdk/openhands/sdk/llm/exceptions/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ def looks_like_malformed_conversation_history_error(exception: Exception) -> boo
return any(p in s for p in MALFORMED_HISTORY_PATTERNS)


# Vertex AI (Gemini) rejects context-caching requests when the cached content
# is below the provider's minimum token threshold (currently 4096 tokens).
# Example error: "The cached content is of 1171 tokens. The minimum token
# count to start caching is 4096." — the `.lower()` comparison handles case
# variation across providers but won't match reworded messages; update this
# pattern if the API phrasing changes.
PROMPT_CACHE_TOO_SMALL_PATTERNS: list[str] = [
"minimum token count to start caching",
]

AUTH_PATTERNS: list[str] = [
"invalid api key",
"unauthorized",
Expand All @@ -85,6 +95,20 @@ def looks_like_malformed_conversation_history_error(exception: Exception) -> boo
]


def is_prompt_cache_too_small(exception: Exception) -> bool:
"""Return True if the error indicates the prompt cache content is too small.

Vertex AI (Gemini) requires a minimum number of tokens (currently 4096)
to create a context cache. When the cached content is below this threshold,
the API returns a 400 error. The SDK should detect this and retry without
prompt caching markers.
"""
if not isinstance(exception, (BadRequestError, OpenAIError)):
return False
Comment thread
juanmichelini marked this conversation as resolved.
Resolved
s = str(exception).lower()
return any(p in s for p in PROMPT_CACHE_TOO_SMALL_PATTERNS)


def looks_like_auth_error(exception: Exception) -> bool:
# Trust the typed exception when the provider/LiteLLM raised an explicit
# 401/403 — its message text may not contain the heuristic patterns below.
Expand Down
77 changes: 77 additions & 0 deletions openhands-sdk/openhands/sdk/llm/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
from openhands.sdk.llm.exceptions import (
LLMContextWindowTooSmallError,
LLMNoResponseError,
is_prompt_cache_too_small,
map_provider_exception,
)

Expand Down Expand Up @@ -1144,6 +1145,7 @@ def completion(
removed_in="1.29.0",
details=_RETURN_METRICS_DETAILS,
)
_caller_kwargs = kwargs.copy()
enable_streaming = bool(kwargs.get("stream", False)) or self.stream
if enable_streaming:
if on_token is None:
Expand Down Expand Up @@ -1183,13 +1185,30 @@ def _one_attempt(**retry_kwargs: Any) -> ModelResponse:
try:
return self._build_completion_result(_one_attempt())
except Exception as e:
# If the prompt cache content is too small for the provider's
# minimum token threshold (e.g., Vertex AI requires ≥4096 tokens),
# retry without prompt caching markers.
if is_prompt_cache_too_small(e) and self.is_caching_prompt_active():
logger.warning(
"Prompt cache content too small for provider minimum, "
Comment thread
juanmichelini marked this conversation as resolved.
Resolved
"retrying without prompt caching"
)
no_cache_llm = self.model_copy(update={"caching_prompt": False})
return no_cache_llm.completion(
messages,
tools,
add_security_risk_prediction=add_security_risk_prediction,
on_token=on_token,
**_caller_kwargs,
)
return self._handle_error(
e,
lambda fb: fb.completion(
messages,
tools,
add_security_risk_prediction=add_security_risk_prediction,
on_token=on_token,
**_caller_kwargs,
),
)
Comment thread
juanmichelini marked this conversation as resolved.
Resolved

Expand Down Expand Up @@ -1217,6 +1236,7 @@ async def acompletion(
removed_in="1.29.0",
details=_RETURN_METRICS_DETAILS,
)
_caller_kwargs = kwargs.copy()
enable_streaming = bool(kwargs.get("stream", False)) or self.stream
if enable_streaming:
if on_token is None:
Expand Down Expand Up @@ -1256,6 +1276,22 @@ async def _one_attempt(**retry_kwargs: Any) -> ModelResponse:
try:
return self._build_completion_result(await _one_attempt())
except Exception as e:
# If the prompt cache content is too small for the provider's
# minimum token threshold (e.g., Vertex AI requires ≥4096 tokens),
# retry without prompt caching markers.
if is_prompt_cache_too_small(e) and self.is_caching_prompt_active():
logger.warning(
"Prompt cache content too small for provider minimum, "
"retrying without prompt caching"
)
no_cache_llm = self.model_copy(update={"caching_prompt": False})
return await no_cache_llm.acompletion(
messages,
tools,
add_security_risk_prediction=add_security_risk_prediction,
on_token=on_token,
**_caller_kwargs,
)
# Fallback is synchronous; cast the token callback since the
# fallback LLM's sync path accepts TokenCallbackType.
_fb_token = cast("TokenCallbackType | None", on_token)
Expand All @@ -1266,6 +1302,7 @@ async def _one_attempt(**retry_kwargs: Any) -> ModelResponse:
tools,
add_security_risk_prediction=add_security_risk_prediction,
on_token=_fb_token,
**_caller_kwargs,
),
)

Expand Down Expand Up @@ -1309,6 +1346,7 @@ def responses(
removed_in="1.29.0",
details=_RETURN_METRICS_DETAILS,
)
_caller_kwargs = kwargs.copy()
user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream
if user_enable_streaming:
# We allow on_token to be None for subscription mode
Expand Down Expand Up @@ -1387,6 +1425,24 @@ def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse:
try:
return self._build_responses_result(_one_attempt())
except Exception as e:
# If the prompt cache content is too small for the provider's
# minimum token threshold (e.g., Vertex AI requires ≥4096 tokens),
# retry without prompt caching markers.
if is_prompt_cache_too_small(e) and self.is_caching_prompt_active():
logger.warning(
"Prompt cache content too small for provider minimum, "
"retrying without prompt caching"
)
no_cache_llm = self.model_copy(update={"caching_prompt": False})
return no_cache_llm.responses(
messages,
tools,
include,
store,
add_security_risk_prediction=add_security_risk_prediction,
on_token=on_token,
**_caller_kwargs,
)
return self._handle_error(
e,
lambda fb: fb.responses(
Expand All @@ -1396,6 +1452,7 @@ def _one_attempt(**retry_kwargs: Any) -> ResponsesAPIResponse:
store,
add_security_risk_prediction=add_security_risk_prediction,
on_token=on_token,
**_caller_kwargs,
),
)

Expand Down Expand Up @@ -1425,6 +1482,7 @@ async def aresponses(
removed_in="1.29.0",
details=_RETURN_METRICS_DETAILS,
)
_caller_kwargs = kwargs.copy()
user_enable_streaming = bool(kwargs.get("stream", False)) or self.stream
if user_enable_streaming:
# We allow on_token to be None for subscription mode
Expand Down Expand Up @@ -1506,6 +1564,24 @@ async def _one_attempt(
try:
return self._build_responses_result(await _one_attempt())
except Exception as e:
# If the prompt cache content is too small for the provider's
# minimum token threshold (e.g., Vertex AI requires ≥4096 tokens),
# retry without prompt caching markers.
if is_prompt_cache_too_small(e) and self.is_caching_prompt_active():
logger.warning(
"Prompt cache content too small for provider minimum, "
"retrying without prompt caching"
)
no_cache_llm = self.model_copy(update={"caching_prompt": False})
return await no_cache_llm.aresponses(
messages,
tools,
include,
store,
add_security_risk_prediction=add_security_risk_prediction,
on_token=on_token,
**_caller_kwargs,
)
_fb_token = cast("TokenCallbackType | None", on_token)
return await self._ahandle_error(
e,
Expand All @@ -1516,6 +1592,7 @@ async def _one_attempt(
store,
add_security_risk_prediction=add_security_risk_prediction,
on_token=_fb_token,
**_caller_kwargs,
),
)

Expand Down
33 changes: 33 additions & 0 deletions tests/sdk/llm/test_exception_classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from openhands.sdk.llm.exceptions import (
is_context_window_exceeded,
is_prompt_cache_too_small,
looks_like_auth_error,
looks_like_malformed_conversation_history_error,
)
Expand Down Expand Up @@ -119,3 +120,35 @@ def test_looks_like_auth_error_negative():
looks_like_auth_error(BadRequestError("Something else", MODEL, PROVIDER))
is False
)


def test_is_prompt_cache_too_small_positive():
"""Vertex AI rejects caching when cached content is below minimum token count."""
vertex_error = BadRequestError(
(
"Vertex_aiException BadRequestError - "
'{"error":{"code":400,'
'"message":"The cached content is of 1171 tokens. '
'The minimum token count to start caching is 4096.",'
'"status":"INVALID_ARGUMENT"}}'
),
MODEL,
PROVIDER,
)
assert is_prompt_cache_too_small(vertex_error) is True


def test_is_prompt_cache_too_small_negative():
assert (
is_prompt_cache_too_small(BadRequestError("irrelevant", MODEL, PROVIDER))
is False
)


def test_is_prompt_cache_too_small_context_window_not_cache_too_small():
"""Context window exceeded is a different error from cache too small."""
ctx_error = BadRequestError(
"The request exceeds the available context size", MODEL, PROVIDER
)
assert is_prompt_cache_too_small(ctx_error) is False
assert is_context_window_exceeded(ctx_error) is True
Loading
Loading