From 8f3ff1210e7e605e309b079681b55840fa4e8077 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 22 Apr 2025 15:16:41 +0000 Subject: [PATCH 01/27] chore(deps): bump the version-all group with 6 updates (#8009) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 53 +++++++++++++++++++++++++------------------------- pyproject.toml | 2 +- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0327733b7b5d..5751367d1529 100644 --- a/poetry.lock +++ b/poetry.lock @@ -496,18 +496,18 @@ files = [ [[package]] name = "boto3" -version = "1.37.37" +version = "1.37.38" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "boto3-1.37.37-py3-none-any.whl", hash = "sha256:d125cb11e22817f7a2581bade4bf7b75247b401888890239ceb5d3e902ccaf38"}, - {file = "boto3-1.37.37.tar.gz", hash = "sha256:752d31105a45e3e01c8c68471db14ae439990b75a35e72b591ca528e2575b28f"}, + {file = "boto3-1.37.38-py3-none-any.whl", hash = "sha256:b6d42803607148804dff82389757827a24ce9271f0583748853934c86310999f"}, + {file = "boto3-1.37.38.tar.gz", hash = "sha256:88c02910933ab7777597d1ca7c62375f52822e0aa1a8e0c51b2598a547af42b2"}, ] [package.dependencies] -botocore = ">=1.37.37,<1.38.0" +botocore = ">=1.37.38,<1.38.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.11.0,<0.12.0" @@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "boto3-stubs" -version = "1.37.37" -description = "Type annotations for boto3 1.37.37 generated with mypy-boto3-builder 8.10.1" +version = "1.37.38" +description = "Type annotations for boto3 1.37.38 generated with mypy-boto3-builder 8.10.1" optional = false python-versions = ">=3.8" groups = ["evaluation"] files = [ - {file = "boto3_stubs-1.37.37-py3-none-any.whl", hash = "sha256:937fabdc226b6661d90b7abb0dcaf4450c08e6e334d726381ba7479672d828c6"}, - {file = "boto3_stubs-1.37.37.tar.gz", hash = "sha256:e467b7aa64c96f71266e3d3d763cd826e34e4063d511c0dec4341d3071d3428c"}, + {file = "boto3_stubs-1.37.38-py3-none-any.whl", hash = "sha256:78418c10b43f1b45d877213a085acac7bcdb23e9c0ab294af04dffe9fc4310b5"}, + {file = "boto3_stubs-1.37.38.tar.gz", hash = "sha256:d78c2de88e9f1a60bef05cfad5b8edc051f1762be0865c83bebe716448f56510"}, ] [package.dependencies] @@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime ( bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.37.0,<1.38.0)"] billing = ["mypy-boto3-billing (>=1.37.0,<1.38.0)"] billingconductor = ["mypy-boto3-billingconductor (>=1.37.0,<1.38.0)"] -boto3 = ["boto3 (==1.37.37)"] +boto3 = ["boto3 (==1.37.38)"] braket = ["mypy-boto3-braket (>=1.37.0,<1.38.0)"] budgets = ["mypy-boto3-budgets (>=1.37.0,<1.38.0)"] ce = ["mypy-boto3-ce (>=1.37.0,<1.38.0)"] @@ -943,14 +943,14 @@ xray = ["mypy-boto3-xray (>=1.37.0,<1.38.0)"] [[package]] name = "botocore" -version = "1.37.37" +version = "1.37.38" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "botocore-1.37.37-py3-none-any.whl", hash = "sha256:eb730ff978f47c02f0c8ed07bccdc0db6d8fa098ed32ac31bee1da0e9be480d1"}, - {file = "botocore-1.37.37.tar.gz", hash = "sha256:3eadde6fed95c4cb469cc39d1c3558528b7fa76d23e7e16d4bddc77250431a64"}, + {file = "botocore-1.37.38-py3-none-any.whl", hash = "sha256:23b4097780e156a4dcaadfc1ed156ce25cb95b6087d010c4bb7f7f5d9bc9d219"}, + {file = "botocore-1.37.38.tar.gz", hash = "sha256:c3ea386177171f2259b284db6afc971c959ec103fa2115911c4368bea7cbbc5d"}, ] [package.dependencies] @@ -3793,14 +3793,14 @@ files = [ [[package]] name = "json-repair" -version = "0.41.1" +version = "0.42.0" description = "A package to repair broken json strings" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "json_repair-0.41.1-py3-none-any.whl", hash = "sha256:0e181fd43a696887881fe19fed23422a54b3e4c558b6ff27a86a8c3ddde9ae79"}, - {file = "json_repair-0.41.1.tar.gz", hash = "sha256:bba404b0888c84a6b86ecc02ec43b71b673cfee463baf6da94e079c55b136565"}, + {file = "json_repair-0.42.0-py3-none-any.whl", hash = "sha256:7b6805162053dfe65722e961bc51b5eecec0582ec8a8e0fd218d33e8de757daf"}, + {file = "json_repair-0.42.0.tar.gz", hash = "sha256:1a901f706c5b6b4325f0f79b53b0d998c5b327070e98b530da71cc5a3eda8616"}, ] [[package]] @@ -4882,14 +4882,14 @@ files = [ [[package]] name = "modal" -version = "0.74.14" +version = "0.74.15" description = "Python client library for Modal" optional = false python-versions = ">=3.9" groups = ["main", "evaluation"] files = [ - {file = "modal-0.74.14-py3-none-any.whl", hash = "sha256:eb3edf5aa7a105a11c32c0f18aba240766d4a8b8089b33b6458f7d5eab632feb"}, - {file = "modal-0.74.14.tar.gz", hash = "sha256:7757518feb53cca3e62022ce8ed9ba389c831a0c3cbe967de00e6dd2217aac0a"}, + {file = "modal-0.74.15-py3-none-any.whl", hash = "sha256:084e898ab202ccd698fd277d9dc9e9cec8d4b0954a1c09d4ba529f0446ab3526"}, + {file = "modal-0.74.15.tar.gz", hash = "sha256:95512811ebd42a52fa03724f60d0d1c32259788351e798d0d695974d94b2e49c"}, ] [package.dependencies] @@ -7686,6 +7686,7 @@ python-versions = "<4,>=3.7" groups = ["test"] files = [ {file = "reportlab-4.4.0-py3-none-any.whl", hash = "sha256:0a993f1d4a765fcbdf4e26adc96b3351004ebf4d27583340595ba7edafebec32"}, + {file = "reportlab-4.4.0.tar.gz", hash = "sha256:a64d85513910e246c21dc97ccc3c9054a1d44370bf8fc1fab80af937814354d5"}, ] [package.dependencies] @@ -7976,14 +7977,14 @@ files = [ [[package]] name = "runloop-api-client" -version = "0.30.0" +version = "0.31.0" description = "The official Python library for the runloop API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "runloop_api_client-0.30.0-py3-none-any.whl", hash = "sha256:5e922399bdd0f67c6e19c212d06203640580a50c3fc8760eef0eea6dd5259ee4"}, - {file = "runloop_api_client-0.30.0.tar.gz", hash = "sha256:9211d9961d9aa2372d7f5ecd36c8197a72bfd1227d145caa9c710b8302e50e66"}, + {file = "runloop_api_client-0.31.0-py3-none-any.whl", hash = "sha256:1eb716a20b268e081bdbcf5b5d1df9ab6eb258a0b929130210a3b643048159c7"}, + {file = "runloop_api_client-0.31.0.tar.gz", hash = "sha256:78992595fd34f98470aa73b8f5b92983414e4878218239e531a9371c5570a13d"}, ] [package.dependencies] @@ -8534,19 +8535,19 @@ test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"] [[package]] name = "stripe" -version = "12.0.0" +version = "12.0.1" description = "Python bindings for the Stripe API" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "stripe-12.0.0-py2.py3-none-any.whl", hash = "sha256:1105bbceca8c0aead941c7c3ec676727610cb6437b6e8905379f2ccc370eeafd"}, - {file = "stripe-12.0.0.tar.gz", hash = "sha256:1bc64192b6bd853fa75e2246ad553baa28e27e348eb2c3336b4d8b021d27aae5"}, + {file = "stripe-12.0.1-py2.py3-none-any.whl", hash = "sha256:b10b19dbd0622868b98a7c6e879ebde704be96ad75c780944bca4069bb427988"}, + {file = "stripe-12.0.1.tar.gz", hash = "sha256:3fc7cc190946d8ebcc5b637e7e04f387d61b9c5156a89619a3ba90704ac09d4a"}, ] [package.dependencies] requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} -typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} +typing_extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} [[package]] name = "swebench" @@ -10255,4 +10256,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "82763fb3ce12aba7fbf76651fa3ea72be700feaabb4d944540fc1156745bb6c1" +content-hash = "315f92801db294eaab22823ba948a2658ea8040bbae97ddcc434e9ac127af53b" diff --git a/pyproject.toml b/pyproject.toml index 18d7aac846ed..dbb89ba0b828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+ opentelemetry-api = "1.25.0" opentelemetry-exporter-otlp-proto-grpc = "1.25.0" modal = ">=0.66.26,<0.75.0" -runloop-api-client = "0.30.0" +runloop-api-client = "0.31.0" libtmux = ">=0.37,<0.40" pygithub = "^2.5.0" joblib = "*" From 039fe295a438f3ca896654cbf58830338efcd52a Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 22 Apr 2025 12:30:41 -0400 Subject: [PATCH 02/27] Add RateLimitError and handle rate limiting in GitLab and GitHub services (#8003) Co-authored-by: openhands Co-authored-by: Engel Nyst --- .../integrations/github/github_service.py | 26 ++++------- .../integrations/gitlab/gitlab_service.py | 27 ++++-------- openhands/integrations/service_types.py | 43 +++++++++++++++++-- 3 files changed, 57 insertions(+), 39 deletions(-) diff --git a/openhands/integrations/github/github_service.py b/openhands/integrations/github/github_service.py index e74dfd2c26c4..7813c781d7df 100644 --- a/openhands/integrations/github/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -5,9 +5,7 @@ import httpx from pydantic import SecretStr -from openhands.core.logger import openhands_logger as logger from openhands.integrations.service_types import ( - AuthenticationError, BaseGitService, GitService, ProviderType, @@ -45,6 +43,10 @@ def __init__( if base_domain: self.BASE_URL = f'https://{base_domain}/api/v3' + @property + def provider(self) -> str: + return ProviderType.GITHUB.value + async def _get_github_headers(self) -> dict: """Retrieve the GH Token from settings store to construct the headers.""" if not self.token: @@ -100,15 +102,9 @@ async def _make_request( return response.json(), headers except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - raise AuthenticationError('Invalid Github token') - - logger.warning(f'Status error on GH API: {e}') - raise UnknownException('Unknown error') - + raise self.handle_http_status_error(e) except httpx.HTTPError as e: - logger.warning(f'HTTP error on GH API: {e}') - raise UnknownException('Unknown error') + raise self.handle_http_error(e) async def get_user(self) -> User: url = f'{self.BASE_URL}/user' @@ -264,15 +260,9 @@ async def execute_graphql_query( return dict(result) except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - raise AuthenticationError('Invalid Github token') - - logger.warning(f'Status error on GH API: {e}') - raise UnknownException('Unknown error') - + raise self.handle_http_status_error(e) except httpx.HTTPError as e: - logger.warning(f'HTTP error on GH API: {e}') - raise UnknownException('Unknown error') + raise self.handle_http_error(e) async def get_suggested_tasks(self) -> list[SuggestedTask]: """Get suggested tasks for the authenticated user across all repositories. diff --git a/openhands/integrations/gitlab/gitlab_service.py b/openhands/integrations/gitlab/gitlab_service.py index 93a3e61fb8a5..c21c068f60f5 100644 --- a/openhands/integrations/gitlab/gitlab_service.py +++ b/openhands/integrations/gitlab/gitlab_service.py @@ -4,9 +4,7 @@ import httpx from pydantic import SecretStr -from openhands.core.logger import openhands_logger as logger from openhands.integrations.service_types import ( - AuthenticationError, BaseGitService, GitService, ProviderType, @@ -24,6 +22,7 @@ class GitLabService(BaseGitService, GitService): GRAPHQL_URL = 'https://gitlab.com/api/graphql' token: SecretStr = SecretStr('') refresh = False + def __init__( self, @@ -44,6 +43,10 @@ def __init__( self.BASE_URL = f'https://{base_domain}/api/v4' self.GRAPHQL_URL = f'https://{base_domain}/api/graphql' + @property + def provider(self) -> str: + return ProviderType.GITLAB.value + async def _get_gitlab_headers(self) -> dict[str, Any]: """ Retrieve the GitLab Token to construct the headers @@ -100,15 +103,9 @@ async def _make_request( return response.json(), headers except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - raise AuthenticationError('Invalid GitLab token') - - logger.warning(f'Status error on GL API: {e}') - raise UnknownException('Unknown error') - + raise self.handle_http_status_error(e) except httpx.HTTPError as e: - logger.warning(f'HTTP error on GL API: {e}') - raise UnknownException('Unknown error') + raise self.handle_http_error(e) async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> Any: """ @@ -156,15 +153,9 @@ async def execute_graphql_query(self, query: str, variables: dict[str, Any]) -> return result.get('data') except httpx.HTTPStatusError as e: - if e.response.status_code == 401: - raise AuthenticationError('Invalid GitLab token') - - logger.warning(f'Status error on GL API: {e}') - raise UnknownException('Unknown error') - + raise self.handle_http_status_error(e) except httpx.HTTPError as e: - logger.warning(f'HTTP error on GL API: {e}') - raise UnknownException('Unknown error') + raise self.handle_http_error(e) async def get_user(self) -> User: url = f'{self.BASE_URL}/user' diff --git a/openhands/integrations/service_types.py b/openhands/integrations/service_types.py index 8747550b4695..590c5fbf38ec 100644 --- a/openhands/integrations/service_types.py +++ b/openhands/integrations/service_types.py @@ -1,9 +1,11 @@ +from abc import ABC, abstractmethod from enum import Enum -from typing import Protocol +from typing import Any, Protocol -from httpx import AsyncClient +from httpx import AsyncClient, HTTPError, HTTPStatusError from pydantic import BaseModel, SecretStr +from openhands.core.logger import openhands_logger as logger from openhands.server.types import AppMode @@ -58,12 +60,31 @@ class UnknownException(ValueError): pass +class RateLimitError(ValueError): + """Raised when the git provider's API rate limits are exceeded.""" + + pass + + class RequestMethod(Enum): POST = 'post' GET = 'get' -class BaseGitService: +class BaseGitService(ABC): + @property + def provider(self) -> str: + raise NotImplementedError('Subclasses must implement the provider property') + + # Method used to satisfy mypy for abstract class definition + @abstractmethod + async def _make_request( + self, + url: str, + params: dict | None = None, + method: RequestMethod = RequestMethod.GET, + ) -> tuple[Any, dict]: ... + async def execute_request( self, client: AsyncClient, @@ -76,6 +97,22 @@ async def execute_request( return await client.post(url, headers=headers, json=params) return await client.get(url, headers=headers, params=params) + def handle_http_status_error( + self, e: HTTPStatusError + ) -> AuthenticationError | RateLimitError | UnknownException: + if e.response.status_code == 401: + return AuthenticationError(f'Invalid {self.provider} token') + elif e.response.status_code == 429: + logger.warning(f'Rate limit exceeded on {self.provider} API: {e}') + return RateLimitError('GitHub API rate limit exceeded') + + logger.warning(f'Status error on {self.provider} API: {e}') + return UnknownException('Unknown error') + + def handle_http_error(self, e: HTTPError) -> UnknownException: + logger.warning(f'HTTP error on {self.provider} API: {e}') + return UnknownException('Unknown error') + class GitService(Protocol): """Protocol defining the interface for Git service providers""" From b0a9938e6c0b4a9beea3596b6ca4c9b18429ebbd Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Tue, 22 Apr 2025 13:43:20 -0400 Subject: [PATCH 03/27] Always run git init in SaaS mode regardless of workspace_base setting (#8014) Co-authored-by: openhands --- openhands/runtime/base.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 1ab43bf012e9..fb065647bd6f 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -317,18 +317,20 @@ async def clone_or_init_repo( repository_provider: ProviderType = ProviderType.GITHUB, ) -> str: if not selected_repository: - if self.config.workspace_base: + # In SaaS mode (indicated by user_id being set), always run git init + # In OSS mode, only run git init if workspace_base is not set + if self.user_id or not self.config.workspace_base: + logger.debug( + 'No repository selected. Initializing a new git repository in the workspace.' + ) + action = CmdRunAction( + command='git init', + ) + self.run_action(action) + else: logger.info( 'In workspace mount mode, not initializing a new git repository.' ) - return '' - logger.debug( - 'No repository selected. Initializing a new git repository in the workspace.' - ) - action = CmdRunAction( - command='git init', - ) - self.run_action(action) return '' provider_domains = { From 89f8e162daeff180d4be825958e8983814d23ffb Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Tue, 22 Apr 2025 15:30:07 -0400 Subject: [PATCH 04/27] Fix: Don't show status indicator for command timeouts (#8012) Co-authored-by: openhands --- .../components/chat/expandable-message.test.tsx | 17 +++++++++++++++++ frontend/src/state/chat-slice.ts | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/frontend/__tests__/components/chat/expandable-message.test.tsx b/frontend/__tests__/components/chat/expandable-message.test.tsx index 3e4f2c3d39f7..2da47c6d4aea 100644 --- a/frontend/__tests__/components/chat/expandable-message.test.tsx +++ b/frontend/__tests__/components/chat/expandable-message.test.tsx @@ -95,6 +95,23 @@ describe("ExpandableMessage", () => { expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); }); + it("should render with neutral border and no icon for action messages with undefined success (timeout case)", () => { + renderWithProviders( + , + ); + const element = screen.getByText("OBSERVATION_MESSAGE$RUN"); + const container = element.closest( + "div.flex.gap-2.items-center.justify-start", + ); + expect(container).toHaveClass("border-neutral-300"); + expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument(); + }); + it("should render the out of credits message when the user is out of credits", async () => { const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); // @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields diff --git a/frontend/src/state/chat-slice.ts b/frontend/src/state/chat-slice.ts index e6b2790f4e4a..f1403fb7f0c6 100644 --- a/frontend/src/state/chat-slice.ts +++ b/frontend/src/state/chat-slice.ts @@ -252,7 +252,13 @@ export const chatSlice = createSlice({ // Set success property based on observation type if (observationID === "run") { const commandObs = observation.payload as CommandObservation; - causeMessage.success = commandObs.extras.metadata.exit_code === 0; + // If exit_code is -1, it means the command timed out, so we set success to undefined + // to not show any status indicator + if (commandObs.extras.metadata.exit_code === -1) { + causeMessage.success = undefined; + } else { + causeMessage.success = commandObs.extras.metadata.exit_code === 0; + } } else if (observationID === "run_ipython") { // For IPython, we consider it successful if there's no error message const ipythonObs = observation.payload as IPythonObservation; From 62557d44f21a61cf2948ae4c61b6a5d848ca1974 Mon Sep 17 00:00:00 2001 From: Engel Nyst Date: Wed, 23 Apr 2025 00:30:21 +0200 Subject: [PATCH 05/27] Use short tool descriptions for o4-mini (#8022) --- openhands/agenthub/codeact_agent/codeact_agent.py | 14 ++++++++------ openhands/agenthub/codeact_agent/tools/bash.py | 8 ++++---- .../codeact_agent/tools/str_replace_editor.py | 8 ++++---- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index 0fbdc78e3d46..61ab0ac1c60d 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -95,19 +95,21 @@ def __init__( self.response_to_actions_fn = codeact_function_calling.response_to_actions def _get_tools(self) -> list[ChatCompletionToolParam]: - SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1'] + # For these models, we use short tool descriptions ( < 1024 tokens) + # to avoid hitting the OpenAI token limit for tool descriptions. + SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4'] - use_simplified_tool_desc = False + use_short_tool_desc = False if self.llm is not None: - use_simplified_tool_desc = any( + use_short_tool_desc = any( model_substr in self.llm.config.model - for model_substr in SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS + for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS ) tools = [] if self.config.enable_cmd: tools.append( - create_cmd_run_tool(use_simplified_description=use_simplified_tool_desc) + create_cmd_run_tool(use_short_description=use_short_tool_desc) ) if self.config.enable_think: tools.append(ThinkTool) @@ -123,7 +125,7 @@ def _get_tools(self) -> list[ChatCompletionToolParam]: elif self.config.enable_editor: tools.append( create_str_replace_editor_tool( - use_simplified_description=use_simplified_tool_desc + use_short_description=use_short_tool_desc ) ) return tools diff --git a/openhands/agenthub/codeact_agent/tools/bash.py b/openhands/agenthub/codeact_agent/tools/bash.py index 60265912a9e1..af557778d03c 100644 --- a/openhands/agenthub/codeact_agent/tools/bash.py +++ b/openhands/agenthub/codeact_agent/tools/bash.py @@ -22,18 +22,18 @@ * Output truncation: If the output exceeds a maximum length, it will be truncated before being returned. """ -_SIMPLIFIED_BASH_DESCRIPTION = """Execute a bash command in the terminal. +_SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal. * Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`. * Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process. * One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.""" def create_cmd_run_tool( - use_simplified_description: bool = False, + use_short_description: bool = False, ) -> ChatCompletionToolParam: description = ( - _SIMPLIFIED_BASH_DESCRIPTION - if use_simplified_description + _SHORT_BASH_DESCRIPTION + if use_short_description else _DETAILED_BASH_DESCRIPTION ) return ChatCompletionToolParam( diff --git a/openhands/agenthub/codeact_agent/tools/str_replace_editor.py b/openhands/agenthub/codeact_agent/tools/str_replace_editor.py index f6752a01214f..d55b0d21c8a4 100644 --- a/openhands/agenthub/codeact_agent/tools/str_replace_editor.py +++ b/openhands/agenthub/codeact_agent/tools/str_replace_editor.py @@ -31,7 +31,7 @@ Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each. """ -_SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format +_SHORT_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format * State is persistent across command calls and discussions with the user * If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep * The `create` command cannot be used if the specified `path` already exists as a file @@ -45,11 +45,11 @@ def create_str_replace_editor_tool( - use_simplified_description: bool = False, + use_short_description: bool = False, ) -> ChatCompletionToolParam: description = ( - _SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION - if use_simplified_description + _SHORT_STR_REPLACE_EDITOR_DESCRIPTION + if use_short_description else _DETAILED_STR_REPLACE_EDITOR_DESCRIPTION ) return ChatCompletionToolParam( From 693c72d670bb21c46b688b21fb8df3eb55712997 Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 22 Apr 2025 15:40:10 -0700 Subject: [PATCH 06/27] remove sse subsection accessor of McpConfig in action_execution_client (#8021) --- .../runtime/impl/action_execution/action_execution_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py index 5a3650aa4014..60f18dcd7c0d 100644 --- a/openhands/runtime/impl/action_execution/action_execution_client.py +++ b/openhands/runtime/impl/action_execution/action_execution_client.py @@ -331,9 +331,9 @@ async def call_tool_mcp(self, action: McpAction) -> Observation: if self.mcp_clients is None: self.log( 'debug', - f'Creating MCP clients with servers: {self.config.mcp.sse.mcp_servers}', + f'Creating MCP clients with servers: {self.config.mcp.mcp_servers}', ) - self.mcp_clients = await create_mcp_clients(self.config.mcp.sse.mcp_servers) + self.mcp_clients = await create_mcp_clients(self.config.mcp.mcp_servers) return await call_tool_mcp_handler(self.mcp_clients, action) async def aclose(self) -> None: From 5d749aeba795d026cc1eb14339955752ebd35346 Mon Sep 17 00:00:00 2001 From: Chase Date: Tue, 22 Apr 2025 15:50:14 -0700 Subject: [PATCH 07/27] replace erroneous rstrip() with removesuffix() (#8024) --- openhands/agenthub/codeact_agent/function_calling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py index 1b8a34fff257..023acca660cb 100644 --- a/openhands/agenthub/codeact_agent/function_calling.py +++ b/openhands/agenthub/codeact_agent/function_calling.py @@ -199,7 +199,7 @@ def response_to_actions(response: ModelResponse) -> list[Action]: # ================================================ elif tool_call.function.name.endswith(MCPClientTool.postfix()): action = McpAction( - name=tool_call.function.name.rstrip(MCPClientTool.postfix()), + name=tool_call.function.name.removesuffix(MCPClientTool.postfix()), arguments=tool_call.function.arguments, ) else: From 5de62d85fdcde61080b23c979e872674a792c31a Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Tue, 22 Apr 2025 22:09:22 -0400 Subject: [PATCH 08/27] add an option for a headless backend (#8032) --- openhands/server/listen.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openhands/server/listen.py b/openhands/server/listen.py index 7bcc3ad9efb0..afda98d3274a 100644 --- a/openhands/server/listen.py +++ b/openhands/server/listen.py @@ -1,3 +1,5 @@ +import os + import socketio from openhands.server.app import app as base_app @@ -12,9 +14,10 @@ ) from openhands.server.static import SPAStaticFiles -base_app.mount( - '/', SPAStaticFiles(directory='./frontend/build', html=True), name='dist' -) +if os.getenv('SERVE_FRONTEND', 'true').lower() == 'true': + base_app.mount( + '/', SPAStaticFiles(directory='./frontend/build', html=True), name='dist' + ) base_app.add_middleware( LocalhostCORSMiddleware, From 1fd26d196a0554d5b8f8ecd3c5fb236252d26e29 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Wed, 23 Apr 2025 11:06:28 -0400 Subject: [PATCH 09/27] Release 0.34.0 (#8011) --- Development.md | 2 +- README.md | 6 +++--- containers/dev/compose.yml | 2 +- docker-compose.yml | 2 +- .../current/usage/how-to/cli-mode.md | 4 ++-- .../current/usage/how-to/headless-mode.md | 4 ++-- .../current/usage/installation.mdx | 6 +++--- .../current/usage/runtimes.md | 2 +- .../current/usage/how-to/cli-mode.md | 4 ++-- .../current/usage/how-to/headless-mode.md | 4 ++-- .../current/usage/runtimes/docker.md | 4 ++-- .../current/usage/how-to/cli-mode.md | 4 ++-- .../current/usage/how-to/headless-mode.md | 4 ++-- .../current/usage/installation.mdx | 6 +++--- .../current/usage/runtimes.md | 2 +- .../current/usage/how-to/cli-mode.md | 4 ++-- .../current/usage/how-to/headless-mode.md | 4 ++-- .../current/usage/installation.mdx | 6 +++--- .../current/usage/runtimes.md | 2 +- docs/modules/usage/how-to/cli-mode.md | 4 ++-- docs/modules/usage/how-to/headless-mode.md | 4 ++-- docs/modules/usage/installation.mdx | 6 +++--- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- pyproject.toml | 2 +- 25 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Development.md b/Development.md index 7bbec7fabd83..832c1a62740c 100644 --- a/Development.md +++ b/Development.md @@ -118,7 +118,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/all-hands-ai/runtime:0.33-nikolaik` +Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.34-nikolaik` ## Develop inside Docker container diff --git a/README.md b/README.md index 570a1d238130..67e0b751deb5 100644 --- a/README.md +++ b/README.md @@ -52,17 +52,17 @@ system requirements and more information. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)! diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index c865613f4829..9dadad9855e3 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -11,7 +11,7 @@ services: - BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"} - SANDBOX_API_HOSTNAME=host.docker.internal # - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.33-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.34-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 b3f962629220..246baec5f950 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.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-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-state for this user - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index ef41167a31fe..859e90fea81a 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -61,7 +61,7 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index 48527fefc63d..e408552d2d53 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -56,6 +56,6 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue ``` diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx index d4803f76b4fa..1cca4aaf7cc3 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -13,16 +13,16 @@ La façon la plus simple d'exécuter OpenHands est avec Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action). diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md index abe1ac1e685b..047aafdfbba2 100644 --- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands. ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index e43c4666ce7b..22a2c0b0cd52 100644 --- a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -44,7 +44,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index 566f61ee530c..3e0ba467ae8b 100644 --- a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -42,7 +42,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" ``` diff --git a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md index 8194a4350990..edc692b1ed2d 100644 --- a/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md +++ b/docs/i18n/ja/docusaurus-plugin-content-docs/current/usage/runtimes/docker.md @@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー ```bash docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -v $WORKSPACE_BASE:/opt/workspace_base \ @@ -82,5 +82,5 @@ docker network create openhands-network # 分離されたネットワークで OpenHands を実行 docker run # ... \ --network openhands-network \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index 393cd8bacc5e..acc41b9c8dd4 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -45,7 +45,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index b200fa23ac84..13995f4e8c9d 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -43,7 +43,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "escreva um script bash que imprima oi" ``` diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx index cd531ab53220..dd4e87aec017 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -58,17 +58,17 @@ A maneira mais fácil de executar o OpenHands é no Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` Você encontrará o OpenHands em execução em http://localhost:3000! diff --git a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md index d83905b159e5..b71cc89f42d4 100644 --- a/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/pt-BR/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md index 2ad414662c0e..2c446a980143 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md @@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -59,7 +59,7 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md index 6ecc9104bce4..b9b7e6c2651f 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md @@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345" ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -57,6 +57,6 @@ docker run -it \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue ``` diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx index 7c9c8c0af5b8..1c137961c7c0 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx @@ -11,16 +11,16 @@ 在 Docker 中运行 OpenHands 是最简单的方式。 ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` 你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。 diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md index 69848dc31be6..40c1e3c291d0 100644 --- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md +++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md @@ -11,7 +11,7 @@ ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/modules/usage/how-to/cli-mode.md b/docs/modules/usage/how-to/cli-mode.md index f5dab5c72f36..712ad368aefd 100644 --- a/docs/modules/usage/how-to/cli-mode.md +++ b/docs/modules/usage/how-to/cli-mode.md @@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -45,7 +45,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.cli ``` diff --git a/docs/modules/usage/how-to/headless-mode.md b/docs/modules/usage/how-to/headless-mode.md index ea12d5ef648f..6846ce92f70b 100644 --- a/docs/modules/usage/how-to/headless-mode.md +++ b/docs/modules/usage/how-to/headless-mode.md @@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -43,7 +43,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --add-host host.docker.internal:host-gateway \ --name openhands-app-$(date +%Y%m%d%H%M%S) \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 \ + docker.all-hands.dev/all-hands-ai/openhands:0.34 \ python -m openhands.core.main -t "write a bash script that prints hi" ``` diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index 274dda1a1511..a836c8bfbfd3 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to The easiest way to run OpenHands is in Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.33 + docker.all-hands.dev/all-hands-ai/openhands:0.34 ``` You'll find OpenHands running at http://localhost:3000! diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 31d435dc2419..52b4b9afaf86 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "0.33.0", + "version": "0.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.33.0", + "version": "0.34.0", "dependencies": { "@heroui/react": "2.7.6", "@microlink/react-json-view": "^1.26.1", diff --git a/frontend/package.json b/frontend/package.json index cac7315c0463..da55f5a5a8c6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "0.33.0", + "version": "0.34.0", "private": true, "type": "module", "engines": { diff --git a/pyproject.toml b/pyproject.toml index dbb89ba0b828..241807cfe1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "openhands-ai" -version = "0.33.0" +version = "0.34.0" description = "OpenHands: Code Less, Make More" authors = ["OpenHands"] license = "MIT" From fa559ace86cf0331eb24e4061b8e5fe3f5a9a995 Mon Sep 17 00:00:00 2001 From: Robert Brennan Date: Wed, 23 Apr 2025 11:08:32 -0400 Subject: [PATCH 10/27] Add API keys management UI to settings page (#7710) Co-authored-by: openhands --- .../routes/settings-with-payment.test.tsx | 10 +- frontend/src/api/api-keys.ts | 49 ++++++ .../features/payment/payment-form.tsx | 6 +- .../features/settings/api-key-modal-base.tsx | 33 ++++ .../features/settings/api-keys-manager.tsx | 146 ++++++++++++++++++ .../features/settings/brand-button.tsx | 3 +- .../settings/create-api-key-modal.tsx | 101 ++++++++++++ .../settings/delete-api-key-modal.tsx | 84 ++++++++++ .../features/settings/new-api-key-modal.tsx | 61 ++++++++ .../features/settings/settings-input.tsx | 5 +- .../src/hooks/mutation/use-create-api-key.ts | 16 ++ .../src/hooks/mutation/use-delete-api-key.ts | 17 ++ frontend/src/hooks/query/use-api-keys.ts | 22 +++ frontend/src/i18n/declaration.ts | 21 +++ frontend/src/i18n/translation.json | 63 ++++++++ frontend/src/routes.ts | 1 + frontend/src/routes/api-keys.tsx | 12 ++ frontend/src/routes/billing.tsx | 14 +- frontend/src/routes/settings.tsx | 4 +- 19 files changed, 642 insertions(+), 26 deletions(-) create mode 100644 frontend/src/api/api-keys.ts create mode 100644 frontend/src/components/features/settings/api-key-modal-base.tsx create mode 100644 frontend/src/components/features/settings/api-keys-manager.tsx create mode 100644 frontend/src/components/features/settings/create-api-key-modal.tsx create mode 100644 frontend/src/components/features/settings/delete-api-key-modal.tsx create mode 100644 frontend/src/components/features/settings/new-api-key-modal.tsx create mode 100644 frontend/src/hooks/mutation/use-create-api-key.ts create mode 100644 frontend/src/hooks/mutation/use-delete-api-key.ts create mode 100644 frontend/src/hooks/query/use-api-keys.ts create mode 100644 frontend/src/routes/api-keys.tsx diff --git a/frontend/__tests__/routes/settings-with-payment.test.tsx b/frontend/__tests__/routes/settings-with-payment.test.tsx index 83ddc95848b9..d86d31e85b62 100644 --- a/frontend/__tests__/routes/settings-with-payment.test.tsx +++ b/frontend/__tests__/routes/settings-with-payment.test.tsx @@ -43,10 +43,12 @@ describe("Settings Billing", () => { renderSettingsScreen(); - await waitFor(() => { - const navbar = screen.queryByTestId("settings-navbar"); - expect(navbar).not.toBeInTheDocument(); - }); + // Wait for the settings screen to be rendered + await screen.findByTestId("settings-screen"); + + // Then check that the navbar is not present + const navbar = screen.queryByTestId("settings-navbar"); + expect(navbar).not.toBeInTheDocument(); }); it("should render the navbar if SaaS mode", async () => { diff --git a/frontend/src/api/api-keys.ts b/frontend/src/api/api-keys.ts new file mode 100644 index 000000000000..cfcad2fede69 --- /dev/null +++ b/frontend/src/api/api-keys.ts @@ -0,0 +1,49 @@ +import { openHands } from "./open-hands-axios"; + +export interface ApiKey { + id: string; + name: string; + prefix: string; + created_at: string; + last_used_at: string | null; +} + +export interface CreateApiKeyResponse { + id: string; + name: string; + key: string; // Full key, only returned once upon creation + prefix: string; + created_at: string; +} + +class ApiKeysClient { + /** + * Get all API keys for the current user + */ + static async getApiKeys(): Promise { + const { data } = await openHands.get("/api/keys"); + // Ensure we always return an array, even if the API returns something else + return Array.isArray(data) ? (data as ApiKey[]) : []; + } + + /** + * Create a new API key + * @param name - A descriptive name for the API key + */ + static async createApiKey(name: string): Promise { + const { data } = await openHands.post("/api/keys", { + name, + }); + return data; + } + + /** + * Delete an API key + * @param id - The ID of the API key to delete + */ + static async deleteApiKey(id: string): Promise { + await openHands.delete(`/api/keys/${id}`); + } +} + +export default ApiKeysClient; diff --git a/frontend/src/components/features/payment/payment-form.tsx b/frontend/src/components/features/payment/payment-form.tsx index 1c8b0a641785..bc337c05dfa8 100644 --- a/frontend/src/components/features/payment/payment-form.tsx +++ b/frontend/src/components/features/payment/payment-form.tsx @@ -40,10 +40,6 @@ export function PaymentForm() { data-testid="billing-settings" className="flex flex-col gap-6 px-11 py-9" > -

- {t(I18nKey.PAYMENT$MANAGE_CREDITS)} -

-
- Balance + {t(I18nKey.PAYMENT$MANAGE_CREDITS)}
{!isLoading && ( ${Number(balance).toFixed(2)} diff --git a/frontend/src/components/features/settings/api-key-modal-base.tsx b/frontend/src/components/features/settings/api-key-modal-base.tsx new file mode 100644 index 000000000000..43d8ba89f028 --- /dev/null +++ b/frontend/src/components/features/settings/api-key-modal-base.tsx @@ -0,0 +1,33 @@ +import React, { ReactNode } from "react"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; + +interface ApiKeyModalBaseProps { + isOpen: boolean; + title: string; + width?: string; + children: ReactNode; + footer: ReactNode; +} + +export function ApiKeyModalBase({ + isOpen, + title, + width = "500px", + children, + footer, +}: ApiKeyModalBaseProps) { + if (!isOpen) return null; + + return ( + +
+

{title}

+ {children} +
{footer}
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/api-keys-manager.tsx b/frontend/src/components/features/settings/api-keys-manager.tsx new file mode 100644 index 000000000000..2490d4bce04f --- /dev/null +++ b/frontend/src/components/features/settings/api-keys-manager.tsx @@ -0,0 +1,146 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { CreateApiKeyModal } from "./create-api-key-modal"; +import { DeleteApiKeyModal } from "./delete-api-key-modal"; +import { NewApiKeyModal } from "./new-api-key-modal"; +import { useApiKeys } from "#/hooks/query/use-api-keys"; + +export function ApiKeysManager() { + const { t } = useTranslation(); + const { data: apiKeys = [], isLoading, error } = useApiKeys(); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [keyToDelete, setKeyToDelete] = useState(null); + const [newlyCreatedKey, setNewlyCreatedKey] = + useState(null); + const [showNewKeyModal, setShowNewKeyModal] = useState(false); + + // Display error toast if the query fails + if (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + + const handleKeyCreated = (newKey: CreateApiKeyResponse) => { + setNewlyCreatedKey(newKey); + setCreateModalOpen(false); + setShowNewKeyModal(true); + }; + + const handleCloseCreateModal = () => { + setCreateModalOpen(false); + }; + + const handleCloseDeleteModal = () => { + setDeleteModalOpen(false); + setKeyToDelete(null); + }; + + const handleCloseNewKeyModal = () => { + setShowNewKeyModal(false); + setNewlyCreatedKey(null); + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return "Never"; + return new Date(dateString).toLocaleString(); + }; + + return ( + <> +
+
+ setCreateModalOpen(true)} + > + {t(I18nKey.SETTINGS$CREATE_API_KEY)} + +
+ +

+ {t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)} +

+ + {isLoading && ( +
+ +
+ )} + {!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && ( +
+ + + + + + + + + + + {apiKeys.map((key) => ( + + + + + + + ))} + +
+ {t(I18nKey.SETTINGS$NAME)} + + {t(I18nKey.SETTINGS$CREATED_AT)} + + {t(I18nKey.SETTINGS$LAST_USED)} + + {t(I18nKey.SETTINGS$ACTIONS)} +
{key.name} + {formatDate(key.created_at)} + + {formatDate(key.last_used_at)} + + +
+
+ )} +
+ + {/* Create API Key Modal */} + + + {/* Delete API Key Modal */} + + + {/* Show New API Key Modal */} + + + ); +} diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx index 03210f46e9e8..e13a2aa2d51c 100644 --- a/frontend/src/components/features/settings/brand-button.tsx +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -2,7 +2,7 @@ import { cn } from "#/utils/utils"; interface BrandButtonProps { testId?: string; - variant: "primary" | "secondary"; + variant: "primary" | "secondary" | "danger"; type: React.ButtonHTMLAttributes["type"]; isDisabled?: boolean; className?: string; @@ -32,6 +32,7 @@ export function BrandButton({ "w-fit p-2 text-sm rounded disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80", variant === "primary" && "bg-primary text-[#0D0F11]", variant === "secondary" && "border border-primary text-primary", + variant === "danger" && "bg-red-600 text-white hover:bg-red-700", startContent && "flex items-center justify-center gap-2", className, )} diff --git a/frontend/src/components/features/settings/create-api-key-modal.tsx b/frontend/src/components/features/settings/create-api-key-modal.tsx new file mode 100644 index 000000000000..b97d29f349b6 --- /dev/null +++ b/frontend/src/components/features/settings/create-api-key-modal.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { CreateApiKeyResponse } from "#/api/api-keys"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; +import { useCreateApiKey } from "#/hooks/mutation/use-create-api-key"; + +interface CreateApiKeyModalProps { + isOpen: boolean; + onClose: () => void; + onKeyCreated: (newKey: CreateApiKeyResponse) => void; +} + +export function CreateApiKeyModal({ + isOpen, + onClose, + onKeyCreated, +}: CreateApiKeyModalProps) { + const { t } = useTranslation(); + const [newKeyName, setNewKeyName] = useState(""); + + const createApiKeyMutation = useCreateApiKey(); + + const handleCreateKey = async () => { + if (!newKeyName.trim()) { + displayErrorToast(t(I18nKey.ERROR$REQUIRED_FIELD)); + return; + } + + try { + const newKey = await createApiKeyMutation.mutateAsync(newKeyName); + onKeyCreated(newKey); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_CREATED)); + setNewKeyName(""); + } catch (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + const handleCancel = () => { + setNewKeyName(""); + onClose(); + }; + + const modalFooter = ( + <> + + {createApiKeyMutation.isPending ? ( + + ) : ( + t(I18nKey.BUTTON$CREATE) + )} + + + {t(I18nKey.BUTTON$CANCEL)} + + + ); + + return ( + +
+

+ {t(I18nKey.SETTINGS$CREATE_API_KEY_DESCRIPTION)} +

+ setNewKeyName(value)} + className="w-full mt-4" + type="text" + /> +
+
+ ); +} diff --git a/frontend/src/components/features/settings/delete-api-key-modal.tsx b/frontend/src/components/features/settings/delete-api-key-modal.tsx new file mode 100644 index 000000000000..187507745839 --- /dev/null +++ b/frontend/src/components/features/settings/delete-api-key-modal.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { ApiKey } from "#/api/api-keys"; +import { + displayErrorToast, + displaySuccessToast, +} from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; +import { useDeleteApiKey } from "#/hooks/mutation/use-delete-api-key"; + +interface DeleteApiKeyModalProps { + isOpen: boolean; + keyToDelete: ApiKey | null; + onClose: () => void; +} + +export function DeleteApiKeyModal({ + isOpen, + keyToDelete, + onClose, +}: DeleteApiKeyModalProps) { + const { t } = useTranslation(); + const deleteApiKeyMutation = useDeleteApiKey(); + + const handleDeleteKey = async () => { + if (!keyToDelete) return; + + try { + await deleteApiKeyMutation.mutateAsync(keyToDelete.id); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_DELETED)); + onClose(); + } catch (error) { + displayErrorToast(t(I18nKey.ERROR$GENERIC)); + } + }; + + if (!keyToDelete) return null; + + const modalFooter = ( + <> + + {deleteApiKeyMutation.isPending ? ( + + ) : ( + t(I18nKey.BUTTON$DELETE) + )} + + + {t(I18nKey.BUTTON$CANCEL)} + + + ); + + return ( + +
+

+ {t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, { + name: keyToDelete.name, + })} +

+
+
+ ); +} diff --git a/frontend/src/components/features/settings/new-api-key-modal.tsx b/frontend/src/components/features/settings/new-api-key-modal.tsx new file mode 100644 index 000000000000..2457f6a46ebc --- /dev/null +++ b/frontend/src/components/features/settings/new-api-key-modal.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { CreateApiKeyResponse } from "#/api/api-keys"; +import { displaySuccessToast } from "#/utils/custom-toast-handlers"; +import { ApiKeyModalBase } from "./api-key-modal-base"; + +interface NewApiKeyModalProps { + isOpen: boolean; + newlyCreatedKey: CreateApiKeyResponse | null; + onClose: () => void; +} + +export function NewApiKeyModal({ + isOpen, + newlyCreatedKey, + onClose, +}: NewApiKeyModalProps) { + const { t } = useTranslation(); + + const handleCopyToClipboard = () => { + if (newlyCreatedKey) { + navigator.clipboard.writeText(newlyCreatedKey.key); + displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED)); + } + }; + + if (!newlyCreatedKey) return null; + + const modalFooter = ( + <> + + {t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)} + + + {t(I18nKey.BUTTON$CLOSE)} + + + ); + + return ( + +
+

{t(I18nKey.SETTINGS$API_KEY_WARNING)}

+
+ {newlyCreatedKey.key} +
+
+
+ ); +} diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx index 75bec0c9c164..4aad109f249d 100644 --- a/frontend/src/components/features/settings/settings-input.tsx +++ b/frontend/src/components/features/settings/settings-input.tsx @@ -7,6 +7,7 @@ interface SettingsInputProps { label: string; type: React.HTMLInputTypeAttribute; defaultValue?: string; + value?: string; placeholder?: string; showOptionalTag?: boolean; isDisabled?: boolean; @@ -24,6 +25,7 @@ export function SettingsInput({ label, type, defaultValue, + value, placeholder, showOptionalTag, isDisabled, @@ -43,11 +45,12 @@ export function SettingsInput({
onChange?.(e.target.value)} + onChange={(e) => onChange && onChange(e.target.value)} name={name} disabled={isDisabled} type={type} defaultValue={defaultValue} + value={value} placeholder={placeholder} min={min} max={max} diff --git a/frontend/src/hooks/mutation/use-create-api-key.ts b/frontend/src/hooks/mutation/use-create-api-key.ts new file mode 100644 index 000000000000..fd3c05c975ee --- /dev/null +++ b/frontend/src/hooks/mutation/use-create-api-key.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys"; +import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; + +export function useCreateApiKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (name: string): Promise => + ApiKeysClient.createApiKey(name), + onSuccess: () => { + // Invalidate the API keys query to trigger a refetch + queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/src/hooks/mutation/use-delete-api-key.ts b/frontend/src/hooks/mutation/use-delete-api-key.ts new file mode 100644 index 000000000000..4f4b566fab8c --- /dev/null +++ b/frontend/src/hooks/mutation/use-delete-api-key.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import ApiKeysClient from "#/api/api-keys"; +import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys"; + +export function useDeleteApiKey() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string): Promise => { + await ApiKeysClient.deleteApiKey(id); + }, + onSuccess: () => { + // Invalidate the API keys query to trigger a refetch + queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] }); + }, + }); +} diff --git a/frontend/src/hooks/query/use-api-keys.ts b/frontend/src/hooks/query/use-api-keys.ts new file mode 100644 index 000000000000..8549d441719b --- /dev/null +++ b/frontend/src/hooks/query/use-api-keys.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import ApiKeysClient from "#/api/api-keys"; +import { useConfig } from "./use-config"; +import { useAuth } from "#/context/auth-context"; + +export const API_KEYS_QUERY_KEY = "api-keys"; + +export function useApiKeys() { + const { providersAreSet } = useAuth(); + const { data: config } = useConfig(); + + return useQuery({ + queryKey: [API_KEYS_QUERY_KEY], + enabled: providersAreSet && config?.APP_MODE === "saas", + queryFn: async () => { + const keys = await ApiKeysClient.getApiKeys(); + return Array.isArray(keys) ? keys : []; + }, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 2b103cca86ce..1128f99eff64 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -267,6 +267,27 @@ export enum I18nKey { SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS", SETTINGS$SAVED = "SETTINGS$SAVED", SETTINGS$RESET = "SETTINGS$RESET", + SETTINGS$API_KEYS = "SETTINGS$API_KEYS", + SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION", + SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY", + SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION", + SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY", + SETTINGS$DELETE_API_KEY_CONFIRMATION = "SETTINGS$DELETE_API_KEY_CONFIRMATION", + SETTINGS$NO_API_KEYS = "SETTINGS$NO_API_KEYS", + SETTINGS$NAME = "SETTINGS$NAME", + SETTINGS$KEY_PREFIX = "SETTINGS$KEY_PREFIX", + SETTINGS$CREATED_AT = "SETTINGS$CREATED_AT", + SETTINGS$LAST_USED = "SETTINGS$LAST_USED", + SETTINGS$ACTIONS = "SETTINGS$ACTIONS", + SETTINGS$API_KEY_CREATED = "SETTINGS$API_KEY_CREATED", + SETTINGS$API_KEY_DELETED = "SETTINGS$API_KEY_DELETED", + SETTINGS$API_KEY_WARNING = "SETTINGS$API_KEY_WARNING", + SETTINGS$API_KEY_COPIED = "SETTINGS$API_KEY_COPIED", + SETTINGS$API_KEY_NAME_PLACEHOLDER = "SETTINGS$API_KEY_NAME_PLACEHOLDER", + BUTTON$CREATE = "BUTTON$CREATE", + BUTTON$DELETE = "BUTTON$DELETE", + BUTTON$COPY_TO_CLIPBOARD = "BUTTON$COPY_TO_CLIPBOARD", + ERROR$REQUIRED_FIELD = "ERROR$REQUIRED_FIELD", PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE", FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL", FEEDBACK$PRIVATE_LABEL = "FEEDBACK$PRIVATE_LABEL", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 17c03f413618..876572504b6c 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -3983,6 +3983,69 @@ "tr": "Ayarlar sıfırlandı", "de": "Einstellungen zurückgesetzt" }, + "SETTINGS$API_KEYS": { + "en": "API Keys" + }, + "SETTINGS$API_KEYS_DESCRIPTION": { + "en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account." + }, + "SETTINGS$CREATE_API_KEY": { + "en": "Create API Key" + }, + "SETTINGS$CREATE_API_KEY_DESCRIPTION": { + "en": "Give your API key a descriptive name to help you identify it later." + }, + "SETTINGS$DELETE_API_KEY": { + "en": "Delete API Key" + }, + "SETTINGS$DELETE_API_KEY_CONFIRMATION": { + "en": "Are you sure you want to delete the API key \"{{name}}\"? This action cannot be undone." + }, + "SETTINGS$NO_API_KEYS": { + "en": "You don't have any API keys yet. Create one to get started." + }, + "SETTINGS$NAME": { + "en": "Name" + }, + "SETTINGS$KEY_PREFIX": { + "en": "Key Prefix" + }, + "SETTINGS$CREATED_AT": { + "en": "Created" + }, + "SETTINGS$LAST_USED": { + "en": "Last Used" + }, + "SETTINGS$ACTIONS": { + "en": "Actions" + }, + "SETTINGS$API_KEY_CREATED": { + "en": "API Key Created" + }, + "SETTINGS$API_KEY_DELETED": { + "en": "API key deleted successfully" + }, + "SETTINGS$API_KEY_WARNING": { + "en": "This is the only time your API key will be displayed. Please copy it now and store it securely." + }, + "SETTINGS$API_KEY_COPIED": { + "en": "API key copied to clipboard" + }, + "SETTINGS$API_KEY_NAME_PLACEHOLDER": { + "en": "My API Key" + }, + "BUTTON$CREATE": { + "en": "Create" + }, + "BUTTON$DELETE": { + "en": "Delete" + }, + "BUTTON$COPY_TO_CLIPBOARD": { + "en": "Copy to Clipboard" + }, + "ERROR$REQUIRED_FIELD": { + "en": "This field is required" + }, "PLANNER$EMPTY_MESSAGE": { "en": "No plan created.", "zh-CN": "计划未创建", diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 9c8f1e4bd899..3543a2f65ecc 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -11,6 +11,7 @@ export default [ route("settings", "routes/settings.tsx", [ index("routes/account-settings.tsx"), route("billing", "routes/billing.tsx"), + route("api-keys", "routes/api-keys.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx", [ index("routes/editor.tsx"), diff --git a/frontend/src/routes/api-keys.tsx b/frontend/src/routes/api-keys.tsx new file mode 100644 index 000000000000..b1609c75f9c6 --- /dev/null +++ b/frontend/src/routes/api-keys.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"; + +function ApiKeysScreen() { + return ( +
+ +
+ ); +} + +export default ApiKeysScreen; diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx index 7d1a4ead2e83..fdd410f6c4e3 100644 --- a/frontend/src/routes/billing.tsx +++ b/frontend/src/routes/billing.tsx @@ -1,25 +1,13 @@ -import { redirect, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import React from "react"; import { useTranslation } from "react-i18next"; import { PaymentForm } from "#/components/features/payment/payment-form"; -import { GetConfigResponse } from "#/api/open-hands.types"; -import { queryClient } from "#/entry.client"; import { displayErrorToast, displaySuccessToast, } from "#/utils/custom-toast-handlers"; import { I18nKey } from "#/i18n/declaration"; -export const clientLoader = async () => { - const config = queryClient.getQueryData(["config"]); - - if (config?.APP_MODE !== "saas" || !config.FEATURE_FLAGS.ENABLE_BILLING) { - return redirect("/settings"); - } - - return null; -}; - function BillingSettingsScreen() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index b89fa2db868c..686e422c7f68 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -9,7 +9,6 @@ function SettingsScreen() { const { t } = useTranslation(); const { data: config } = useConfig(); const isSaas = config?.APP_MODE === "saas"; - const billingIsEnabled = config?.FEATURE_FLAGS.ENABLE_BILLING; return (
{t(I18nKey.SETTINGS$TITLE)} - {isSaas && billingIsEnabled && ( + {isSaas && (