Compare commits

..

8 Commits

Author SHA1 Message Date
Graham Neubig
6ba79c454b feat(llm): Add Claude 3.7 backend configurations (#6937)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-02-25 16:46:53 +00:00
sp.wack
fbc06f42aa chore(frontend): Claude 3.7 is visible in dropdown for selection (#6931) 2025-02-25 08:17:33 -05:00
mamoodi
f35ed5e277 Add documentation checkbox to PR template (#6924) 2025-02-24 16:03:10 -05:00
mamoodi
6787a3adf7 Release 0.26.0 (#6915) 2025-02-24 15:34:34 -05:00
celek
fa50e0c9b9 add extended generic section (#5932)
Co-authored-by: Christophe Elek <christophe.elek@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-02-24 20:31:02 +01:00
tofarr
f4c5bbda19 Revert "Fix file descriptor leak (#6897)" (#6921) 2025-02-24 13:47:13 -05:00
Mateusz Kwiatkowski
6562297615 Replace shebang with /usr/bin/env bash for improved portability (#6876)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-02-24 18:07:28 +00:00
Xingyao Wang
0217a7cfbd chore: Make remote runtime class default to None (#6919) 2025-02-24 17:51:48 +00:00
77 changed files with 322 additions and 660 deletions

View File

@@ -1,11 +1,12 @@
**End-user friendly description of the problem this fixes or functionality that this introduces**
- [ ] Include this change in the Release Notes. If checked, you must provide an **end-user friendly** description for your change below
---
**Give a summary of what the PR does, explaining any non-trivial design decisions**
- [ ] This change is worth documenting at https://docs.all-hands.dev/
- [ ] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below
**End-user friendly description of the problem this fixes or functionality that this introduces.**
---
**Link of any specific issues this addresses**
**Give a summary of what the PR does, explaining any non-trivial design decisions.**
---
**Link of any specific issues this addresses.**

View File

@@ -100,7 +100,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.25-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.26-nikolaik`
## Develop inside Docker container

View File

@@ -1,4 +1,4 @@
SHELL=/bin/bash
SHELL=/usr/bin/env bash
# Makefile for OpenHands project
# Variables

View File

@@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.26-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25
docker.all-hands.dev/all-hands-ai/openhands:0.26
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
poetry build -v

View File

@@ -83,9 +83,6 @@ workspace_base = "./workspace"
# Runtime environment
#runtime = "docker"
# Runtime executor
#runtime_executor = "openhands.runtime.executor:ActionExecutor"
# Name of the default agent
#default_agent = "CodeActAgent"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
# Initialize variables with default values

View File

@@ -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.25-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.26-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -o pipefail
function get_docker() {

View File

@@ -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.25-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.26-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:

View File

@@ -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.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25 \
docker.all-hands.dev/all-hands-ai/openhands:0.26 \
python -m openhands.core.cli
```

View File

@@ -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.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25 \
docker.all-hands.dev/all-hands-ai/openhands:0.26 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -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.25-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.26-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25
docker.all-hands.dev/all-hands-ai/openhands:0.26
```
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).

View File

@@ -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.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -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.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25 \
docker.all-hands.dev/all-hands-ai/openhands:0.26 \
python -m openhands.core.cli
```

View File

@@ -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.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25 \
docker.all-hands.dev/all-hands-ai/openhands:0.26 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.26-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25
docker.all-hands.dev/all-hands-ai/openhands:0.26
```
你也可以在可脚本化的[无头模式](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)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -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.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25 \
docker.all-hands.dev/all-hands-ai/openhands:0.26 \
python -m openhands.core.cli
```

View File

@@ -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.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25 \
docker.all-hands.dev/all-hands-ai/openhands:0.26 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -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.25-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.26-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-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.25
docker.all-hands.dev/all-hands-ai/openhands:0.26
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.25-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.26-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Step 1: Stop all running containers
echo "Stopping all running containers..."

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
RESULT_FILE=$1
MODEL_CONFIG=$2

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
LEVEL=$1

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# This is ONLY used for pushing docker images created by https://github.com/princeton-nlp/SWE-bench/blob/main/docs/20240627_docker/README.md

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
FOLDER_PATH=$1
NEW_FOLDER_PATH=${FOLDER_PATH}.swebench_submission

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
PROCESS_FILEPATH=$1
if [ -z "$PROCESS_FILEPATH" ]; then

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
INPUT_FILE=$1

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
source ~/.bashrc
SWEUTIL_DIR=/swe_util

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
EVAL_WORKSPACE="evaluation/benchmarks/swe_bench/eval_workspace"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
##################################################################################################
# Adapted from https://github.com/TheAgentCompany/TheAgentCompany/blob/main/evaluation/run_eval.sh

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"

View File

@@ -1,2 +1,2 @@
#!/bin/bash
#!/usr/bin/env bash
echo "hello world"

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# API base URL

View File

@@ -65,6 +65,12 @@ describe("extractModelAndProvider", () => {
separator: "/",
});
expect(extractModelAndProvider("claude-3-7-sonnet-20250219")).toEqual({
provider: "anthropic",
model: "claude-3-7-sonnet-20250219",
separator: "/",
});
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
provider: "anthropic",
model: "claude-3-haiku-20240307",

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.25.0",
"version": "0.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.25.0",
"version": "0.26.0",
"dependencies": {
"@heroui/react": "2.6.14",
"@monaco-editor/react": "^4.7.0-rc.0",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.25.0",
"version": "0.26.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -3,6 +3,7 @@ export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek"];
export const VERIFIED_MODELS = [
"o3-mini-2025-01-31",
"claude-3-5-sonnet-20241022",
"claude-3-7-sonnet-20250219",
"deepseek-chat",
];
@@ -31,4 +32,5 @@ export const VERIFIED_ANTHROPIC_MODELS = [
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-7-sonnet-20250219",
];

View File

@@ -5,6 +5,7 @@ from openhands.core.config.config_utils import (
OH_MAX_ITERATIONS,
get_field_info,
)
from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.sandbox_config import SandboxConfig
from openhands.core.config.security_config import SecurityConfig
@@ -28,6 +29,7 @@ __all__ = [
'LLMConfig',
'SandboxConfig',
'SecurityConfig',
'ExtendedConfig',
'load_app_config',
'load_from_env',
'load_from_toml',

View File

@@ -9,6 +9,7 @@ from openhands.core.config.config_utils import (
OH_MAX_ITERATIONS,
model_defaults_to_dict,
)
from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.sandbox_config import SandboxConfig
from openhands.core.config.security_config import SecurityConfig
@@ -52,6 +53,7 @@ class AppConfig(BaseModel):
default_agent: str = Field(default=OH_DEFAULT_AGENT)
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
extended: ExtendedConfig = Field(default_factory=lambda: ExtendedConfig({}))
runtime: str = Field(default='docker')
file_store: str = Field(default='local')
file_store_path: str = Field(default='/tmp/openhands_file_store')

View File

@@ -0,0 +1,40 @@
from pydantic import RootModel
class ExtendedConfig(RootModel[dict]):
"""Configuration for extended functionalities.
This is implemented as a root model so that the entire input is stored
as the root value. This allows arbitrary keys to be stored and later
accessed via attribute or dictionary-style access.
"""
@property
def root(self) -> dict: # type annotation to help mypy
return super().root
def __str__(self) -> str:
# Use the root dict to build a string representation.
attr_str = [f'{k}={repr(v)}' for k, v in self.root.items()]
return f"ExtendedConfig({', '.join(attr_str)})"
def __repr__(self) -> str:
return self.__str__()
@classmethod
def from_dict(cls, data: dict) -> 'ExtendedConfig':
# Create an instance directly by wrapping the input dict.
return cls(data)
def __getitem__(self, key: str) -> object:
# Provide dictionary-like access via the root dict.
return self.root[key]
def __getattr__(self, key: str) -> object:
# Fallback for attribute access using the root dict.
try:
return self.root[key]
except KeyError as e:
raise AttributeError(
f"'ExtendedConfig' object has no attribute '{key}'"
) from e

View File

@@ -53,11 +53,11 @@ class SandboxConfig(BaseModel):
remote_runtime_api_timeout: int = Field(default=10)
remote_runtime_enable_retries: bool = Field(default=False)
remote_runtime_class: str | None = Field(
default='sysbox'
default=None
) # can be "None" (default to gvisor) or "sysbox" (support docker inside runtime + more stable)
enable_auto_lint: bool = Field(
default=False # once enabled, OpenHands would lint files after editing
)
default=False
) # once enabled, OpenHands would lint files after editing
use_host_network: bool = Field(default=False)
runtime_extra_build_args: list[str] | None = Field(default=None)
initialize_plugins: bool = Field(default=True)

View File

@@ -19,6 +19,7 @@ from openhands.core.config.config_utils import (
OH_DEFAULT_AGENT,
OH_MAX_ITERATIONS,
)
from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.sandbox_config import SandboxConfig
from openhands.core.config.security_config import SecurityConfig
@@ -134,6 +135,10 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None:
for key, value in toml_config.items():
if isinstance(value, dict):
try:
if key.lower() == 'extended':
# For ExtendedConfig (RootModel), pass the entire dict as the root value
cfg.extended = ExtendedConfig(value)
continue
if key is not None and key.lower() == 'agent':
# Every entry here is either a field for the default `agent` config group, or itself a group
# The best way to tell the difference is to try to parse it as an AgentConfig object

View File

@@ -8,7 +8,6 @@ from typing import Any, Callable
import requests
from openhands.core.config import LLMConfig
from openhands.utils.ensure_httpx_close import EnsureHttpxClose
with warnings.catch_warnings():
warnings.simplefilter('ignore')
@@ -43,6 +42,7 @@ LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (RateLimitError,)
# cache prompt supporting models
# remove this when we gemini and deepseek are supported
CACHE_PROMPT_SUPPORTED_MODELS = [
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
'claude-3-5-sonnet-20240620',
'claude-3-5-haiku-20241022',
@@ -52,6 +52,7 @@ CACHE_PROMPT_SUPPORTED_MODELS = [
# function calling supporting models
FUNCTION_CALLING_SUPPORTED_MODELS = [
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet',
'claude-3-5-sonnet-20240620',
'claude-3-5-sonnet-20241022',
@@ -231,9 +232,9 @@ class LLM(RetryMixin, DebugMixin):
# Record start time for latency measurement
start_time = time.time()
with EnsureHttpxClose():
# we don't support streaming here, thus we get a ModelResponse
resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
# we don't support streaming here, thus we get a ModelResponse
resp: ModelResponse = self._completion_unwrapped(*args, **kwargs)
# Calculate and record latency
latency = time.time() - start_time
@@ -288,11 +289,7 @@ class LLM(RetryMixin, DebugMixin):
'messages': messages,
'response': resp,
'args': args,
'kwargs': {
k: v
for k, v in kwargs.items()
if k not in ('messages', 'client')
},
'kwargs': {k: v for k, v in kwargs.items() if k != 'messages'},
'timestamp': time.time(),
'cost': cost,
}

View File

@@ -15,7 +15,7 @@ import tempfile
import time
import traceback
from contextlib import asynccontextmanager
from typing import Type
from pathlib import Path
from zipfile import ZipFile
from fastapi import Depends, FastAPI, HTTPException, Request, UploadFile
@@ -60,6 +60,7 @@ from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
class ActionRequest(BaseModel):
@@ -67,6 +68,7 @@ class ActionRequest(BaseModel):
ROOT_GID = 0
SESSION_API_KEY = os.environ.get('SESSION_API_KEY')
api_key_header = APIKeyHeader(name='X-Session-API-Key', auto_error=False)
@@ -133,6 +135,7 @@ class ActionExecutor:
"""ActionExecutor is running inside docker sandbox.
It is responsible for executing actions received from OpenHands backend and producing observations.
"""
def __init__(
self,
plugins_to_load: list[Plugin],
@@ -460,7 +463,6 @@ class ActionExecutor:
if self.bash_session is not None:
self.bash_session.close()
self.browser.close()
>>>>>>> origin/main
if __name__ == '__main__':
@@ -478,12 +480,6 @@ if __name__ == '__main__':
help='BrowserGym environment used for browser evaluation',
default=None,
)
parser.add_argument(
'--executor-class',
type=str,
default='openhands.runtime.executor:ActionExecutor',
help='Action executor class to use (format: module.path:ClassName)',
)
# example: python client.py 8000 --working-dir /workspace --plugins JupyterRequirement
args = parser.parse_args()
@@ -494,13 +490,12 @@ if __name__ == '__main__':
raise ValueError(f'Plugin {plugin} not found')
plugins_to_load.append(ALL_PLUGINS[plugin]()) # type: ignore
executor_class = get_action_executor_class(args.executor_class)
client: RuntimeExecutor | None = None
client: ActionExecutor | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global client
client = executor_class(
client = ActionExecutor(
plugins_to_load,
work_dir=args.working_dir,
username=args.username,

View File

@@ -1,4 +0,0 @@
from .base import RuntimeExecutor
from .action_executor import ActionExecutor, BaseActionExecutor
__all__ = ['ActionExecutor', 'BaseActionExecutor', 'RuntimeExecutor']

View File

@@ -1,267 +0,0 @@
import base64
import json
import mimetypes
import os
from pathlib import Path
import re
from openhands_aci.utils.diff import get_diff
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
from openhands.events.action.commands import IPythonRunCellAction
from openhands.events.action.files import FileReadAction, FileWriteAction
from openhands.events.event import FileEditSource, FileReadSource
from openhands.events.observation.commands import (
IPythonRunCellObservation,
)
from openhands.events.observation.error import ErrorObservation
from openhands.events.observation.files import (
FileEditObservation,
FileReadObservation,
FileWriteObservation,
)
from openhands.events.observation.observation import Observation
from openhands.runtime.browser import browse
from openhands.runtime.executor.base import RuntimeExecutor
from openhands.runtime.plugins.jupyter import JupyterPlugin
from openhands.runtime.utils.files import insert_lines, read_lines
class BaseActionExecutor(RuntimeExecutor):
"""Runtime executor that dynamically dispatches actions to the appropriate method based on their name."""
async def run_action(self, action) -> Observation:
async with self.lock:
action_type = action.action
logger.debug(f'Running action:\n{action}')
observation = await getattr(self, action_type)(action)
logger.debug(f'Action output:\n{observation}')
return observation
class ActionExecutor(BaseActionExecutor):
"""ActionExecutor runs inside docker sandbox.
It is responsible for executing actions received from OpenHands backend and producing observations.
It is a BaseActionExectuor that provides a default implementation for all of the built-in actions.
"""
async def run_ipython(self, action: IPythonRunCellAction) -> Observation:
assert self.bash_session is not None
if 'jupyter' in self.plugins:
_jupyter_plugin: JupyterPlugin = self.plugins['jupyter'] # type: ignore
# This is used to make AgentSkills in Jupyter aware of the
# current working directory in Bash
jupyter_cwd = getattr(self, '_jupyter_cwd', None)
if self.bash_session.cwd != jupyter_cwd:
logger.debug(
f'{self.bash_session.cwd} != {jupyter_cwd} -> reset Jupyter PWD'
)
reset_jupyter_cwd_code = (
f'import os; os.chdir("{self.bash_session.cwd}")'
)
_aux_action = IPythonRunCellAction(code=reset_jupyter_cwd_code)
_reset_obs: IPythonRunCellObservation = await _jupyter_plugin.run(
_aux_action
)
logger.debug(
f'Changed working directory in IPython to: {self.bash_session.cwd}. Output: {_reset_obs}'
)
self._jupyter_cwd = self.bash_session.cwd
obs: IPythonRunCellObservation = await _jupyter_plugin.run(action)
obs.content = obs.content.rstrip()
matches = re.findall(
r'<oh_aci_output_[0-9a-f]{32}>(.*?)</oh_aci_output_[0-9a-f]{32}>',
obs.content,
re.DOTALL,
)
if matches:
results: list[str] = []
if len(matches) == 1:
# Use specific actions/observations types
match = matches[0]
try:
result_dict = json.loads(match)
if result_dict.get('path'): # Successful output
if (
result_dict['new_content'] is not None
): # File edit commands
diff = get_diff(
old_contents=result_dict['old_content']
or '', # old_content is None when file is created
new_contents=result_dict['new_content'],
filepath=result_dict['path'],
)
return FileEditObservation(
content=diff,
path=result_dict['path'],
old_content=result_dict['old_content'],
new_content=result_dict['new_content'],
prev_exist=result_dict['prev_exist'],
impl_source=FileEditSource.OH_ACI,
formatted_output_and_error=result_dict[
'formatted_output_and_error'
],
)
else: # File view commands
return FileReadObservation(
content=result_dict['formatted_output_and_error'],
path=result_dict['path'],
impl_source=FileReadSource.OH_ACI,
)
else: # Error output
results.append(result_dict['formatted_output_and_error'])
except json.JSONDecodeError:
# Handle JSON decoding errors if necessary
results.append(
f"Invalid JSON in 'openhands-aci' output: {match}"
)
else:
for match in matches:
try:
result_dict = json.loads(match)
results.append(result_dict['formatted_output_and_error'])
except json.JSONDecodeError:
# Handle JSON decoding errors if necessary
results.append(
f"Invalid JSON in 'openhands-aci' output: {match}"
)
# Combine the results (e.g., join them) or handle them as required
obs.content = '\n'.join(str(result) for result in results)
if action.include_extra:
obs.content += (
f'\n[Jupyter current working directory: {self.bash_session.cwd}]'
)
obs.content += f'\n[Jupyter Python interpreter: {_jupyter_plugin.python_interpreter_path}]'
return obs
else:
raise RuntimeError(
'JupyterRequirement not found. Unable to run IPython action.'
)
def _resolve_path(self, path: str, working_dir: str) -> str:
filepath = Path(path)
if not filepath.is_absolute():
return str(Path(working_dir) / filepath)
return str(filepath)
async def read(self, action: FileReadAction) -> Observation:
assert self.bash_session is not None
if action.impl_source == FileReadSource.OH_ACI:
return await self.run_ipython(
IPythonRunCellAction(
code=action.translated_ipython_code,
include_extra=False,
)
)
# NOTE: the client code is running inside the sandbox,
# so there's no need to check permission
working_dir = self.bash_session.cwd
filepath = self._resolve_path(action.path, working_dir)
try:
if filepath.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
with open(filepath, 'rb') as file:
image_data = file.read()
encoded_image = base64.b64encode(image_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
mime_type = 'image/png' # default to PNG if mime type cannot be determined
encoded_image = f'data:{mime_type};base64,{encoded_image}'
return FileReadObservation(path=filepath, content=encoded_image)
elif filepath.lower().endswith('.pdf'):
with open(filepath, 'rb') as file:
pdf_data = file.read()
encoded_pdf = base64.b64encode(pdf_data).decode('utf-8')
encoded_pdf = f'data:application/pdf;base64,{encoded_pdf}'
return FileReadObservation(path=filepath, content=encoded_pdf)
elif filepath.lower().endswith(('.mp4', '.webm', '.ogg')):
with open(filepath, 'rb') as file:
video_data = file.read()
encoded_video = base64.b64encode(video_data).decode('utf-8')
mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
mime_type = 'video/mp4' # default to MP4 if MIME type cannot be determined
encoded_video = f'data:{mime_type};base64,{encoded_video}'
return FileReadObservation(path=filepath, content=encoded_video)
with open(filepath, 'r', encoding='utf-8') as file:
lines = read_lines(file.readlines(), action.start, action.end)
except FileNotFoundError:
return ErrorObservation(
f'File not found: {filepath}. Your current working directory is {working_dir}.'
)
except UnicodeDecodeError:
return ErrorObservation(f'File could not be decoded as utf-8: {filepath}.')
except IsADirectoryError:
return ErrorObservation(
f'Path is a directory: {filepath}. You can only read files'
)
code_view = ''.join(lines)
return FileReadObservation(path=filepath, content=code_view)
async def write(self, action: FileWriteAction) -> Observation:
assert self.bash_session is not None
working_dir = self.bash_session.cwd
filepath = self._resolve_path(action.path, working_dir)
insert = action.content.split('\n')
try:
if not os.path.exists(os.path.dirname(filepath)):
os.makedirs(os.path.dirname(filepath))
file_exists = os.path.exists(filepath)
if file_exists:
file_stat = os.stat(filepath)
else:
file_stat = None
mode = 'w' if not file_exists else 'r+'
try:
with open(filepath, mode, encoding='utf-8') as file:
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(
insert, all_lines, action.start, action.end
)
else:
new_file = [i + '\n' for i in insert]
file.seek(0)
file.writelines(new_file)
file.truncate()
# Handle file permissions
if file_exists:
assert file_stat is not None
# restore the original file permissions if the file already exists
os.chmod(filepath, file_stat.st_mode)
os.chown(filepath, file_stat.st_uid, file_stat.st_gid)
else:
# set the new file permissions if the file is new
os.chmod(filepath, 0o664)
os.chown(filepath, self.user_id, self.user_id)
except FileNotFoundError:
return ErrorObservation(f'File not found: {filepath}')
except IsADirectoryError:
return ErrorObservation(
f'Path is a directory: {filepath}. You can only write to files'
)
except UnicodeDecodeError:
return ErrorObservation(
f'File could not be decoded as utf-8: {filepath}'
)
except PermissionError:
return ErrorObservation(f'Malformed paths not permitted: {filepath}')
return FileWriteObservation(content='', path=filepath)
async def browse(self, action: BrowseURLAction) -> Observation:
return await browse(action, self.browser)
async def browse_interactive(self, action: BrowseInteractiveAction) -> Observation:
return await browse(action, self.browser)

View File

@@ -1,126 +0,0 @@
import asyncio
import time
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.commands import CmdRunAction, IPythonRunCellAction
from openhands.events.observation.commands import CmdOutputObservation
from openhands.events.observation.error import ErrorObservation
from openhands.runtime.browser.browser_env import BrowserEnv
from openhands.runtime.plugins.jupyter import JupyterPlugin
from openhands.runtime.plugins.requirement import Plugin
from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.utils.async_utils import call_sync_from_async, wait_all
ROOT_GID = 0
INIT_COMMANDS = [
'git config --global user.name "openhands" && git config --global user.email "openhands@all-hands.dev" && alias git="git --no-pager"',
]
class RuntimeExecutor:
"""RuntimeExecutor for running inside docker sandbox.
It provides a minimal base class that handles initialization of the executor, and provides a run method to execute bash commands.
"""
def __init__(
self,
plugins_to_load: list[Plugin],
work_dir: str,
username: str,
user_id: int,
browsergym_eval_env: str | None,
) -> None:
self.plugins_to_load = plugins_to_load
self._initial_cwd = work_dir
self.username = username
self.user_id = user_id
_updated_user_id = init_user_and_working_directory(
username=username, user_id=self.user_id, initial_cwd=work_dir
)
if _updated_user_id is not None:
self.user_id = _updated_user_id
self.bash_session: BashSession | None = None
self.lock = asyncio.Lock()
self.plugins: dict[str, Plugin] = {}
self.browser = BrowserEnv(browsergym_eval_env)
self.start_time = time.time()
self.last_execution_time = self.start_time
self._initialized = False
@property
def initial_cwd(self):
return self._initial_cwd
async def ainit(self):
# bash needs to be initialized first
self.bash_session = BashSession(
work_dir=self._initial_cwd,
username=self.username,
)
self.bash_session.initialize()
await wait_all(
(self._init_plugin(plugin) for plugin in self.plugins_to_load),
timeout=30,
)
# This is a temporary workaround
# TODO: refactor AgentSkills to be part of JupyterPlugin
# AFTER ServerRuntime is deprecated
if 'agent_skills' in self.plugins and 'jupyter' in self.plugins:
obs = await self.run_ipython(
IPythonRunCellAction(
code='from openhands.runtime.plugins.agent_skills.agentskills import *\n'
)
)
logger.debug(f'AgentSkills initialized: {obs}')
await self._init_bash_commands()
logger.debug('Runtime client initialized.')
self._initialized = True
@property
def initialized(self) -> bool:
return self._initialized
async def _init_plugin(self, plugin: Plugin):
assert self.bash_session is not None
await plugin.initialize(self.username)
self.plugins[plugin.name] = plugin
logger.debug(f'Initializing plugin: {plugin.name}')
if isinstance(plugin, JupyterPlugin):
await self.run_ipython(
IPythonRunCellAction(
code=f'import os; os.chdir("{self.bash_session.cwd}")'
)
)
async def _init_bash_commands(self):
logger.debug(f'Initializing by running {len(INIT_COMMANDS)} bash commands...')
for command in INIT_COMMANDS:
action = CmdRunAction(command=command)
action.timeout = 300
logger.debug(f'Executing init command: {command}')
obs = await self.run(action)
assert isinstance(obs, CmdOutputObservation)
logger.debug(
f'Init command outputs (exit code: {obs.exit_code}): {obs.content}'
)
assert obs.exit_code == 0
logger.debug('Bash init commands completed')
async def run(
self, action: CmdRunAction
) -> CmdOutputObservation | ErrorObservation:
assert self.bash_session is not None
obs = await call_sync_from_async(self.bash_session.execute, action)
return obs
def close(self):
if self.bash_session is not None:
self.bash_session.close()
self.browser.close()

View File

@@ -55,8 +55,6 @@ def get_action_execution_server_startup_command(
'--user-id',
str(user_id),
*browsergym_args,
'--executor-class',
app_config.runtime_executor,
]
return base_cmd

View File

@@ -1,43 +0,0 @@
"""
LiteLLM currently have an issue where HttpHandlers are being created but not
closed. We have submitted a PR to them, (https://github.com/BerriAI/litellm/pull/8711)
and their dev team say they are in the process of a refactor that will fix this, but
in the meantime, we need to manage the lifecycle of the httpx.Client manually.
We can't simply pass in our own client object, because all the different implementations use
different types of client object.
So we monkey patch the httpx.Client class to track newly created instances and close these
when the operations complete. (This is relatively safe, as if the client is reused after this
then is will transparently reopen)
Hopefully, this will be fixed soon and we can remove this abomination.
"""
from dataclasses import dataclass, field
from functools import wraps
from typing import Callable
from httpx import Client
@dataclass
class EnsureHttpxClose:
clients: list[Client] = field(default_factory=list)
original_init: Callable | None = None
def __enter__(self):
self.original_init = Client.__init__
@wraps(Client.__init__)
def init_wrapper(*args, **kwargs):
self.clients.append(args[0])
return self.original_init(*args, **kwargs) # type: ignore
Client.__init__ = init_wrapper
def __exit__(self, type, value, traceback):
Client.__init__ = self.original_init
while self.clients:
client = self.clients.pop()
client.close()

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.25.0"
version = "0.26.0"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"
@@ -109,7 +109,6 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -138,7 +137,6 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"

View File

@@ -0,0 +1,169 @@
import os
import pytest
from openhands.core.config.app_config import AppConfig
from openhands.core.config.extended_config import ExtendedConfig
from openhands.core.config.utils import load_from_toml
def test_extended_config_from_dict():
"""
Test that ExtendedConfig.from_dict successfully creates an instance
from a dictionary containing arbitrary extra keys.
"""
data = {'foo': 'bar', 'baz': 123, 'flag': True}
ext_cfg = ExtendedConfig.from_dict(data)
# Check that the keys are accessible both as attributes and via __getitem__
assert ext_cfg.foo == 'bar'
assert ext_cfg['baz'] == 123
assert ext_cfg.flag is True
# Verify the root dictionary contains all keys
assert ext_cfg.root == data
def test_extended_config_empty():
"""
Test that an empty ExtendedConfig can be created and accessed.
"""
ext_cfg = ExtendedConfig.from_dict({})
assert ext_cfg.root == {}
# Creating directly should also work
ext_cfg2 = ExtendedConfig({})
assert ext_cfg2.root == {}
def test_extended_config_str_and_repr():
"""
Test that __str__ and __repr__ return the correct string representations
of the ExtendedConfig instance.
"""
data = {'alpha': 'test', 'beta': 42}
ext_cfg = ExtendedConfig.from_dict(data)
string_repr = str(ext_cfg)
repr_str = repr(ext_cfg)
# Ensure the representations include our key/value pairs
assert "alpha='test'" in string_repr
assert 'beta=42' in string_repr
# __repr__ should match __str__
assert string_repr == repr_str
def test_extended_config_getitem_and_getattr():
"""
Test that __getitem__ and __getattr__ can be used to access values
in the ExtendedConfig instance.
"""
data = {'key1': 'value1', 'key2': 2}
ext_cfg = ExtendedConfig.from_dict(data)
# Attribute access
assert ext_cfg.key1 == 'value1'
# Dictionary-style access
assert ext_cfg['key2'] == 2
def test_extended_config_invalid_key():
"""
Test that accessing a non-existent key via attribute access raises AttributeError.
"""
data = {'existing': 'yes'}
ext_cfg = ExtendedConfig.from_dict(data)
with pytest.raises(AttributeError):
_ = ext_cfg.nonexistent
with pytest.raises(KeyError):
_ = ext_cfg['nonexistent']
def test_app_config_extended_from_toml(tmp_path: os.PathLike) -> None:
"""
Test that the [extended] section in a TOML file is correctly loaded into
AppConfig.extended and that it accepts arbitrary keys.
"""
# Create a temporary TOML file with multiple sections including [extended]
config_content = """
[core]
workspace_base = "/tmp/workspace"
[llm]
model = "test-model"
api_key = "toml-api-key"
[extended]
custom1 = "custom_value"
custom2 = 42
llm = "overridden" # even a key like 'llm' is accepted in extended
[agent]
memory_enabled = true
"""
config_file = tmp_path / 'config.toml'
config_file.write_text(config_content)
# Load the TOML into the AppConfig instance
config = AppConfig()
load_from_toml(config, str(config_file))
# Verify that extended section is applied
assert config.extended.custom1 == 'custom_value'
assert config.extended.custom2 == 42
# Even though 'llm' is defined in extended, it should not affect the main llm config.
assert config.get_llm_config().model == 'test-model'
def test_app_config_extended_default(tmp_path: os.PathLike) -> None:
"""
Test that if there is no [extended] section in the TOML file,
AppConfig.extended remains its default (empty) ExtendedConfig.
"""
config_content = """
[core]
workspace_base = "/tmp/workspace"
[llm]
model = "test-model"
api_key = "toml-api-key"
[agent]
memory_enabled = true
"""
config_file = tmp_path / 'config.toml'
config_file.write_text(config_content)
config = AppConfig()
load_from_toml(config, str(config_file))
# Extended config should be empty
assert config.extended.root == {}
def test_app_config_extended_random_keys(tmp_path: os.PathLike) -> None:
"""
Test that the extended section accepts arbitrary keys,
including ones not defined in any schema.
"""
config_content = """
[core]
workspace_base = "/tmp/workspace"
[extended]
random_key = "random_value"
another_key = 3.14
"""
config_file = tmp_path / 'config.toml'
config_file.write_text(config_content)
config = AppConfig()
load_from_toml(config, str(config_file))
# Verify that extended config holds the arbitrary keys with correct values.
assert config.extended.random_key == 'random_value'
assert config.extended.another_key == 3.14
# Verify the root dictionary contains all keys
assert config.extended.root == {'random_key': 'random_value', 'another_key': 3.14}

View File

@@ -1,84 +0,0 @@
from httpx import Client
from openhands.utils.ensure_httpx_close import EnsureHttpxClose
def test_ensure_httpx_close_basic():
"""Test basic functionality of EnsureHttpxClose."""
clients = []
ctx = EnsureHttpxClose()
with ctx:
# Create a client - should be tracked
client = Client()
assert client in ctx.clients
assert len(ctx.clients) == 1
clients.append(client)
# After context exit, client should be closed
assert client.is_closed
def test_ensure_httpx_close_multiple_clients():
"""Test EnsureHttpxClose with multiple clients."""
ctx = EnsureHttpxClose()
with ctx:
client1 = Client()
client2 = Client()
assert len(ctx.clients) == 2
assert client1 in ctx.clients
assert client2 in ctx.clients
assert client1.is_closed
assert client2.is_closed
def test_ensure_httpx_close_nested():
"""Test nested usage of EnsureHttpxClose."""
outer_ctx = EnsureHttpxClose()
with outer_ctx:
client1 = Client()
assert client1 in outer_ctx.clients
inner_ctx = EnsureHttpxClose()
with inner_ctx:
client2 = Client()
assert client2 in inner_ctx.clients
# Since both contexts are using the same monkey-patched __init__,
# both contexts will track all clients created while they are active
assert client2 in outer_ctx.clients
# After inner context, client2 should be closed
assert client2.is_closed
# client1 should still be open since outer context is still active
assert not client1.is_closed
# After outer context, both clients should be closed
assert client1.is_closed
assert client2.is_closed
def test_ensure_httpx_close_exception():
"""Test EnsureHttpxClose when an exception occurs."""
client = None
ctx = EnsureHttpxClose()
try:
with ctx:
client = Client()
raise ValueError('Test exception')
except ValueError:
pass
# Client should be closed even if an exception occurred
assert client is not None
assert client.is_closed
def test_ensure_httpx_close_restore_init():
"""Test that the original __init__ is restored after context exit."""
original_init = Client.__init__
ctx = EnsureHttpxClose()
with ctx:
assert Client.__init__ != original_init
# Original __init__ should be restored
assert Client.__init__ == original_init

View File

@@ -1,6 +1,4 @@
import copy
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@@ -491,27 +489,3 @@ def test_llm_token_usage(mock_litellm_completion, default_config):
assert usage_entry_2['cache_read_tokens'] == 1
assert usage_entry_2['cache_write_tokens'] == 3
assert usage_entry_2['response_id'] == 'test-response-usage-2'
@patch('openhands.llm.llm.litellm_completion')
def test_completion_with_log_completions(mock_litellm_completion, default_config):
with tempfile.TemporaryDirectory() as temp_dir:
default_config.log_completions = True
default_config.log_completions_folder = temp_dir
mock_response = {
'choices': [{'message': {'content': 'This is a mocked response.'}}]
}
mock_litellm_completion.return_value = mock_response
test_llm = LLM(config=default_config)
response = test_llm.completion(
messages=[{'role': 'user', 'content': 'Hello!'}],
stream=False,
drop_params=True,
)
assert (
response['choices'][0]['message']['content'] == 'This is a mocked response.'
)
files = list(Path(temp_dir).iterdir())
# Expect a log to be generated
assert len(files) == 1