Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5ca143c
Add gemini-3.5-flash model configuration
openhands-agent May 20, 2026
dcea291
Address review: drop -preview suffix and remove verified-list entries
openhands-agent May 20, 2026
6fb1bf5
Fix prompt cache too small error for Vertex AI Gemini models
openhands-agent May 25, 2026
8362654
Address review feedback: preserve caller kwargs in cache retry, fix t…
openhands-agent May 26, 2026
31d1aaf
test: add responses() prompt-cache retry coverage
openhands-agent May 27, 2026
46ebff1
Merge branch 'main' into openhands/add-gemini-3.5-flash
juanmichelini May 28, 2026
f84be45
Merge branch 'main' into openhands/add-gemini-3.5-flash
openhands-agent Jun 2, 2026
e260ae1
Merge branch 'main' into openhands/add-gemini-3.5-flash
juanmichelini Jun 2, 2026
82fd17f
Add prompt-cache-too-small retry to acompletion() and aresponses()
openhands-agent Jun 2, 2026
1282c55
Remove misleading sync-only section marker in completion tests
openhands-agent Jun 2, 2026
d177acd
Forward caller kwargs through async _ahandle_error fallback lambdas
openhands-agent Jun 2, 2026
fca0ab8
Merge branch 'main' into openhands/add-gemini-3.5-flash
juanmichelini Jun 2, 2026
6fba784
Merge branch 'main' into openhands/add-gemini-3.5-flash
juanmichelini Jun 2, 2026
e149879
Merge branch 'main' into openhands/add-gemini-3.5-flash
juanmichelini Jun 2, 2026
0cbdb10
Add metadata kwarg assertion to sync cache-retry test
openhands-agent Jun 2, 2026
cef1295
Merge branch 'main' into openhands/add-gemini-3.5-flash
juanmichelini Jun 2, 2026
790e9aa
Merge branch 'main' into openhands/add-gemini-3.5-flash
juanmichelini Jun 2, 2026
db42456
Update tests/cross/test_resolve_model_config.py
juanmichelini Jun 3, 2026
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
19 changes: 14 additions & 5 deletions .github/run-eval/ADDINGMODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,20 @@ This file (`resolve_model_config.py`) defines models available for evaluation. M
- `openhands-sdk/openhands/sdk/llm/utils/model_prompt_spec.py` - GPT models only (variant detection)
- `openhands-sdk/openhands/sdk/llm/utils/verified_models.py` - Production-ready models

> ⚠️ **When editing `verified_models.py`**: If you add a model to `VERIFIED_OPENHANDS_MODELS`,
> you **must also** add it to its provider-specific list (e.g. `VERIFIED_ANTHROPIC_MODELS`,
> `VERIFIED_GEMINI_MODELS`, `VERIFIED_MOONSHOT_MODELS`, etc.).
> If no list exists for the provider yet, create one and add it to the `VERIFIED_MODELS` dict.
> This ensures the model appears under its actual provider in the UI, not just under "openhands".
> ⛔ **Do NOT add a model to `verified_models.py` unless explicitly asked to.**
Comment thread
juanmichelini marked this conversation as resolved.
> "Verified" means the model has been validated against the OpenHands integration
> test suite **and** an OpenHands maintainer has approved it for the production UI.
> A passing integration run is *necessary but not sufficient*. New models should be
> added to `MODELS` in `resolve_model_config.py` (and `model_features.py` if
> applicable) only — leave `verified_models.py` alone until a maintainer requests it
> in the PR.
>
> ⚠️ **When you are explicitly asked to edit `verified_models.py`**: If you add a
> model to `VERIFIED_OPENHANDS_MODELS`, you **must also** add it to its
> provider-specific list (e.g. `VERIFIED_ANTHROPIC_MODELS`, `VERIFIED_GEMINI_MODELS`,
> `VERIFIED_MOONSHOT_MODELS`, etc.). If no list exists for the provider yet, create
> one and add it to the `VERIFIED_MODELS` dict. This ensures the model appears under
> its actual provider in the UI, not just under "openhands".

## Step 1: Add to resolve_model_config.py

Expand Down
8 changes: 8 additions & 0 deletions .github/run-eval/resolve_model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ def _sigterm_handler(signum: int, _frame: object) -> None:
"temperature": 0.0,
},
},
"gemini-3.5-flash": {
"id": "gemini-3.5-flash",
"display_name": "Gemini 3.5 Flash",
"llm_config": {
"model": "litellm_proxy/gemini-3.5-flash",
Comment thread
juanmichelini marked this conversation as resolved.
Comment thread
juanmichelini marked this conversation as resolved.
Comment thread
juanmichelini marked this conversation as resolved.
Comment thread
juanmichelini marked this conversation as resolved.
"temperature": 0.0,
},
},
"gpt-5.2": {
"id": "gpt-5.2",
"display_name": "GPT-5.2",
Expand Down
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",
Comment thread
juanmichelini marked this conversation as resolved.
Comment thread
juanmichelini marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pattern fragility: PROMPT_CACHE_TOO_SMALL_PATTERNS contains a single, narrow substring. If Vertex AI rewords its error (e.g., "minimum number of tokens to cache") or another provider surfaces a similar constraint with different phrasing, this guard silently fails and the original BadRequestError propagates — which is safe, but surprising.

Consider adding at least one alternative phrasing (e.g., "minimum number of tokens") or a comment explicitly directing maintainers to extend the list when new providers or rephrased messages are encountered.

]

AUTH_PATTERNS: list[str] = [
"invalid api key",
"unauthorized",
Expand All @@ -85,6 +95,20 @@ def looks_like_malformed_conversation_history_error(exception: Exception) -> boo
]
Comment thread
juanmichelini marked this conversation as resolved.


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)):
Comment thread
juanmichelini marked this conversation as resolved.
return False
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():
Comment thread
juanmichelini marked this conversation as resolved.
Comment thread
juanmichelini marked this conversation as resolved.
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.completion(
Comment thread
juanmichelini marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retry-budget doubling: no_cache_llm.completion(...) re-enters the full completion() method, giving the no-cache retry its own complete num_retries budget. A single cache-too-small failure can therefore produce up to 2 × (num_retries + 1) total requests — potentially surprising and costly for callers with a large retry count.

The same pattern is replicated in acompletion, responses, and aresponses. If the intent is a single best-effort no-cache attempt rather than a fully retriable new invocation, consider calling the underlying litellm function directly with the cache markers removed, bypassing the outer retry loop.

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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implicit bug fix: caller kwargs now forwarded to fallback LLM

Before this PR the fallback lambda was:

lambda fb: fb.completion(messages, tools, add_security_risk_prediction=add_security_risk_prediction, on_token=on_token)

Extra caller kwargs (e.g. metadata, logit_bias, custom headers) were silently dropped whenever a fallback LLM took over. The new **_caller_kwargs restores them. This fix is applied consistently across all four methods (completion, acompletion, responses, aresponses) — good.

Worth calling out in the PR description since it affects any caller with a configured fallback LLM, not just Vertex AI cache users.

),
)

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():
Comment thread
juanmichelini marked this conversation as resolved.
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.
Comment thread
juanmichelini marked this conversation as resolved.
_fb_token = cast("TokenCallbackType | None", on_token)
Comment thread
juanmichelini marked this conversation as resolved.
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),
Comment thread
juanmichelini marked this conversation as resolved.
# 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(
Comment thread
juanmichelini marked this conversation as resolved.
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
10 changes: 10 additions & 0 deletions tests/cross/test_resolve_model_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,16 @@ def test_deepseek_v4_flash_config():
assert model["llm_config"]["model"] == "litellm_proxy/deepseek/deepseek-v4-flash"


def test_gemini_3_5_flash_config():
"""Test that gemini-3.5-flash has correct configuration."""
model = MODELS["gemini-3.5-flash"]

assert model["id"] == "gemini-3.5-flash"
assert model["display_name"] == "Gemini 3.5 Flash"
assert model["llm_config"]["model"] == "litellm_proxy/gemini-3.5-flash"
Comment thread
juanmichelini marked this conversation as resolved.
Comment thread
juanmichelini marked this conversation as resolved.
assert model["llm_config"]["temperature"] == 0.0


def test_nemotron_3_ultra_550b_a55b_config():
"""Test that nemotron-3-ultra-550b-a55b has correct configuration."""
model = MODELS["nemotron-3-ultra-550b-a55b"]
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