diff --git a/.github/workflows/run-eval.yml b/.github/workflows/run-eval.yml index b00d70a8cd..d91cf76539 100644 --- a/.github/workflows/run-eval.yml +++ b/.github/workflows/run-eval.yml @@ -26,7 +26,8 @@ on: sdk_ref: description: SDK commit/ref to evaluate (must be a semantic version like v1.0.0 unless 'Allow unreleased branches' is checked) required: true - default: v1.27.0 + default: v1.27.1 + diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index a2489d014f..766c72c42f 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-agent-server" -version = "1.27.0" +version = "1.27.1" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" requires-python = ">=3.12" diff --git a/openhands-sdk/openhands/sdk/llm/llm.py b/openhands-sdk/openhands/sdk/llm/llm.py index df2dd3f0dc..2f15586f61 100644 --- a/openhands-sdk/openhands/sdk/llm/llm.py +++ b/openhands-sdk/openhands/sdk/llm/llm.py @@ -2100,8 +2100,9 @@ def _apply_prompt_caching(self, messages: list[Message]) -> None: # Single block: mark it for caching sys_content[0].cache_prompt = True - # Anthropic and Gemini both use these cache_control markers. LiteLLM - # performs the provider-specific cache setup for Gemini downstream. + # Second breakpoint: mark the last user/tool message so the cached prefix + # extends every turn. Anthropic-only; Gemini is excluded from + # PROMPT_CACHE_MODELS because its cache can't extend this way. for message in reversed(messages): if message.role in ("user", "tool"): message.content[ diff --git a/openhands-sdk/openhands/sdk/llm/utils/model_features.py b/openhands-sdk/openhands/sdk/llm/utils/model_features.py index 34dc4a90f9..497a385a75 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/model_features.py +++ b/openhands-sdk/openhands/sdk/llm/utils/model_features.py @@ -117,10 +117,9 @@ def _supports_reasoning_effort(model: str | None) -> bool: "claude-opus-4-7", "claude-opus-4-8", "claude-sonnet-4-6", - # Gemini uses the same cache_control marker format. LiteLLM handles - # Vertex/Gemini context-cache creation when these markers are present. - "gemini-2.5", - "gemini-3", + # Do NOT add Gemini: explicit cache_control markers freeze its cache at the + # static prefix and disable Google's implicit caching on the growing body + # (~6-14x cost). Gemini uses implicit prefix caching instead. ] # Models that support a top-level prompt_cache_retention parameter diff --git a/openhands-sdk/pyproject.toml b/openhands-sdk/pyproject.toml index d0bc52e11e..fe2d301633 100644 --- a/openhands-sdk/pyproject.toml +++ b/openhands-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-sdk" -version = "1.27.0" +version = "1.27.1" description = "OpenHands SDK - Core functionality for building AI agents" requires-python = ">=3.12" diff --git a/openhands-tools/pyproject.toml b/openhands-tools/pyproject.toml index 8b835f3714..56672931a0 100644 --- a/openhands-tools/pyproject.toml +++ b/openhands-tools/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-tools" -version = "1.27.0" +version = "1.27.1" description = "OpenHands Tools - Runtime tools for AI agents" requires-python = ">=3.12" diff --git a/openhands-workspace/pyproject.toml b/openhands-workspace/pyproject.toml index 30bd8d6164..065549261b 100644 --- a/openhands-workspace/pyproject.toml +++ b/openhands-workspace/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openhands-workspace" -version = "1.27.0" +version = "1.27.1" description = "OpenHands Workspace - Docker and container-based workspace implementations" requires-python = ">=3.12" diff --git a/tests/sdk/llm/test_model_features.py b/tests/sdk/llm/test_model_features.py index 5b6de7f6e8..b2adaff3e7 100644 --- a/tests/sdk/llm/test_model_features.py +++ b/tests/sdk/llm/test_model_features.py @@ -123,10 +123,11 @@ def test_extended_thinking_support(model, expected_extended_thinking): ("anthropic.claude-3-5-sonnet-20241022", True), ("anthropic.claude-3-haiku-20240307", True), ("anthropic.claude-3-opus-20240229", True), - # Gemini explicit context caching through LiteLLM. - ("gemini-2.5-pro", True), - ("gemini-3.1-pro-preview", True), - ("litellm_proxy/gemini-3.1-pro-preview", True), + # Gemini must NOT use explicit cache_control markers: they freeze the + # cache at the static prefix and disable Google's implicit caching. + ("gemini-2.5-pro", False), + ("gemini-3.1-pro-preview", False), + ("litellm_proxy/gemini-3.1-pro-preview", False), ("gpt-4o", False), # OpenAI doesn't support explicit prompt caching ("gemini-1.5-pro", False), ("unknown-model", False), diff --git a/tests/sdk/llm/test_prompt_caching_cross_conversation.py b/tests/sdk/llm/test_prompt_caching_cross_conversation.py index 55a7c257cd..6c7b84db92 100644 --- a/tests/sdk/llm/test_prompt_caching_cross_conversation.py +++ b/tests/sdk/llm/test_prompt_caching_cross_conversation.py @@ -144,13 +144,24 @@ def on_event(event): assert messages[1].content[-1].cache_prompt is True -def test_gemini_prompt_caching_marks_formatted_messages(): - """Gemini models should emit cache_control markers when caching is enabled.""" +def test_gemini_prompt_caching_emits_no_markers(): + """REGRESSION: Gemini must not emit explicit cache_control markers. + + Explicit markers freeze Gemini's cache at the static prefix and disable + Google's implicit caching on the growing body (~6-14x cost). No markers keeps + Gemini on the implicit-caching path, where the cached prefix grows. + """ llm = LLM( model="litellm_proxy/gemini-3.1-pro-preview", usage_id="test", caching_prompt=True, ) + + # Explicit-breakpoint caching must be inactive for Gemini. + assert llm.is_caching_prompt_active() is False + + # System (index 0) and last user message (index 3) are non-adjacent — the + # case that froze the LiteLLM/Vertex cache at the static prefix. messages = [ Message( role="system", @@ -159,19 +170,21 @@ def test_gemini_prompt_caching_marks_formatted_messages(): TextContent(text="Dynamic context"), ], ), - Message( - role="user", - content=[TextContent(text="Hello")], - ), + Message(role="user", content=[TextContent(text="First question")]), + Message(role="assistant", content=[TextContent(text="First answer")]), + Message(role="user", content=[TextContent(text="Second question")]), ] formatted_messages = llm.format_messages_for_llm(messages) - system_content = formatted_messages[0]["content"] - user_content = formatted_messages[1]["content"] - assert system_content[0]["cache_control"] == {"type": "ephemeral"} - assert "cache_control" not in system_content[1] - assert user_content[-1]["cache_control"] == {"type": "ephemeral"} + # No cache_control marker anywhere in the formatted payload. + for message in formatted_messages: + assert "cache_control" not in message + content = message.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict): + assert "cache_control" not in block @pytest.mark.parametrize( diff --git a/tests/sdk/llm/test_responses_parsing_and_kwargs.py b/tests/sdk/llm/test_responses_parsing_and_kwargs.py index 4aa99895f3..92ec7d9f32 100644 --- a/tests/sdk/llm/test_responses_parsing_and_kwargs.py +++ b/tests/sdk/llm/test_responses_parsing_and_kwargs.py @@ -338,8 +338,9 @@ def test_responses_retries_without_caching_on_prompt_cache_too_small(mock_respon # Pick a model that supports prompt caching so is_caching_prompt_active() # is True and the retry branch is reachable on the responses() path. + # (Gemini no longer uses explicit caching, so use an Anthropic model here.) llm = LLM( - model="gemini-3-flash", + model="claude-sonnet-4-20250514", api_key=SecretStr("test_key"), usage_id="test-llm", caching_prompt=True, @@ -417,8 +418,10 @@ async def test_aresponses_retries_without_caching_on_prompt_cache_too_small( ) mock_aresponses.side_effect = [cache_error, success_resp] + # Anthropic model so is_caching_prompt_active() is True (Gemini no longer + # uses explicit caching); mirrors the sync test above. llm = LLM( - model="gemini-3-flash", + model="claude-sonnet-4-20250514", api_key=SecretStr("test_key"), usage_id="test-llm", caching_prompt=True, diff --git a/uv.lock b/uv.lock index 463163b322..eec62410e3 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-06-03T20:34:27.903856Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -2458,7 +2458,7 @@ wheels = [ [[package]] name = "openhands-agent-server" -version = "1.27.0" +version = "1.27.1" source = { editable = "openhands-agent-server" } dependencies = [ { name = "aiosqlite" }, @@ -2489,7 +2489,7 @@ requires-dist = [ [[package]] name = "openhands-sdk" -version = "1.27.0" +version = "1.27.1" source = { editable = "openhands-sdk" } dependencies = [ { name = "agent-client-protocol" }, @@ -2541,7 +2541,7 @@ provides-extras = ["boto3"] [[package]] name = "openhands-tools" -version = "1.27.0" +version = "1.27.1" source = { editable = "openhands-tools" } dependencies = [ { name = "binaryornot" }, @@ -2572,7 +2572,7 @@ requires-dist = [ [[package]] name = "openhands-workspace" -version = "1.27.0" +version = "1.27.1" source = { editable = "openhands-workspace" } dependencies = [ { name = "openhands-agent-server" },