From 3ae09680d6606e68cdb41769f28ab98414f766e0 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Tue, 30 Dec 2025 11:35:14 -0500 Subject: [PATCH 1/6] Release 1.1.0 (#12212) --- Development.md | 2 +- containers/dev/compose.yml | 2 +- docker-compose.yml | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- openhands/runtime/impl/kubernetes/README.md | 2 +- pyproject.toml | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Development.md b/Development.md index 421959a5ec..028d71514c 100644 --- a/Development.md +++ b/Development.md @@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image. -Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.0-nikolaik` +Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.1-nikolaik` ## Develop inside Docker container diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index 7ff5042081..78549feb68 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -12,7 +12,7 @@ services: - SANDBOX_API_HOSTNAME=host.docker.internal - DOCKER_HOST_ADDR=host.docker.internal # - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.0-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.1-nikolaik} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/docker-compose.yml b/docker-compose.yml index d4aef552c0..7e879f09b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: image: openhands:latest container_name: openhands-app-${DATE:-} environment: - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.0-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.1-nikolaik} #- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ada75eec74..2cc295d243 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@heroui/react": "2.8.7", "@microlink/react-json-view": "^1.26.2", diff --git a/frontend/package.json b/frontend/package.json index f993462524..9003680543 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "1.0.0", + "version": "1.1.0", "private": true, "type": "module", "engines": { diff --git a/openhands/runtime/impl/kubernetes/README.md b/openhands/runtime/impl/kubernetes/README.md index 36b7452d1e..c9f5ccfdf2 100644 --- a/openhands/runtime/impl/kubernetes/README.md +++ b/openhands/runtime/impl/kubernetes/README.md @@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime: 2. **Runtime Container Image**: Specify the container image to use for the runtime environment ```toml [sandbox] - runtime_container_image = "docker.openhands.dev/openhands/runtime:1.0-nikolaik" + runtime_container_image = "docker.openhands.dev/openhands/runtime:1.1-nikolaik" ``` #### Additional Kubernetes Options diff --git a/pyproject.toml b/pyproject.toml index 94ecd393e4..fc5e8fab98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = [ [tool.poetry] name = "openhands-ai" -version = "1.0.0" +version = "1.1.0" description = "OpenHands: Code Less, Make More" authors = [ "OpenHands" ] license = "MIT" From b5758b160415455976de704e0ead4567b0c74c2d Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Tue, 30 Dec 2025 12:59:51 -0500 Subject: [PATCH 2/6] Update GithubIntegration to use auth=Auth.AppAuth() (#12204) Co-authored-by: openhands --- .../integrations/github/data_collector.py | 4 ++-- .../integrations/github/github_manager.py | 4 ++-- enterprise/integrations/github/github_view.py | 6 +++--- .../github_v1_callback_processor.py | 5 ++--- .../test_github_v1_callback_processor.py | 21 ++++++++++++++----- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/enterprise/integrations/github/data_collector.py b/enterprise/integrations/github/data_collector.py index f6b2477e7e..2d1f4b1b18 100644 --- a/enterprise/integrations/github/data_collector.py +++ b/enterprise/integrations/github/data_collector.py @@ -6,7 +6,7 @@ from datetime import datetime from enum import Enum from typing import Any -from github import Github, GithubIntegration +from github import Auth, Github, GithubIntegration from integrations.github.github_view import ( GithubIssue, ) @@ -84,7 +84,7 @@ class GitHubDataCollector: # self.full_saved_pr_path = 'github_data/prs/{}-{}/data.json' self.full_saved_pr_path = 'prs/github/{}-{}/data.json' self.github_integration = GithubIntegration( - GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY + auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY) ) self.conversation_id = None diff --git a/enterprise/integrations/github/github_manager.py b/enterprise/integrations/github/github_manager.py index 00ad5124ce..8b5d9fc542 100644 --- a/enterprise/integrations/github/github_manager.py +++ b/enterprise/integrations/github/github_manager.py @@ -1,6 +1,6 @@ from types import MappingProxyType -from github import Github, GithubIntegration +from github import Auth, Github, GithubIntegration from integrations.github.data_collector import GitHubDataCollector from integrations.github.github_solvability import summarize_issue_solvability from integrations.github.github_view import ( @@ -43,7 +43,7 @@ class GithubManager(Manager): self.token_manager = token_manager self.data_collector = data_collector self.github_integration = GithubIntegration( - GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY + auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY) ) self.jinja_env = Environment( diff --git a/enterprise/integrations/github/github_view.py b/enterprise/integrations/github/github_view.py index a01457f88c..a37ae04c33 100644 --- a/enterprise/integrations/github/github_view.py +++ b/enterprise/integrations/github/github_view.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from uuid import UUID, uuid4 -from github import Github, GithubIntegration +from github import Auth, Github, GithubIntegration from github.Issue import Issue from integrations.github.github_types import ( WorkflowRun, @@ -729,7 +729,7 @@ class GithubFactory: def _interact_with_github() -> Issue | None: with GithubIntegration( - GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY + auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY) ) as integration: access_token = integration.get_access_token( payload['installation']['id'] @@ -867,7 +867,7 @@ class GithubFactory: access_token = '' with GithubIntegration( - GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY + auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY) ) as integration: access_token = integration.get_access_token(installation_id).token diff --git a/openhands/app_server/event_callback/github_v1_callback_processor.py b/openhands/app_server/event_callback/github_v1_callback_processor.py index 1a83bed9c0..dc48ab5dcf 100644 --- a/openhands/app_server/event_callback/github_v1_callback_processor.py +++ b/openhands/app_server/event_callback/github_v1_callback_processor.py @@ -4,7 +4,7 @@ from typing import Any from uuid import UUID import httpx -from github import Github, GithubIntegration +from github import Auth, Github, GithubIntegration from pydantic import Field from openhands.agent_server.models import AskAgentRequest, AskAgentResponse @@ -124,8 +124,7 @@ class GithubV1CallbackProcessor(EventCallbackProcessor): raise ValueError('GitHub App credentials are not configured') github_integration = GithubIntegration( - github_app_client_id, - github_app_private_key, + auth=Auth.AppAuth(github_app_client_id, github_app_private_key), ) token_data = github_integration.get_access_token(installation_id) return token_data.token diff --git a/tests/unit/app_server/test_github_v1_callback_processor.py b/tests/unit/app_server/test_github_v1_callback_processor.py index acf958a8e3..037485e278 100644 --- a/tests/unit/app_server/test_github_v1_callback_processor.py +++ b/tests/unit/app_server/test_github_v1_callback_processor.py @@ -211,6 +211,7 @@ class TestGithubV1CallbackProcessor: @patch( 'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template' ) + @patch('openhands.app_server.event_callback.github_v1_callback_processor.Auth') @patch( 'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration' ) @@ -219,6 +220,7 @@ class TestGithubV1CallbackProcessor: self, mock_github, mock_github_integration, + mock_auth, mock_get_prompt_template, mock_get_httpx_client, mock_get_sandbox_service, @@ -242,6 +244,10 @@ class TestGithubV1CallbackProcessor: mock_get_prompt_template.return_value = 'Please provide a summary' + # Auth.AppAuth mock + mock_app_auth_instance = MagicMock() + mock_auth.AppAuth.return_value = mock_app_auth_instance + # GitHub integration mock_token_data = MagicMock() mock_token_data.token = 'test_access_token' @@ -271,9 +277,8 @@ class TestGithubV1CallbackProcessor: assert result.detail == 'Test summary from agent' assert github_callback_processor.should_request_summary is False - mock_github_integration.assert_called_once_with( - 'test_client_id', 'test_private_key' - ) + mock_auth.AppAuth.assert_called_once_with('test_client_id', 'test_private_key') + mock_github_integration.assert_called_once_with(auth=mock_app_auth_instance) mock_integration_instance.get_access_token.assert_called_once_with(12345) mock_github.assert_called_once_with('test_access_token') @@ -618,12 +623,17 @@ class TestGithubV1CallbackProcessor: 'GITHUB_APP_PRIVATE_KEY': 'test_private_key\\nwith_newlines', }, ) + @patch('openhands.app_server.event_callback.github_v1_callback_processor.Auth') @patch( 'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration' ) def test_get_installation_access_token_success( - self, mock_github_integration, github_callback_processor + self, mock_github_integration, mock_auth, github_callback_processor ): + # Auth.AppAuth mock + mock_app_auth_instance = MagicMock() + mock_auth.AppAuth.return_value = mock_app_auth_instance + mock_token_data = MagicMock() mock_token_data.token = 'test_access_token' mock_integration_instance = MagicMock() @@ -633,9 +643,10 @@ class TestGithubV1CallbackProcessor: token = github_callback_processor._get_installation_access_token() assert token == 'test_access_token' - mock_github_integration.assert_called_once_with( + mock_auth.AppAuth.assert_called_once_with( 'test_client_id', 'test_private_key\nwith_newlines' ) + mock_github_integration.assert_called_once_with(auth=mock_app_auth_instance) mock_integration_instance.get_access_token.assert_called_once_with(12345) @patch('openhands.app_server.event_callback.github_v1_callback_processor.Github') From 06a97fc3826db73035db9e0147da9c7950c2a6ac Mon Sep 17 00:00:00 2001 From: OpenHands Bot Date: Wed, 31 Dec 2025 02:47:14 +0800 Subject: [PATCH 3/6] Bump SDK packages to v1.7.3 (#12218) Co-authored-by: Tim O'Farrell --- enterprise/poetry.lock | 26 +++++++++---------- .../sandbox/sandbox_spec_service.py | 2 +- poetry.lock | 20 +++++++------- pyproject.toml | 6 ++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index ca4b190ec1..fbb1e8c5ea 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -5836,14 +5836,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0 [[package]] name = "openhands-agent-server" -version = "1.7.1" +version = "1.7.3" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.7.1-py3-none-any.whl", hash = "sha256:e5c57f1b73293d00a68b77f9d290f59d9e2217d9df844fb01c7d2f929c3417f4"}, - {file = "openhands_agent_server-1.7.1.tar.gz", hash = "sha256:c82e1e6748ea3b4278ef2ee72f091dc37da6667c854b3aa3c0bc616086a82310"}, + {file = "openhands_agent_server-1.7.3-py3-none-any.whl", hash = "sha256:456e7162cefec8ed7fda61433180b3f867265e15c7151b3a2e3e02546c9d9b6d"}, + {file = "openhands_agent_server-1.7.3.tar.gz", hash = "sha256:2c06dc497c38050d445559da2825d4d69fe84af90289c82a95317e45359cc547"}, ] [package.dependencies] @@ -5860,7 +5860,7 @@ wsproto = ">=1.2.0" [[package]] name = "openhands-ai" -version = "0.0.0-post.5742+ee50f333b" +version = "0.0.0-post.5769+b5758b160" description = "OpenHands: Code Less, Make More" optional = false python-versions = "^3.12,<3.14" @@ -5902,9 +5902,9 @@ memory-profiler = "^0.61.0" numpy = "*" openai = "2.8.0" openhands-aci = "0.3.2" -openhands-agent-server = "1.7.1" -openhands-sdk = "1.7.1" -openhands-tools = "1.7.1" +openhands-agent-server = "1.7.3" +openhands-sdk = "1.7.3" +openhands-tools = "1.7.3" opentelemetry-api = "^1.33.1" opentelemetry-exporter-otlp-proto-grpc = "^1.33.1" pathspec = "^0.12.1" @@ -5960,14 +5960,14 @@ url = ".." [[package]] name = "openhands-sdk" -version = "1.7.1" +version = "1.7.3" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.7.1-py3-none-any.whl", hash = "sha256:e097e34dfbd45f38225ae2ff4830702424bcf742bc197b5a811540a75265b135"}, - {file = "openhands_sdk-1.7.1.tar.gz", hash = "sha256:e13d1fe8bf14dffd91e9080608072a989132c981cf9bfcd124fa4f7a68a13691"}, + {file = "openhands_sdk-1.7.3-py3-none-any.whl", hash = "sha256:afbce9c9e7d1167d9b9610673657fbbcd454b04f0151d943418d897de790aeed"}, + {file = "openhands_sdk-1.7.3.tar.gz", hash = "sha256:7fa0cde9148ab905e24346b50f2d7267fb6dde32ec8dcbc1c7d35ced6e0233aa"}, ] [package.dependencies] @@ -5987,14 +5987,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.7.1" +version = "1.7.3" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.7.1-py3-none-any.whl", hash = "sha256:e25815f24925e94fbd4d8c3fd9b2147a0556fde595bf4f80a7dbba1014ea3c86"}, - {file = "openhands_tools-1.7.1.tar.gz", hash = "sha256:f3823f7bd302c78969c454730cf793eb63109ce2d986e78585989c53986cc966"}, + {file = "openhands_tools-1.7.3-py3-none-any.whl", hash = "sha256:e823f5a47936dd23221cb4eb846d62b59dce5be69210330095fc242772e71d27"}, + {file = "openhands_tools-1.7.3.tar.gz", hash = "sha256:f2779cc5ca3b78b9afebb7617006da8069c12b41e6d67cbf0cc8de5d819005f8"}, ] [package.dependencies] diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index a3f77db134..0338bc822d 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin # The version of the agent server to use for deployments. # Typically this will be the same as the values from the pyproject.toml -AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:0b7ccc9-python' +AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:5cbfbf7-python' class SandboxSpecService(ABC): diff --git a/poetry.lock b/poetry.lock index 61097d2151..df339a9c03 100644 --- a/poetry.lock +++ b/poetry.lock @@ -7380,14 +7380,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0 [[package]] name = "openhands-agent-server" -version = "1.7.1" +version = "1.7.3" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_agent_server-1.7.1-py3-none-any.whl", hash = "sha256:e5c57f1b73293d00a68b77f9d290f59d9e2217d9df844fb01c7d2f929c3417f4"}, - {file = "openhands_agent_server-1.7.1.tar.gz", hash = "sha256:c82e1e6748ea3b4278ef2ee72f091dc37da6667c854b3aa3c0bc616086a82310"}, + {file = "openhands_agent_server-1.7.3-py3-none-any.whl", hash = "sha256:456e7162cefec8ed7fda61433180b3f867265e15c7151b3a2e3e02546c9d9b6d"}, + {file = "openhands_agent_server-1.7.3.tar.gz", hash = "sha256:2c06dc497c38050d445559da2825d4d69fe84af90289c82a95317e45359cc547"}, ] [package.dependencies] @@ -7404,14 +7404,14 @@ wsproto = ">=1.2.0" [[package]] name = "openhands-sdk" -version = "1.7.1" +version = "1.7.3" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_sdk-1.7.1-py3-none-any.whl", hash = "sha256:e097e34dfbd45f38225ae2ff4830702424bcf742bc197b5a811540a75265b135"}, - {file = "openhands_sdk-1.7.1.tar.gz", hash = "sha256:e13d1fe8bf14dffd91e9080608072a989132c981cf9bfcd124fa4f7a68a13691"}, + {file = "openhands_sdk-1.7.3-py3-none-any.whl", hash = "sha256:afbce9c9e7d1167d9b9610673657fbbcd454b04f0151d943418d897de790aeed"}, + {file = "openhands_sdk-1.7.3.tar.gz", hash = "sha256:7fa0cde9148ab905e24346b50f2d7267fb6dde32ec8dcbc1c7d35ced6e0233aa"}, ] [package.dependencies] @@ -7431,14 +7431,14 @@ boto3 = ["boto3 (>=1.35.0)"] [[package]] name = "openhands-tools" -version = "1.7.1" +version = "1.7.3" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "openhands_tools-1.7.1-py3-none-any.whl", hash = "sha256:e25815f24925e94fbd4d8c3fd9b2147a0556fde595bf4f80a7dbba1014ea3c86"}, - {file = "openhands_tools-1.7.1.tar.gz", hash = "sha256:f3823f7bd302c78969c454730cf793eb63109ce2d986e78585989c53986cc966"}, + {file = "openhands_tools-1.7.3-py3-none-any.whl", hash = "sha256:e823f5a47936dd23221cb4eb846d62b59dce5be69210330095fc242772e71d27"}, + {file = "openhands_tools-1.7.3.tar.gz", hash = "sha256:f2779cc5ca3b78b9afebb7617006da8069c12b41e6d67cbf0cc8de5d819005f8"}, ] [package.dependencies] @@ -16824,4 +16824,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "5673c5d0fd9cc39031661fa199bc50eb3add121eaeef139f418261838bdbb3c8" +content-hash = "dc1654633f511a20e9bfbb3d660e24869c587cbab2c14267692e9042de34f43d" diff --git a/pyproject.toml b/pyproject.toml index fc5e8fab98..c042bffe88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,9 +116,9 @@ pybase62 = "^1.0.0" #openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } #openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } #openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" } -openhands-sdk = "1.7.1" -openhands-agent-server = "1.7.1" -openhands-tools = "1.7.1" +openhands-sdk = "1.7.3" +openhands-agent-server = "1.7.3" +openhands-tools = "1.7.3" python-jose = { version = ">=3.3", extras = [ "cryptography" ] } sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" } pg8000 = "^1.31.5" From bfe827596346fd09baab5ab3c65fcf43b3b7eb5e Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:04:29 +0400 Subject: [PATCH 4/6] hotfix(test): add top-level mock for custom-toast-handlers in conversation-panel tests (#12220) --- .../conversation-panel.test.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx index 95c4ca9521..7a7765f542 100644 --- a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx +++ b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx @@ -16,6 +16,13 @@ vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({ }), })); +// Mock toast handlers to prevent unhandled rejection errors +vi.mock("#/utils/custom-toast-handlers", () => ({ + displaySuccessToast: vi.fn(), + displayErrorToast: vi.fn(), + TOAST_OPTIONS: {}, +})); + describe("ConversationPanel", () => { const onCloseMock = vi.fn(); const RouterStub = createRoutesStub([ @@ -634,12 +641,6 @@ describe("ConversationPanel", () => { ); updateConversationSpy.mockResolvedValue(true); - // Mock the toast function - const mockToast = vi.fn(); - vi.mock("#/utils/custom-toast-handlers", () => ({ - displaySuccessToast: mockToast, - })); - renderConversationPanel(); const cards = await screen.findAllByTestId("conversation-card"); @@ -767,10 +768,6 @@ describe("ConversationPanel", () => { ); updateConversationSpy.mockRejectedValue(new Error("API Error")); - vi.mock("#/utils/custom-toast-handlers", () => ({ - displayErrorToast: vi.fn(), - })); - renderConversationPanel(); const cards = await screen.findAllByTestId("conversation-card"); From ffdd95305f066708cecbda17585b858e78e74da4 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 31 Dec 2025 02:05:43 +0700 Subject: [PATCH 5/6] fix(backend): invalid api key (#12217) --- enterprise/server/constants.py | 2 + enterprise/server/routes/api_keys.py | 105 +++++- .../tests/unit/server/routes/test_api_keys.py | 330 ++++++++++++++++++ 3 files changed, 433 insertions(+), 4 deletions(-) create mode 100644 enterprise/tests/unit/server/routes/test_api_keys.py diff --git a/enterprise/server/constants.py b/enterprise/server/constants.py index e2020ba2b9..1dbe3384f2 100644 --- a/enterprise/server/constants.py +++ b/enterprise/server/constants.py @@ -38,6 +38,8 @@ LITE_LLM_API_URL = os.environ.get( ) LITE_LLM_TEAM_ID = os.environ.get('LITE_LLM_TEAM_ID', None) LITE_LLM_API_KEY = os.environ.get('LITE_LLM_API_KEY', None) +# Timeout in seconds for BYOR key verification requests to LiteLLM +BYOR_KEY_VERIFICATION_TIMEOUT = 5.0 SUBSCRIPTION_PRICE_DATA = { 'MONTHLY_SUBSCRIPTION': { 'unit_amount': 2000, diff --git a/enterprise/server/routes/api_keys.py b/enterprise/server/routes/api_keys.py index 5cb6939217..99e0996226 100644 --- a/enterprise/server/routes/api_keys.py +++ b/enterprise/server/routes/api_keys.py @@ -4,7 +4,11 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, field_validator from server.config import get_config -from server.constants import LITE_LLM_API_KEY, LITE_LLM_API_URL +from server.constants import ( + BYOR_KEY_VERIFICATION_TIMEOUT, + LITE_LLM_API_KEY, + LITE_LLM_API_URL, +) from storage.api_key_store import ApiKeyStore from storage.database import session_maker from storage.saas_settings_store import SaasSettingsStore @@ -112,6 +116,70 @@ async def generate_byor_key(user_id: str) -> str | None: return None +async def verify_byor_key_in_litellm(byor_key: str, user_id: str) -> bool: + """Verify that a BYOR key is valid in LiteLLM by making a lightweight API call. + + Args: + byor_key: The BYOR key to verify + user_id: The user ID for logging purposes + + Returns: + True if the key is verified as valid, False if verification fails or key is invalid. + Returns False on network errors/timeouts to ensure we don't return potentially invalid keys. + """ + if not (LITE_LLM_API_URL and byor_key): + return False + + try: + async with httpx.AsyncClient( + verify=httpx_verify_option(), + timeout=BYOR_KEY_VERIFICATION_TIMEOUT, + ) as client: + # Make a lightweight request to verify the key + # Using /v1/models endpoint as it's lightweight and requires authentication + response = await client.get( + f'{LITE_LLM_API_URL}/v1/models', + headers={ + 'Authorization': f'Bearer {byor_key}', + }, + ) + + # Only 200 status code indicates valid key + if response.status_code == 200: + logger.debug( + 'BYOR key verification successful', + extra={'user_id': user_id}, + ) + return True + + # All other status codes (401, 403, 500, etc.) are treated as invalid + # This includes authentication errors and server errors + logger.warning( + 'BYOR key verification failed - treating as invalid', + extra={ + 'user_id': user_id, + 'status_code': response.status_code, + 'key_prefix': byor_key[:10] + '...' + if len(byor_key) > 10 + else byor_key, + }, + ) + return False + + except (httpx.TimeoutException, Exception) as e: + # Any exception (timeout, network error, etc.) means we can't verify + # Return False to trigger regeneration rather than returning potentially invalid key + logger.warning( + 'BYOR key verification error - treating as invalid to ensure key validity', + extra={ + 'user_id': user_id, + 'error': str(e), + 'error_type': type(e).__name__, + }, + ) + return False + + async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool: """Delete the BYOR key from LiteLLM using the key directly.""" if not (LITE_LLM_API_KEY and LITE_LLM_API_URL): @@ -278,18 +346,44 @@ async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)): @api_router.get('/llm/byor', response_model=LlmApiKeyResponse) async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)): - """Get the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.""" + """Get the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user. + + This endpoint validates that the key exists in LiteLLM before returning it. + If validation fails, it automatically generates a new key to ensure users + always receive a working key. + """ try: # Check if the BYOR key exists in the database byor_key = await get_byor_key_from_db(user_id) if byor_key: - return {'key': byor_key} + # Validate that the key is actually registered in LiteLLM + is_valid = await verify_byor_key_in_litellm(byor_key, user_id) + if is_valid: + return {'key': byor_key} + else: + # Key exists in DB but is invalid in LiteLLM - regenerate it + logger.warning( + 'BYOR key found in database but invalid in LiteLLM - regenerating', + extra={ + 'user_id': user_id, + 'key_prefix': byor_key[:10] + '...' + if len(byor_key) > 10 + else byor_key, + }, + ) + # Delete the invalid key from LiteLLM (best effort, don't fail if it doesn't exist) + await delete_byor_key_from_litellm(user_id, byor_key) + # Fall through to generate a new key - # If not, generate a new key for BYOR + # Generate a new key for BYOR (either no key exists or validation failed) key = await generate_byor_key(user_id) if key: # Store the key in the database await store_byor_key_in_db(user_id, key) + logger.info( + 'Successfully generated and stored new BYOR key', + extra={'user_id': user_id}, + ) return {'key': key} else: logger.error( @@ -301,6 +395,9 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)): detail='Failed to generate new BYOR LLM API key', ) + except HTTPException: + # Re-raise HTTP exceptions as-is + raise except Exception as e: logger.exception('Error retrieving BYOR LLM API key', extra={'error': str(e)}) raise HTTPException( diff --git a/enterprise/tests/unit/server/routes/test_api_keys.py b/enterprise/tests/unit/server/routes/test_api_keys.py new file mode 100644 index 0000000000..31c1edba3c --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_api_keys.py @@ -0,0 +1,330 @@ +"""Unit tests for API keys routes, focusing on BYOR key validation and retrieval.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest +from fastapi import HTTPException +from server.routes.api_keys import ( + get_llm_api_key_for_byor, + verify_byor_key_in_litellm, +) + + +class TestVerifyByorKeyInLitellm: + """Test the verify_byor_key_in_litellm function.""" + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com') + @patch('server.routes.api_keys.httpx.AsyncClient') + async def test_verify_valid_key_returns_true(self, mock_client_class): + """Test that a valid key (200 response) returns True.""" + # Arrange + byor_key = 'sk-valid-key-123' + user_id = 'user-123' + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.is_success = True + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is True + mock_client.get.assert_called_once_with( + 'https://litellm.example.com/v1/models', + headers={'Authorization': f'Bearer {byor_key}'}, + ) + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com') + @patch('server.routes.api_keys.httpx.AsyncClient') + async def test_verify_invalid_key_401_returns_false(self, mock_client_class): + """Test that an invalid key (401 response) returns False.""" + # Arrange + byor_key = 'sk-invalid-key-123' + user_id = 'user-123' + mock_response = MagicMock() + mock_response.status_code = 401 + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is False + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com') + @patch('server.routes.api_keys.httpx.AsyncClient') + async def test_verify_invalid_key_403_returns_false(self, mock_client_class): + """Test that an invalid key (403 response) returns False.""" + # Arrange + byor_key = 'sk-forbidden-key-123' + user_id = 'user-123' + mock_response = MagicMock() + mock_response.status_code = 403 + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is False + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com') + @patch('server.routes.api_keys.httpx.AsyncClient') + async def test_verify_server_error_returns_false(self, mock_client_class): + """Test that a server error (500) returns False to ensure key validity.""" + # Arrange + byor_key = 'sk-key-123' + user_id = 'user-123' + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.is_success = False + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is False + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com') + @patch('server.routes.api_keys.httpx.AsyncClient') + async def test_verify_timeout_returns_false(self, mock_client_class): + """Test that a timeout returns False to ensure key validity.""" + # Arrange + byor_key = 'sk-key-123' + user_id = 'user-123' + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.get.side_effect = httpx.TimeoutException('Request timed out') + mock_client_class.return_value = mock_client + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is False + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com') + @patch('server.routes.api_keys.httpx.AsyncClient') + async def test_verify_network_error_returns_false(self, mock_client_class): + """Test that a network error returns False to ensure key validity.""" + # Arrange + byor_key = 'sk-key-123' + user_id = 'user-123' + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + mock_client.get.side_effect = httpx.NetworkError('Network error') + mock_client_class.return_value = mock_client + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is False + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', None) + async def test_verify_missing_api_url_returns_false(self): + """Test that missing LITE_LLM_API_URL returns False.""" + # Arrange + byor_key = 'sk-key-123' + user_id = 'user-123' + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is False + + @pytest.mark.asyncio + @patch('server.routes.api_keys.LITE_LLM_API_URL', 'https://litellm.example.com') + async def test_verify_empty_key_returns_false(self): + """Test that empty key returns False.""" + # Arrange + byor_key = '' + user_id = 'user-123' + + # Act + result = await verify_byor_key_in_litellm(byor_key, user_id) + + # Assert + assert result is False + + +class TestGetLlmApiKeyForByor: + """Test the get_llm_api_key_for_byor endpoint.""" + + @pytest.mark.asyncio + @patch('server.routes.api_keys.store_byor_key_in_db') + @patch('server.routes.api_keys.generate_byor_key') + @patch('server.routes.api_keys.get_byor_key_from_db') + async def test_no_key_in_database_generates_new( + self, mock_get_key, mock_generate_key, mock_store_key + ): + """Test that when no key exists in database, a new one is generated.""" + # Arrange + user_id = 'user-123' + new_key = 'sk-new-generated-key' + mock_get_key.return_value = None + mock_generate_key.return_value = new_key + mock_store_key.return_value = None + + # Act + result = await get_llm_api_key_for_byor(user_id=user_id) + + # Assert + assert result == {'key': new_key} + mock_get_key.assert_called_once_with(user_id) + mock_generate_key.assert_called_once_with(user_id) + mock_store_key.assert_called_once_with(user_id, new_key) + + @pytest.mark.asyncio + @patch('server.routes.api_keys.verify_byor_key_in_litellm') + @patch('server.routes.api_keys.get_byor_key_from_db') + async def test_valid_key_in_database_returns_key( + self, mock_get_key, mock_verify_key + ): + """Test that when a valid key exists in database, it is returned.""" + # Arrange + user_id = 'user-123' + existing_key = 'sk-existing-valid-key' + mock_get_key.return_value = existing_key + mock_verify_key.return_value = True + + # Act + result = await get_llm_api_key_for_byor(user_id=user_id) + + # Assert + assert result == {'key': existing_key} + mock_get_key.assert_called_once_with(user_id) + mock_verify_key.assert_called_once_with(existing_key, user_id) + + @pytest.mark.asyncio + @patch('server.routes.api_keys.store_byor_key_in_db') + @patch('server.routes.api_keys.generate_byor_key') + @patch('server.routes.api_keys.delete_byor_key_from_litellm') + @patch('server.routes.api_keys.verify_byor_key_in_litellm') + @patch('server.routes.api_keys.get_byor_key_from_db') + async def test_invalid_key_in_database_regenerates( + self, + mock_get_key, + mock_verify_key, + mock_delete_key, + mock_generate_key, + mock_store_key, + ): + """Test that when an invalid key exists in database, it is regenerated.""" + # Arrange + user_id = 'user-123' + invalid_key = 'sk-invalid-key' + new_key = 'sk-new-generated-key' + mock_get_key.return_value = invalid_key + mock_verify_key.return_value = False + mock_delete_key.return_value = True + mock_generate_key.return_value = new_key + mock_store_key.return_value = None + + # Act + result = await get_llm_api_key_for_byor(user_id=user_id) + + # Assert + assert result == {'key': new_key} + mock_get_key.assert_called_once_with(user_id) + mock_verify_key.assert_called_once_with(invalid_key, user_id) + mock_delete_key.assert_called_once_with(user_id, invalid_key) + mock_generate_key.assert_called_once_with(user_id) + mock_store_key.assert_called_once_with(user_id, new_key) + + @pytest.mark.asyncio + @patch('server.routes.api_keys.store_byor_key_in_db') + @patch('server.routes.api_keys.generate_byor_key') + @patch('server.routes.api_keys.delete_byor_key_from_litellm') + @patch('server.routes.api_keys.verify_byor_key_in_litellm') + @patch('server.routes.api_keys.get_byor_key_from_db') + async def test_invalid_key_deletion_failure_still_regenerates( + self, + mock_get_key, + mock_verify_key, + mock_delete_key, + mock_generate_key, + mock_store_key, + ): + """Test that even if deletion fails, regeneration still proceeds.""" + # Arrange + user_id = 'user-123' + invalid_key = 'sk-invalid-key' + new_key = 'sk-new-generated-key' + mock_get_key.return_value = invalid_key + mock_verify_key.return_value = False + mock_delete_key.return_value = False # Deletion fails + mock_generate_key.return_value = new_key + mock_store_key.return_value = None + + # Act + result = await get_llm_api_key_for_byor(user_id=user_id) + + # Assert + assert result == {'key': new_key} + mock_delete_key.assert_called_once_with(user_id, invalid_key) + mock_generate_key.assert_called_once_with(user_id) + mock_store_key.assert_called_once_with(user_id, new_key) + + @pytest.mark.asyncio + @patch('server.routes.api_keys.generate_byor_key') + @patch('server.routes.api_keys.get_byor_key_from_db') + async def test_key_generation_failure_raises_exception( + self, mock_get_key, mock_generate_key + ): + """Test that when key generation fails, an HTTPException is raised.""" + # Arrange + user_id = 'user-123' + mock_get_key.return_value = None + mock_generate_key.return_value = None + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await get_llm_api_key_for_byor(user_id=user_id) + + assert exc_info.value.status_code == 500 + assert 'Failed to generate new BYOR LLM API key' in exc_info.value.detail + + @pytest.mark.asyncio + @patch('server.routes.api_keys.get_byor_key_from_db') + async def test_database_error_raises_exception(self, mock_get_key): + """Test that database errors are properly handled.""" + # Arrange + user_id = 'user-123' + mock_get_key.side_effect = Exception('Database connection error') + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await get_llm_api_key_for_byor(user_id=user_id) + + assert exc_info.value.status_code == 500 + assert 'Failed to retrieve BYOR LLM API key' in exc_info.value.detail From 232dcf49915d6e5435003cf8d984a2231275fbfd Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Tue, 30 Dec 2025 14:41:45 -0600 Subject: [PATCH 6/6] fix(ci): update PAT_TOKEN to ALLHANDS_BOT_GITHUB_PAT for enterprise preview (#12216) Co-authored-by: openhands --- .github/workflows/enterprise-preview.yml | 2 +- .github/workflows/ghcr-build.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/enterprise-preview.yml b/.github/workflows/enterprise-preview.yml index e31222827b..ac9f81c3b9 100644 --- a/.github/workflows/enterprise-preview.yml +++ b/.github/workflows/enterprise-preview.yml @@ -23,7 +23,7 @@ jobs: - name: Trigger remote job run: | curl --fail-with-body -sS -X POST \ - -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \ + -H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \ -H "Accept: application/vnd.github+json" \ -d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \ https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches diff --git a/.github/workflows/ghcr-build.yml b/.github/workflows/ghcr-build.yml index b63e681fe3..68462f75c2 100644 --- a/.github/workflows/ghcr-build.yml +++ b/.github/workflows/ghcr-build.yml @@ -247,7 +247,7 @@ jobs: - name: Trigger remote job run: | curl --fail-with-body -sS -X POST \ - -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \ + -H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \ -H "Accept: application/vnd.github+json" \ -d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \ https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches