Compare commits

..

1 Commits

Author SHA1 Message Date
openhands
4068f56481 feat(cli): add working directory configuration support
- Add working directory configuration to oh_cli_settings.json
- Implement working directory prompt at conversation start
- Add working directory settings to TUI settings screen
- Update setup_conversation to use configured directory
- Add comprehensive test coverage for working directory functionality
- Follow TUI patterns and user action confirmation patterns

Fixes #11345

Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-21 16:42:08 +00:00
1056 changed files with 33028 additions and 55757 deletions

View File

@@ -1 +0,0 @@
This way of running OpenHands is not officially supported. It is maintained by the community.

View File

@@ -7,8 +7,5 @@ git config --global --add safe.directory "$(realpath .)"
# Install `nc`
sudo apt update && sudo apt install netcat -y
# Install `uv` and `uvx`
wget -qO- https://astral.sh/uv/install.sh | sh
# Do common setup tasks
source .openhands/setup.sh

12
.github/CODEOWNERS vendored
View File

@@ -1,8 +1,12 @@
# CODEOWNERS file for OpenHands repository
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
/frontend/ @amanape @hieptl
/openhands-ui/ @amanape @hieptl
/openhands/ @tofarr @malhotra5 @hieptl
/enterprise/ @chuckbutkus @tofarr @malhotra5
# Frontend code owners
/frontend/ @amanape
/openhands-ui/ @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig
# Documentation code owners
/docs/ @mamoodi

View File

@@ -13,7 +13,6 @@
- [ ] Other (dependency update, docs, typo fixes, etc.)
## Checklist
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
- [ ] I have read and reviewed the code and I understand what the code is doing.
- [ ] I have tested the code to the best of my ability and ensured it works as expected.

73
.github/scripts/check_version_consistency.py vendored Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import os
import re
import sys
def find_version_references(directory: str) -> tuple[set[str], set[str]]:
openhands_versions = set()
runtime_versions = set()
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
for root, _, files in os.walk(directory):
# Skip .git directory and docs/build directory
if '.git' in root or 'docs/build' in root:
continue
for file in files:
if file.endswith(
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find all openhands version references
matches = version_pattern_openhands.findall(content)
if matches:
print(f'Found openhands version {matches} in {file_path}')
openhands_versions.update(matches)
# Find all runtime version references
matches = version_pattern_runtime.findall(content)
if matches:
print(f'Found runtime version {matches} in {file_path}')
runtime_versions.update(matches)
except Exception as e:
print(f'Error reading {file_path}: {e}', file=sys.stderr)
return openhands_versions, runtime_versions
def main():
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
print(f'Checking version consistency in {repo_root}')
openhands_versions, runtime_versions = find_version_references(repo_root)
print(f'Found openhands versions: {sorted(openhands_versions)}')
print(f'Found runtime versions: {sorted(runtime_versions)}')
exit_code = 0
if len(openhands_versions) > 1:
print('Error: Multiple openhands versions found:', file=sys.stderr)
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
exit_code = 1
elif len(openhands_versions) == 0:
print('Warning: No openhands version references found', file=sys.stderr)
if len(runtime_versions) > 1:
print('Error: Multiple runtime versions found:', file=sys.stderr)
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
exit_code = 1
elif len(runtime_versions) == 0:
print('Warning: No runtime version references found', file=sys.stderr)
sys.exit(exit_code)
if __name__ == '__main__':
main()

View File

@@ -13,9 +13,12 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:${SHORT_SHA}-nikolaik \
--name openhands-app-${SHORT_SHA} \
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"
docker.all-hands.dev/all-hands-ai/openhands:${SHORT_SHA}"
# Define the uvx command
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands"
# Get the current PR body
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq .body)
@@ -34,6 +37,11 @@ GUI with Docker:
\`\`\`
${DOCKER_RUN_COMMAND}
\`\`\`
CLI with uvx:
\`\`\`
${UVX_RUN_COMMAND}
\`\`\`
EOF
)
else
@@ -49,6 +57,11 @@ GUI with Docker:
\`\`\`
${DOCKER_RUN_COMMAND}
\`\`\`
CLI with uvx:
\`\`\`
${UVX_RUN_COMMAND}
\`\`\`
EOF
)
fi

View File

@@ -1,65 +0,0 @@
name: Check Package Versions
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check-package-versions:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Check for any 'rev' fields in pyproject.toml
run: |
python - <<'PY'
import sys, tomllib, pathlib
path = pathlib.Path("pyproject.toml")
if not path.exists():
print("❌ ERROR: pyproject.toml not found")
sys.exit(1)
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except Exception as e:
print(f"❌ ERROR: Failed to parse pyproject.toml: {e}")
sys.exit(1)
poetry = data.get("tool", {}).get("poetry", {})
sections = {
"dependencies": poetry.get("dependencies", {}),
}
errors = []
print("🔍 Checking for any dependencies with 'rev' fields...\n")
for section_name, deps in sections.items():
if not isinstance(deps, dict):
continue
for pkg_name, cfg in deps.items():
if isinstance(cfg, dict) and "rev" in cfg:
msg = f" ✖ {pkg_name} in [{section_name}] uses rev='{cfg['rev']}' (NOT ALLOWED)"
print(msg)
errors.append(msg)
else:
print(f" • {pkg_name}: OK")
if errors:
print("\n❌ FAILED: Found dependencies using 'rev' fields:\n" + "\n".join(errors))
print("\nPlease use versioned releases instead, e.g.:")
print(' my-package = "1.0.0"')
sys.exit(1)
print("\n✅ SUCCESS: No 'rev' fields found. All dependencies are using proper versioned releases.")
PY

69
.github/workflows/clean-up.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
# Workflow that cleans up outdated and old workflows to prevent out of disk issues
name: Delete old workflow runs
# This workflow is currently only triggered manually
on:
workflow_dispatch:
inputs:
days:
description: 'Days-worth of runs to keep for each workflow'
required: true
default: '30'
minimum_runs:
description: 'Minimum runs to keep for each workflow'
required: true
default: '10'
delete_workflow_pattern:
description: 'Name or filename of the workflow (if not set, all workflows are targeted)'
required: false
delete_workflow_by_state_pattern:
description: 'Filter workflows by state: active, deleted, disabled_fork, disabled_inactivity, disabled_manually'
required: true
default: "ALL"
type: choice
options:
- "ALL"
- active
- deleted
- disabled_inactivity
- disabled_manually
delete_run_by_conclusion_pattern:
description: 'Remove runs based on conclusion: action_required, cancelled, failure, skipped, success'
required: true
default: 'ALL'
type: choice
options:
- 'ALL'
- 'Unsuccessful: action_required,cancelled,failure,skipped'
- action_required
- cancelled
- failure
- skipped
- success
dry_run:
description: 'Logs simulated changes, no deletions are performed'
required: false
jobs:
del_runs:
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
actions: write
contents: read
steps:
- name: Delete workflow runs
uses: Mattraks/delete-workflow-runs@v2
with:
token: ${{ github.token }}
repository: ${{ github.repository }}
retain_days: ${{ github.event.inputs.days }}
keep_minimum_runs: ${{ github.event.inputs.minimum_runs }}
delete_workflow_pattern: ${{ github.event.inputs.delete_workflow_pattern }}
delete_workflow_by_state_pattern: ${{ github.event.inputs.delete_workflow_by_state_pattern }}
delete_run_by_conclusion_pattern: >-
${{
startsWith(github.event.inputs.delete_run_by_conclusion_pattern, 'Unsuccessful:')
&& 'action_required,cancelled,failure,skipped'
|| github.event.inputs.delete_run_by_conclusion_pattern
}}
dry_run: ${{ github.event.inputs.dry_run }}

View File

@@ -0,0 +1,114 @@
# Workflow that builds and tests the CLI binary executable
name: CLI - Build binary and optionally release
# Run on pushes to main branch and CLI tags, and on pull requests when CLI files change
on:
push:
branches:
- main
tags:
- "*-cli"
pull_request:
paths:
- "openhands-cli/**"
permissions:
contents: write # needed to create releases or upload assets
# Cancel previous runs if a new commit is pushed
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
build-binary:
name: Build binary executable
strategy:
matrix:
include:
# Build on Ubuntu 22.04 for maximum GLIBC compatibility (GLIBC 2.31)
- os: ubuntu-22.04
platform: linux
artifact_name: openhands-cli-linux
# Build on macOS for macOS users
- os: macos-15
platform: macos
artifact_name: openhands-cli-macos
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Install dependencies
working-directory: openhands-cli
run: |
uv sync
- name: Build binary executable
working-directory: openhands-cli
run: |
./build.sh --install-pyinstaller | tee output.log
echo "Full output:"
cat output.log
if grep -q "❌" output.log; then
echo "❌ Found failure marker in output"
exit 1
fi
echo "✅ Build & test finished without ❌ markers"
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: openhands-cli/dist/openhands*
retention-days: 30
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: build-binary
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release assets
run: |
mkdir -p release-assets
# Copy binaries with appropriate names for release
if [ -f artifacts/openhands-cli-linux/openhands ]; then
cp artifacts/openhands-cli-linux/openhands release-assets/openhands-linux
fi
if [ -f artifacts/openhands-cli-macos/openhands ]; then
cp artifacts/openhands-cli-macos/openhands release-assets/openhands-macos
fi
ls -la release-assets/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-assets/*
draft: true
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

23
.github/workflows/dispatch-to-docs.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Dispatch to docs repo
on:
push:
branches: [main]
paths:
- 'docs/**'
workflow_dispatch:
jobs:
dispatch:
runs-on: ubuntu-latest
strategy:
matrix:
repo: ["All-Hands-AI/docs"]
steps:
- name: Push to docs repo
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
repository: ${{ matrix.repo }}
event-type: update
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "module": "openhands", "branch": "main"}'

View File

@@ -27,7 +27,7 @@ jobs:
poetry-version: 2.1.3
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'poetry'
@@ -38,7 +38,7 @@ jobs:
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
@@ -192,7 +192,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/test-results/
@@ -200,7 +200,7 @@ jobs:
- name: Upload OpenHands logs
if: always()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: openhands-logs
path: |

View File

@@ -43,7 +43,7 @@ jobs:
⚠️ This PR contains **migrations**
- name: Comment warning on PR
uses: peter-evans/create-or-update-comment@v5
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}

View File

@@ -26,4 +26,4 @@ jobs:
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
https://api.github.com/repos/All-Hands-AI/deploy/actions/workflows/deploy.yaml/dispatches

View File

@@ -1,47 +0,0 @@
# Workflow that runs frontend e2e tests with Playwright
name: Run Frontend E2E Tests
on:
push:
branches:
- main
pull_request:
paths:
- "frontend/**"
- ".github/workflows/fe-e2e-tests.yml"
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
fe-e2e-test:
name: FE E2E Tests
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [22]
fail-fast: true
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Install Playwright browsers
working-directory: ./frontend
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test --project=chromium
- name: Upload Playwright report
uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 30

View File

@@ -37,6 +37,7 @@ jobs:
shell: bash
id: define-base-images
run: |
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
@@ -45,6 +46,7 @@ jobs:
else
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ghcr.io/all-hands-ai/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
fi
@@ -64,7 +66,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
uses: docker/setup-qemu-action@v3.6.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -86,7 +88,7 @@ jobs:
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Runtime Image
name: Build Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
@@ -102,7 +104,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.7.0
uses: docker/setup-qemu-action@v3.6.0
with:
image: tonistiigi/binfmt:latest
- name: Login to GHCR
@@ -161,7 +163,7 @@ jobs:
context: containers/runtime
- name: Upload runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
@@ -198,7 +200,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/openhands/enterprise-server
images: ghcr.io/all-hands-ai/enterprise-server
tags: |
type=ref,event=branch
type=ref,event=pr
@@ -250,13 +252,13 @@ jobs:
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
https://api.github.com/repos/All-Hands-AI/deploy/actions/workflows/deploy.yaml/dispatches
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-ubuntu-2204
strategy:
fail-fast: false
matrix:
@@ -268,7 +270,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v6
uses: actions/download-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
@@ -298,7 +300,7 @@ jobs:
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flakey tests
# Install to be able to retry on failures for flaky tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
@@ -311,14 +313,14 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: blacksmith-8vcpu-ubuntu-2204
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
@@ -330,7 +332,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Download runtime source for fork
if: github.event.pull_request.head.repo.fork
uses: actions/download-artifact@v6
uses: actions/download-artifact@v4
with:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
@@ -370,7 +372,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"

199
.github/workflows/integration-runner.yml vendored Normal file
View File

@@ -0,0 +1,199 @@
name: Run Integration Tests
on:
pull_request:
types: [labeled]
workflow_dispatch:
inputs:
reason:
description: 'Reason for manual trigger'
required: true
default: ''
schedule:
- cron: '30 22 * * *' # Runs at 10:30pm UTC every day
env:
N_PROCESSES: 10 # Global configuration for number of parallel processes for evaluation
jobs:
run-integration-tests:
if: github.event.label.name == 'integration-test' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: "read"
id-token: "write"
pull-requests: "write"
issues: "write"
strategy:
matrix:
python-version: ["3.12"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Setup Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: '22.x'
- name: Comment on PR if 'integration-test' label is present
if: github.event_name == 'pull_request' && github.event.label.name == 'integration-test'
uses: KeisukeYamashita/create-comment@v1
with:
unique: false
comment: |
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime,evaluation
- name: Configure config.toml for testing with Haiku
env:
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 10
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Build environment
run: make build
- name: Run integration test evaluation for Haiku
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'haiku_run'
# get integration tests report
REPORT_FILE_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/*haiku*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_HAIKU"
echo "INTEGRATION_TEST_REPORT_HAIKU<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_HAIKU >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Wait a little bit
run: sleep 10
- name: Configure config.toml for testing with DeepSeek
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 10
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DeepSeek
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'deepseek_run'
# get integration tests report
REPORT_FILE_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek*_maxiter_10_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE: $REPORT_FILE_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 15
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
# Find and export the visual browsing agent test results
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create archive of evaluation outputs
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
id: upload_results_artifact
with:
name: integration-test-outputs-${{ github.run_id }}-${{ github.run_attempt }}
path: integration_tests_*.tar.gz
- name: Get artifact URLs
run: |
echo "ARTIFACT_URL=${{ steps.upload_results_artifact.outputs.artifact-url }}" >> $GITHUB_ENV
- name: Set timestamp and trigger reason
run: |
echo "TIMESTAMP=$(date +'%Y-%m-%d-%H-%M')" >> $GITHUB_ENV
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "TRIGGER_REASON=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "TRIGGER_REASON=manual-${{ github.event.inputs.reason }}" >> $GITHUB_ENV
else
echo "TRIGGER_REASON=nightly-scheduled" >> $GITHUB_ENV
fi
- name: Comment with results and artifact link
id: create_comment
uses: KeisukeYamashita/create-comment@v1
with:
# if triggered by PR, use PR number, otherwise use 9745 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 9745 }}
unique: false
comment: |
Trigger by: ${{ github.event_name == 'pull_request' && format('Pull Request (integration-test label on PR #{0})', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && format('Manual Trigger: {0}', github.event.inputs.reason)) || 'Nightly Scheduled Run' }}
Commit: ${{ github.sha }}
**Integration Tests Report (Haiku)**
Haiku LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_HAIKU }}
---
**Integration Tests Report (DeepSeek)**
DeepSeek LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_DEEPSEEK }}
---
**Integration Tests Report VisualBrowsing (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
---
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})

View File

@@ -72,3 +72,34 @@ jobs:
- name: Run pre-commit hooks
working-directory: ./enterprise
run: pre-commit run --all-files --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
lint-cli-python:
name: Lint CLI python
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up python
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: "pip"
- name: Install pre-commit
run: pip install pre-commit==4.2.0
- name: Run pre-commit hooks
working-directory: ./openhands-cli
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Set up python
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Run version consistency check
run: .github/scripts/check_version_consistency.py

70
.github/workflows/mdx-lint.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
# Workflow that checks MDX format in docs/ folder
name: MDX Lint
# Run on pushes to main and on pull requests that modify docs/ files
on:
push:
branches:
- main
paths:
- 'docs/**/*.mdx'
pull_request:
paths:
- 'docs/**/*.mdx'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
mdx-lint:
name: Lint MDX files
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
node-version: 22
- name: Install MDX dependencies
run: |
npm install @mdx-js/mdx@3 glob@10
- name: Validate MDX files
run: |
node -e "
const {compile} = require('@mdx-js/mdx');
const fs = require('fs');
const path = require('path');
const glob = require('glob');
async function validateMDXFiles() {
const files = glob.sync('docs/**/*.mdx');
console.log('Found', files.length, 'MDX files to validate');
let hasErrors = false;
for (const file of files) {
try {
const content = fs.readFileSync(file, 'utf8');
await compile(content);
console.log('✅ MDX parsing successful for', file);
} catch (err) {
console.error('❌ MDX parsing failed for', file, ':', err.message);
hasErrors = true;
}
}
if (hasErrors) {
console.error('\\n❌ Some MDX files have parsing errors. Please fix them before merging.');
process.exit(1);
} else {
console.log('\\n✅ All MDX files are valid!');
}
}
validateMDXFiles();
"

View File

@@ -89,7 +89,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Upgrade pip
@@ -118,7 +118,7 @@ jobs:
contains(github.event.review.body, '@openhands-agent-exp')
)
)
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
@@ -201,7 +201,7 @@ jobs:
issue_number: ${{ env.ISSUE_NUMBER }},
owner: context.repo.owner,
repo: context.repo.repo,
body: `[OpenHands](https://github.com/OpenHands/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
body: `[OpenHands](https://github.com/All-Hands-AI/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
});
- name: Install OpenHands
@@ -233,7 +233,7 @@ jobs:
if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
console.log("Installing experimental OpenHands...");
await exec.exec("pip install git+https://github.com/openhands/openhands.git");
await exec.exec("pip install git+https://github.com/all-hands-ai/openhands.git");
} else {
console.log("Installing from requirements.txt...");
@@ -269,7 +269,7 @@ jobs:
fi
- name: Upload output.jsonl as artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
if: always() # Upload even if the previous steps fail
with:
name: resolver-output

View File

@@ -48,10 +48,7 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Python dependencies using Poetry
run: |
poetry install --with dev,test,runtime
poetry run pip install pytest-xdist
poetry run pip install pytest-rerunfailures
run: poetry install --with dev,test,runtime
- name: Build Environment
run: make build
- name: Run Unit Tests
@@ -59,18 +56,48 @@ jobs:
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: coverage-openhands
path: |
.coverage.${{ matrix.python_version }}
.coverage.runtime.${{ matrix.python_version }}
include-hidden-files: true
# Run specific Windows python tests
test-on-windows:
name: Python Tests on Windows
runs-on: windows-latest
strategy:
matrix:
python-version: ["3.12"]
steps:
- uses: actions/checkout@v4
- name: Install pipx
run: pip install pipx
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
DEBUG: "1"
- name: Run Windows runtime tests with LocalRuntime
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
TEST_RUNTIME: local
DEBUG: "1"
test-enterprise:
name: Enterprise Python Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -95,17 +122,63 @@ jobs:
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: coverage-enterprise
path: ".coverage.enterprise.${{ matrix.python_version }}"
include-hidden-files: true
# Run CLI unit tests
test-cli-python:
name: CLI Unit Tests
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
matrix:
python-version: ["3.12"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Install dependencies
working-directory: ./openhands-cli
run: |
uv sync --group dev
- name: Run CLI unit tests
working-directory: ./openhands-cli
env:
# write coverage to repo root so the merge step finds it
COVERAGE_FILE: "${{ github.workspace }}/.coverage.openhands-cli.${{ matrix.python-version }}"
run: |
uv run pytest --forked -n auto -s \
-p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark \
tests --cov=openhands_cli --cov-branch
- name: Store coverage file
uses: actions/upload-artifact@v4
with:
name: coverage-openhands-cli
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
include-hidden-files: true
coverage-comment:
name: Coverage Comment
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: [test-on-linux, test-enterprise]
needs: [test-on-linux, test-enterprise, test-cli-python]
permissions:
pull-requests: write
@@ -113,12 +186,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v5
id: download
with:
pattern: coverage-*
merge-multiple: true
- name: Create symlink for CLI source files
run: ln -sf openhands-cli/openhands_cli openhands_cli
- name: Coverage comment
id: coverage_comment
uses: py-cov-action/python-coverage-comment-action@v3

View File

@@ -10,6 +10,7 @@ on:
type: choice
options:
- app server
- cli
default: app server
push:
tags:
@@ -38,3 +39,36 @@ jobs:
run: ./build.sh
- name: publish
run: poetry publish -u __token__ -p ${{ secrets.PYPI_TOKEN }}
release-cli:
name: Publish CLI to PyPI
runs-on: ubuntu-latest
# Run when manually dispatched for "cli" OR for tag pushes that contain '-cli'
if: |
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'cli')
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-cli'))
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
- name: Build CLI package
working-directory: openhands-cli
run: |
# Clean dist directory to avoid conflicts with binary builds
rm -rf dist/
uv build
- name: Publish CLI to PyPI
working-directory: openhands-cli
run: |
uv publish --token ${{ secrets.PYPI_TOKEN_OPENHANDS }}

135
.github/workflows/run-eval.yml vendored Normal file
View File

@@ -0,0 +1,135 @@
# Run evaluation on a PR, after releases, or manually
name: Run Eval
# Runs when a PR is labeled with one of the "run-eval-" labels, after releases, or manually triggered
on:
pull_request:
types: [labeled]
release:
types: [published]
workflow_dispatch:
inputs:
branch:
description: 'Branch to evaluate'
required: true
default: 'main'
eval_instances:
description: 'Number of evaluation instances'
required: true
default: '50'
type: choice
options:
- '1'
- '2'
- '50'
- '100'
reason:
description: 'Reason for manual trigger'
required: false
default: ''
env:
# Environment variable for the master GitHub issue number where all evaluation results will be commented
# This should be set to the issue number where you want all evaluation results to be posted
MASTER_EVAL_ISSUE_NUMBER: ${{ vars.MASTER_EVAL_ISSUE_NUMBER || '0' }}
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ (github.event_name == 'pull_request' && (github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100')) || github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout branch
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.head_ref || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch) || github.ref }}
- name: Set evaluation parameters
id: eval_params
run: |
REPO_URL="https://github.com/${{ github.repository }}"
echo "Repository URL: $REPO_URL"
# Determine branch based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
EVAL_BRANCH="${{ github.head_ref }}"
echo "PR Branch: $EVAL_BRANCH"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_BRANCH="${{ github.event.inputs.branch }}"
echo "Manual Branch: $EVAL_BRANCH"
else
# For release events, use the tag name or main branch
EVAL_BRANCH="${{ github.ref_name }}"
echo "Release Branch/Tag: $EVAL_BRANCH"
fi
# Determine evaluation instances based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_INSTANCES="${{ github.event.inputs.eval_instances }}"
else
# For release events, default to 50 instances
EVAL_INSTANCES="50"
fi
echo "Evaluation instances: $EVAL_INSTANCES"
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
echo "eval_branch=$EVAL_BRANCH" >> $GITHUB_OUTPUT
echo "eval_instances=$EVAL_INSTANCES" >> $GITHUB_OUTPUT
- name: Trigger remote job
run: |
# Determine PR number for the remote evaluation system
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
# For non-PR triggers, use the master issue number as PR number
PR_NUMBER="${{ env.MASTER_EVAL_ISSUE_NUMBER }}"
fi
curl -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${{ steps.eval_params.outputs.repo_url }}\", \"github-branch\": \"${{ steps.eval_params.outputs.eval_branch }}\", \"pr-number\": \"${PR_NUMBER}\", \"eval-instances\": \"${{ steps.eval_params.outputs.eval_instances }}\"}}" \
https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
# Send Slack message
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
elif [[ "${{ github.event_name }}" == "release" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
slack_text="Release $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
else
TRIGGER_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
slack_text="Manual trigger (${{ github.event.inputs.reason || 'No reason provided' }}) has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances for branch ${{ steps.eval_params.outputs.eval_branch }}..."
fi
curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
- name: Comment on issue/PR
uses: KeisukeYamashita/create-comment@v1
with:
# For PR triggers, comment on the PR. For other triggers, comment on the master issue
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || env.MASTER_EVAL_ISSUE_NUMBER }}
unique: false
comment: |
**Evaluation Triggered**
**Trigger:** ${{ github.event_name == 'pull_request' && format('Pull Request #{0}', github.event.pull_request.number) || (github.event_name == 'release' && 'Release') || format('Manual Trigger: {0}', github.event.inputs.reason || 'No reason provided') }}
**Branch:** ${{ steps.eval_params.outputs.eval_branch }}
**Instances:** ${{ steps.eval_params.outputs.eval_instances }}
**Commit:** ${{ github.sha }}
Running evaluation on the specified branch. Once eval is done, the results will be posted here.

View File

@@ -37,7 +37,7 @@ jobs:
node-version: '22'
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.12'
@@ -70,7 +70,7 @@ jobs:
fi
- name: Upload VSCode extension artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: vscode-extension
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
@@ -142,7 +142,7 @@ jobs:
steps:
- name: Download .vsix artifact
uses: actions/download-artifact@v6
uses: actions/download-artifact@v4
with:
name: vscode-extension
path: ./

3
.gitignore vendored
View File

@@ -185,9 +185,6 @@ cython_debug/
.repomix
repomix-output.txt
# Emacs backup
*~
# evaluation
evaluation/evaluation_outputs
evaluation/outputs

View File

@@ -63,7 +63,7 @@ Frontend:
- We use TanStack Query (fka React Query) for data fetching and cache management
- Data Access Layer: API client methods are located in `frontend/src/api` and should never be called directly from UI components - they must always be wrapped with TanStack Query
- Custom hooks are located in `frontend/src/hooks/query/` and `frontend/src/hooks/mutation/`
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationMicroagents`)
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
@@ -83,116 +83,6 @@ VSCode Extension:
- Use `vscode.window.createOutputChannel()` for debug logging instead of `showErrorMessage()` popups
- Pre-commit process runs both frontend and backend checks when committing extension changes
## Enterprise Directory
The `enterprise/` directory contains additional functionality that extends the open-source OpenHands codebase. This includes:
- Authentication and user management (Keycloak integration)
- Database migrations (Alembic)
- Integration services (GitHub, GitLab, Jira, Linear, Slack)
- Billing and subscription management (Stripe)
- Telemetry and analytics (PostHog, custom metrics framework)
### Enterprise Development Setup
**Prerequisites:**
- Python 3.12
- Poetry (for dependency management)
- Node.js 22.x (for frontend)
- Docker (optional)
**Setup Steps:**
1. First, build the main OpenHands project: `make build`
2. Then install enterprise dependencies: `cd enterprise && poetry install --with dev,test` (This can take a very long time. Be patient.)
3. Set up enterprise pre-commit hooks: `poetry run pre-commit install --config ./dev_config/python/.pre-commit-config.yaml`
**Running Enterprise Tests:**
```bash
# Enterprise unit tests (full suite)
PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
# Test specific modules (faster for development)
cd enterprise
PYTHONPATH=".:$PYTHONPATH" poetry run pytest tests/unit/telemetry/ --confcutdir=tests/unit/telemetry
# Enterprise linting (IMPORTANT: use --show-diff-on-failure to match GitHub CI)
poetry run pre-commit run --all-files --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
```
**Running Enterprise Server:**
```bash
cd enterprise
make start-backend # Development mode with hot reload
# or
make run # Full application (backend + frontend)
```
**Key Configuration Files:**
- `enterprise/pyproject.toml` - Enterprise-specific dependencies
- `enterprise/Makefile` - Enterprise build and run commands
- `enterprise/dev_config/python/` - Linting and type checking configuration
- `enterprise/migrations/` - Database migration files
**Database Migrations:**
Enterprise uses Alembic for database migrations. When making schema changes:
1. Create migration files in `enterprise/migrations/versions/`
2. Test migrations thoroughly
3. The CI will check for migration conflicts on PRs
**Integration Development:**
The enterprise codebase includes integrations for:
- **GitHub** - PR management, webhooks, app installations
- **GitLab** - Similar to GitHub but for GitLab instances
- **Jira** - Issue tracking and project management
- **Linear** - Modern issue tracking
- **Slack** - Team communication and notifications
Each integration follows a consistent pattern with service classes, storage models, and API endpoints.
**Important Notes:**
- Enterprise code is licensed under Polyform Free Trial License (30-day limit)
- The enterprise server extends the OSS server through dynamic imports
- Database changes require careful migration planning in `enterprise/migrations/`
- Always test changes in both OSS and enterprise contexts
- Use the enterprise-specific Makefile commands for development
**Enterprise Testing Best Practices:**
**Database Testing:**
- Use SQLite in-memory databases (`sqlite:///:memory:`) for unit tests instead of real PostgreSQL
- Create module-specific `conftest.py` files with database fixtures
- Mock external database connections in unit tests to avoid dependency on running services
- Use real database connections only for integration tests
**Import Patterns:**
- Use relative imports without `enterprise.` prefix in enterprise code
- Example: `from storage.database import session_maker` not `from enterprise.storage.database import session_maker`
- This ensures code works in both OSS and enterprise contexts
**Test Structure:**
- Place tests in `enterprise/tests/unit/` following the same structure as the source code
- Use `--confcutdir=tests/unit/[module]` when testing specific modules
- Create comprehensive fixtures for complex objects (databases, external services)
- Write platform-agnostic tests (avoid hardcoded OS-specific assertions)
**Mocking Strategy:**
- Use `AsyncMock` for async operations and `MagicMock` for complex objects
- Mock all external dependencies (databases, APIs, file systems) in unit tests
- Use `patch` with correct import paths (e.g., `telemetry.registry.logger` not `enterprise.telemetry.registry.logger`)
- Test both success and failure scenarios with proper error handling
**Coverage Goals:**
- Aim for 90%+ test coverage on new enterprise modules
- Focus on critical business logic and error handling paths
- Use `--cov-report=term-missing` to identify uncovered lines
**Troubleshooting:**
- If tests fail, ensure all dependencies are installed: `poetry install --with dev,test`
- For database issues, check migration status and run migrations if needed
- For frontend issues, ensure the main OpenHands frontend is built: `make build`
- Check logs in the `logs/` directory for runtime issues
- If tests fail with import errors, verify `PYTHONPATH=".:$PYTHONPATH"` is set
- **If GitHub CI fails but local linting passes**: Always use `--show-diff-on-failure` flag to match CI behavior exactly
## Template for Github Pull Request
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.

16
.vscode/settings.json vendored
View File

@@ -3,20 +3,4 @@
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.terminal.activateEnvironment": true,
"python.analysis.autoImportCompletions": true,
"python.analysis.autoSearchPaths": true,
"python.analysis.extraPaths": [
"./.venv/lib/python3.12/site-packages"
],
"python.analysis.packageIndexDepths": [
{
"name": "openhands",
"depth": 10,
"includeAllSymbols": true
}
],
"python.analysis.stubPath": "./.venv/lib/python3.12/site-packages",
}

1
CNAME
View File

@@ -1 +0,0 @@
docs.all-hands.dev

View File

@@ -124,7 +124,7 @@ These Slack etiquette guidelines are designed to foster an inclusive, respectful
- Post questions or discussions in the most relevant channel (e.g., for [slack - #general](https://openhands-ai.slack.com/archives/C06P5NCGSFP) for general topics, [slack - #questions](https://openhands-ai.slack.com/archives/C06U8UTKSAD) for queries/questions.
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
- Always adhere to [our standards](https://github.com/OpenHands/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
- Always adhere to [our standards](https://github.com/All-Hands-AI/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
- If you choose to mute a channel, consider setting up alerts for topics that still interest you to stay engaged. For Slack, Go to Settings → Notifications → My Keywords to add specific keywords that will notify you when mentioned. For example, if you're here for discussions about LLMs, mute the channel if its too busy, but set notifications to alert you only when “LLMs” appears in messages.
## Attribution

View File

@@ -1,45 +1,43 @@
# The OpenHands Community
# 🙌 The OpenHands Community
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered world.
The OpenHands community is built around the belief that (1) AI and AI agents are going to fundamentally change the way
we build software, and (2) if this is true, we should do everything we can to make sure that the benefits provided by
such powerful technology are accessible to everyone.
## Mission
If this resonates with you, we'd love to have you join us in our quest!
Its very clear that AI is changing software development. We want the developer community to drive that change organically, through open source.
## 🤝 How to Join
So were not just building friendly interfaces for AI-driven development. Were publishing _building blocks_ that empower developers to create new experiences, tailored to your own habits, needs, and imagination.
Check out our [How to Join the Community section.](https://github.com/All-Hands-AI/OpenHands?tab=readme-ov-file#-how-to-join-the-community)
## Ethos
## 💪 Becoming a Contributor
We have two core values: **high openness** and **high agency**. While we dont expect everyone in the community to embody these values, we want to establish them as norms.
We welcome contributions from everyone! Whether you're a developer, a researcher, or simply enthusiastic about advancing
the field of software engineering with AI, there are many ways to get involved:
### High Openness
- **Code Contributions:** Help us develop new core functionality, improve our agents, improve the frontend and other
interfaces, or anything else that would help make OpenHands better.
- **Research and Evaluation:** Contribute to our understanding of LLMs in software engineering, participate in
evaluating the models, or suggest improvements.
- **Feedback and Testing:** Use the OpenHands toolset, report bugs, suggest features, or provide feedback on usability.
We welcome anyone and everyone into our community by default. You dont have to be a software developer to help us build. You dont have to be pro-AI to help us learn.
For details, please check [CONTRIBUTING.md](./CONTRIBUTING.md).
Our plans, our work, our successes, and our failures are all public record. We want the world to see not just the fruits of our work, but the whole process of growing it.
## Code of Conduct
We welcome thoughtful criticism, whether its a comment on a PR or feedback on the community as a whole.
We have a [Code of Conduct](./CODE_OF_CONDUCT.md) that we expect all contributors to adhere to.
Long story short, we are aiming for an open, welcoming, diverse, inclusive, and healthy community.
All contributors are expected to contribute to building this sort of community.
### High Agency
## 🛠️ Becoming a Maintainer
Everyone should feel empowered to contribute to OpenHands. Whether its by making a PR, hosting an event, sharing feedback, or just asking a question, dont hold back!
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining
the maintainer team. The process for this is as follows:
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly and love building new things.
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any
maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
Coding, development practices, and communities are changing rapidly. We wont hesitate to change direction and make big bets.
## Relationship to All Hands
OpenHands is supported by the for-profit organization [All Hands AI, Inc](https://www.all-hands.dev/).
All Hands was founded by three of the first major contributors to OpenHands:
- Xingyao Wang, a UIUC PhD candidate who got OpenHands to the top of the SWE-bench leaderboards
- Graham Neubig, a CMU Professor who rallied the academic community around OpenHands
- Robert Brennan, a software engineer who architected the user-facing features of OpenHands
All Hands is an important part of the OpenHands ecosystem. Weve raised over $20M--mainly to hire developers and researchers who can work on OpenHands full-time, and to provide them with expensive infrastructure. ([Join us!](https://allhandsai.applytojob.com/apply/))
But we see OpenHands as much larger, and ultimately more important, than All Hands. When our financial responsibility to investors is at odds with our social responsibility to the community—as it inevitably will be, from time to time—we promise to navigate that conflict thoughtfully and transparently.
At some point, we may transfer custody of OpenHands to an open source foundation. But for now, the [Benevolent Dictator approach](http://www.catb.org/~esr/writings/cathedral-bazaar/homesteading/ar01s16.html) helps us move forward with speed and intention. If we ever forget the “benevolent” part, please: fork us.
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking
at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).

View File

@@ -13,15 +13,15 @@ To understand the codebase, please refer to the README in each module:
## Setting up Your Development Environment
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
We have a separate doc [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md) that tells you how to set up a development workflow.
## How Can I Contribute?
There are many ways that you can contribute:
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
1. **Download and use** OpenHands, and send [issues](https://github.com/All-Hands-AI/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.all-hands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/All-Hands-AI/OpenHands/labels/good%20first%20issue) that may be ones to start on.
## What Can I Build?
Here are a few ways you can help improve the codebase.
@@ -35,7 +35,7 @@ of the application, please open an issue first, or better, join the #eng-ui-ux c
to gather consensus from our design team first.
#### Improving the agent
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub/codeact_agent).
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
@@ -54,11 +54,11 @@ The agent needs a place to run code and commands. When you run OpenHands on your
to do this by default. But there are other ways of creating a sandbox for the agent.
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
by implementing the [interface specified here](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/base.py).
#### Testing
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
At the moment, we have two kinds of tests: [`unit`](./tests/unit) and [`integration`](./evaluation/integration_tests). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
## Sending Pull Requests to OpenHands
@@ -84,7 +84,7 @@ For example, a PR title could be:
- `refactor: modify package path`
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
You may also check out previous PRs in the [PR list](https://github.com/All-Hands-AI/OpenHands/pulls).
### Pull Request description
- If your PR is small (such as a typo fix), you can go brief.
@@ -97,7 +97,7 @@ please include a short message that we can add to our changelog.
### Opening Issues
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/All-Hands-AI/OpenHands/issues). We will triage based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that the community has interest/effort for.
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.

View File

@@ -2,7 +2,7 @@
## Contributors
We would like to thank all the [contributors](https://github.com/OpenHands/OpenHands/graphs/contributors) who have helped make OpenHands possible. We greatly appreciate your dedication and hard work.
We would like to thank all the [contributors](https://github.com/All-Hands-AI/OpenHands/graphs/contributors) who have helped make OpenHands possible. We greatly appreciate your dedication and hard work.
## Open Source Projects
@@ -14,7 +14,7 @@ OpenHands includes and adapts the following open source projects. We are gratefu
#### [Aider](https://github.com/paul-gauthier/aider)
- License: Apache License 2.0
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
- License: Apache License 2.0

View File

@@ -2,7 +2,7 @@
This guide is for people working on OpenHands and editing the source code.
If you wish to contribute your changes, check out the
[CONTRIBUTING.md](https://github.com/OpenHands/OpenHands/blob/main/CONTRIBUTING.md)
[CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md)
on how to clone and setup the project initially before moving on. Otherwise,
you can clone the OpenHands project directly.
@@ -91,14 +91,14 @@ make run
#### Option B: Individual Server Startup
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
backend-related tasks or configurations.
```bash
make start-backend
```
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
components or interface enhancements.
```bash
make start-frontend
```
@@ -110,7 +110,6 @@ You can use OpenHands to develop and improve OpenHands itself! This is a powerfu
#### Quick Start
1. **Build and run OpenHands:**
```bash
export INSTALL_DOCKER=0
export RUNTIME=local
@@ -118,7 +117,6 @@ You can use OpenHands to develop and improve OpenHands itself! This is a powerfu
```
2. **Access the interface:**
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
@@ -161,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.1-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.59-nikolaik`
## Develop inside Docker container
@@ -195,12 +193,12 @@ Here's a guide to the important documentation files in the repository:
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
- [DOC_STYLE_GUIDE.md](https://github.com/All-Hands-AI/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/docs/DOC_STYLE_GUIDE.md](./docs/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [/evaluation/README.md](./evaluation/README.md): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/microagents/README.md](./microagents/README.md): Information about the microagents architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

202
README.md
View File

@@ -1,86 +1,184 @@
<a name="readme-top"></a>
<div align="center">
<img src="https://raw.githubusercontent.com/OpenHands/docs/main/openhands/static/img/logo.png" alt="Logo" width="200">
<h1 align="center" style="border-bottom: none">OpenHands: AI-Driven Development</h1>
<img src="https://raw.githubusercontent.com/All-Hands-AI/docs/main/openhands/static/img/logo.png" alt="Logo" width="200">
<h1 align="center">OpenHands: Code Less, Make More</h1>
</div>
<div align="center">
<a href="https://github.com/OpenHands/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/badge/LICENSE-MIT-20B2AA?style=for-the-badge" alt="MIT License"></a>
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-77.6-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/graphs/contributors"><img src="https://img.shields.io/github/contributors/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Contributors"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
<br/>
<a href="https://docs.openhands.dev/sdk"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
<a href="https://arxiv.org/abs/2511.03690"><img src="https://img.shields.io/badge/Paper-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Tech Report"></a>
<a href="https://all-hands.dev/joinslack"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
<br/>
<a href="https://docs.all-hands.dev/usage/getting-started"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
<a href="https://arxiv.org/abs/2407.16741"><img src="https://img.shields.io/badge/Paper%20on%20Arxiv-000?logoColor=FFE165&logo=arxiv&style=for-the-badge" alt="Paper on Arxiv"></a>
<a href="https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0#gid=0"><img src="https://img.shields.io/badge/Benchmark%20score-000?logoColor=FFE165&logo=huggingface&style=for-the-badge" alt="Evaluation Benchmark Score"></a>
<!-- Keep these links. Translations will automatically update with the README. -->
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=de">Deutsch</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=es">Español</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=fr">français</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ja">日本語</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ko">한국어</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=pt">Português</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=ru">Русский</a> |
<a href="https://www.readme-i18n.com/OpenHands/OpenHands?lang=zh">中文</a>
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=de">Deutsch</a> |
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=es">Español</a> |
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=fr">français</a> |
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=ja">日本語</a> |
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=ko">한국어</a> |
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=pt">Português</a> |
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=ru">Русский</a> |
<a href="https://www.readme-i18n.com/All-Hands-AI/OpenHands?lang=zh">中文</a>
<hr>
</div>
<hr>
Welcome to OpenHands (formerly OpenDevin), a platform for software development agents powered by AI.
🙌 Welcome to OpenHands, a [community](COMMUNITY.md) focused on AI-driven development. Wed love for you to [join us on Slack](https://dub.sh/openhands).
OpenHands agents can do anything a human developer can: modify code, run commands, browse the web,
call APIs, and yes—even copy code snippets from StackOverflow.
There are a few ways to work with OpenHands:
Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for OpenHands Cloud](https://app.all-hands.dev) to get started.
### OpenHands Software Agent SDK
The SDK is a composable Python library that contains all of our agentic tech. It's the engine that powers everything else below.
Define agents in code, then run them locally, or scale to 1000s of agents in the cloud.
> [!IMPORTANT]
> **Upcoming change**: We are renaming our GitHub Org from `All-Hands-AI` to `OpenHands` on October 20th, 2025.
> Check the [tracking issue](https://github.com/All-Hands-AI/OpenHands/issues/11376) for more information.
[Check out the docs](https://docs.openhands.dev/sdk) or [view the source](https://github.com/OpenHands/software-agent-sdk/)
### OpenHands CLI
The CLI is the easiest way to start using OpenHands. The experience will be familiar to anyone who has worked
with e.g. Claude Code or Codex. You can power it with Claude, GPT, or any other LLM.
> [!IMPORTANT]
> Using OpenHands for work? We'd love to chat! Fill out
> [this short form](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
> to join our Design Partner program, where you'll get early access to commercial features and the opportunity to provide input on our product roadmap.
[Check out the docs](https://docs.openhands.dev/openhands/usage/run-openhands/cli-mode) or [view the source](https://github.com/OpenHands/OpenHands-CLI)
## ☁️ OpenHands Cloud
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
which comes with $20 in free credits for new users.
### OpenHands Local GUI
Use the Local GUI for running agents on your laptop. It comes with a REST API and a single-page React application.
The experience will be familiar to anyone who has used Devin or Jules.
## 💻 Running OpenHands Locally
[Check out the docs](https://docs.openhands.dev/openhands/usage/run-openhands/local-setup) or view the source in this repo.
### Option 1: CLI Launcher (Recommended)
### OpenHands Cloud
This is a deployment of OpenHands GUI, running on hosted infrastructure.
The easiest way to run OpenHands locally is using the CLI launcher with [uv](https://docs.astral.sh/uv/). This provides better isolation from your current project's virtual environment and is required for OpenHands' default MCP servers.
You can try it with a free $10 credit by [signing in with your GitHub account](https://app.all-hands.dev).
**Install uv** (if you haven't already):
OpenHands Cloud comes with source-available features and integrations:
- Integrations with Slack, Jira, and Linear
- Multi-user support
- RBAC and permissions
- Collaboration features (e.g., conversation sharing)
See the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/) for the latest installation instructions for your platform.
### OpenHands Enterprise
Large enterprises can work with us to self-host OpenHands Cloud in their own VPC, via Kubernetes.
OpenHands Enterprise can also work with the CLI and SDK above.
**Launch OpenHands**:
```bash
# Launch the GUI server
uvx --python 3.12 --from openhands-ai openhands serve
OpenHands Enterprise is source-available--you can see all the source code here in the enterprise/ directory,
but you'll need to purchase a license if you want to run it for more than one month.
# Or launch the CLI
uvx --python 3.12 --from openhands-ai openhands
```
Enterprise contracts also come with extended support and access to our research team.
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000) (for GUI mode)!
Learn more at [openhands.dev/enterprise](https://openhands.dev/enterprise)
### Option 2: Docker
### Everything Else
<details>
<summary>Click to expand Docker command</summary>
Check out our [Product Roadmap](https://github.com/orgs/openhands/projects/1), and feel free to
[open up an issue](https://github.com/OpenHands/OpenHands/issues) if there's something you'd like to see!
You can also run OpenHands directly with Docker:
You might also be interested in our [evaluation infrastructure](https://github.com/OpenHands/benchmarks), our [chrome extension](https://github.com/OpenHands/openhands-chrome-extension/), or our [Theory-of-Mind module](https://github.com/OpenHands/ToM-SWE).
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik
All our work is available under the MIT license, except for the `enterprise/` directory in this repository (see the [enterprise license](enterprise/LICENSE) for details).
The core `openhands` and `agent-server` Docker images are fully MIT-licensed as well.
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.59
```
If you need help with anything, or just want to chat, [come find us on Slack](https://dub.sh/openhands).
</details>
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
> [!WARNING]
> On a public network? See our [Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)
> to secure your deployment by restricting network binding and implementing additional security measures.
### Getting Started
When you open the application, you'll be asked to choose an LLM provider and add an API key.
[Anthropic's Claude Sonnet 4.5](https://www.anthropic.com/api) (`anthropic/claude-sonnet-4-5-20250929`)
works best, but you have [many options](https://docs.all-hands.dev/usage/llms).
See the [Running OpenHands](https://docs.all-hands.dev/usage/installation) guide for
system requirements and more information.
## 💡 Other ways to run OpenHands
> [!WARNING]
> OpenHands is meant to be run by a single user on their local workstation.
> It is not appropriate for multi-tenant deployments where multiple users share the same instance. There is no built-in authentication, isolation, or scalability.
>
> If you're interested in running OpenHands in a multi-tenant environment, check out the source-available, commercially-licensed
> [OpenHands Cloud Helm Chart](https://github.com/all-Hands-AI/OpenHands-cloud)
You can [connect OpenHands to your local filesystem](https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem),
interact with it via a [friendly CLI](https://docs.all-hands.dev/usage/how-to/cli-mode),
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/usage/how-to/headless-mode),
or run it on tagged issues with [a github action](https://docs.all-hands.dev/usage/how-to/github-action).
Visit [Running OpenHands](https://docs.all-hands.dev/usage/installation) for more information and setup instructions.
If you want to modify the OpenHands source code, check out [Development.md](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/usage/troubleshooting) can help.
## 📖 Documentation
To learn more about the project, and for tips on using OpenHands,
check out our [documentation](https://docs.all-hands.dev/usage/getting-started).
There you'll find resources on how to use different LLM providers,
troubleshooting resources, and advanced configuration options.
## 🤝 How to Join the Community
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
through Slack, so this is the best place to start, but we also are happy to have you contact us on Github:
- [Join our Slack workspace](https://all-hands.dev/joinslack) - Here we talk about research, architecture, and future development.
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
See more about the community in [COMMUNITY.md](./COMMUNITY.md) or find details on contributing in [CONTRIBUTING.md](./CONTRIBUTING.md).
## 📈 Progress
See the monthly OpenHands roadmap [here](https://github.com/orgs/All-Hands-AI/projects/1) (updated at the maintainer's meeting at the end of each month).
<p align="center">
<a href="https://star-history.com/#All-Hands-AI/OpenHands&Date">
<img src="https://api.star-history.com/svg?repos=All-Hands-AI/OpenHands&type=Date" width="500" alt="Star History Chart">
</a>
</p>
## 📜 License
Distributed under the MIT License, with the exception of the `enterprise/` folder. See [`LICENSE`](./LICENSE) for more information.
## 🙏 Acknowledgements
OpenHands is built by a large number of contributors, and every contribution is greatly appreciated! We also build upon other open source projects, and we are deeply thankful for their work.
For a list of open source projects and licenses used in OpenHands, please see our [CREDITS.md](./CREDITS.md) file.
## 📚 Cite
```
@inproceedings{
wang2025openhands,
title={OpenHands: An Open Platform for {AI} Software Developers as Generalist Agents},
author={Xingyao Wang and Boxuan Li and Yufan Song and Frank F. Xu and Xiangru Tang and Mingchen Zhuge and Jiayi Pan and Yueqi Song and Bowen Li and Jaskirat Singh and Hoang H. Tran and Fuqiang Li and Ren Ma and Mingzhang Zheng and Bill Qian and Yanjun Shao and Niklas Muennighoff and Yizhe Zhang and Binyuan Hui and Junyang Lin and Robert Brennan and Hao Peng and Heng Ji and Graham Neubig},
booktitle={The Thirteenth International Conference on Learning Representations},
year={2025},
url={https://openreview.net/forum?id=OJd3ayDDoF}
}
```

View File

@@ -189,7 +189,7 @@ model = "gpt-4o"
# Whether to use native tool calling if supported by the model. Can be true, false, or None by default, which chooses the model's default behavior based on the evaluation.
# ATTENTION: Based on evaluation, enabling native function calling may lead to worse results
# in some scenarios. Use with caution and consider testing with your specific use case.
# https://github.com/OpenHands/OpenHands/pull/4711
# https://github.com/All-Hands-AI/OpenHands/pull/4711
#native_tool_calling = None

View File

@@ -73,7 +73,7 @@ ENV VIRTUAL_ENV=/app/.venv \
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./

View File

@@ -1,4 +1,4 @@
DOCKER_REGISTRY=ghcr.io
DOCKER_ORG=openhands
DOCKER_ORG=all-hands-ai
DOCKER_IMAGE=openhands
DOCKER_BASE_DIR="."

View File

@@ -104,9 +104,6 @@ RUN apt-get update && apt-get install -y \
&& apt-get clean \
&& apt-get autoremove -y
# mark /app as safe git directory to avoid pre-commit errors
RUN git config --system --add safe.directory /app
WORKDIR /app
# cache build dependencies

View File

@@ -1,7 +1,7 @@
# Develop in Docker
> [!WARNING]
> This way of running OpenHands is not officially supported. It is maintained by the community and may not work.
> This is not officially supported and may not work.
Install [Docker](https://docs.docker.com/engine/install/) on your host machine and run:

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.1-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.59-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -1,5 +1,5 @@
DOCKER_REGISTRY=ghcr.io
DOCKER_ORG=openhands
DOCKER_ORG=all-hands-ai
DOCKER_BASE_DIR="./containers/runtime"
DOCKER_IMAGE=runtime
# These variables will be appended by the runtime_build.py script

View File

@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
- id: check-yaml
args: ["--allow-multiple-documents"]
- id: debug-statements
@@ -28,12 +28,12 @@ repos:
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
exclude: ^(third_party/|enterprise/)
exclude: ^(third_party/|enterprise/|openhands-cli/)
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: ^(third_party/|enterprise/)
exclude: ^(third_party/|enterprise/|openhands-cli/)
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0

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.openhands.dev/openhands/runtime:1.1-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_VERSION=latest
ARG BASE="ghcr.io/openhands/openhands"
ARG BASE="ghcr.io/all-hands-ai/openhands"
FROM ${BASE}:${OPENHANDS_VERSION}
# Datadog labels
@@ -31,8 +31,9 @@ RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gsprea
"pillow>=11.3.0"
WORKDIR /app
COPY --chown=openhands:openhands --chmod=770 enterprise .
COPY enterprise .
RUN chown -R openhands:openhands /app && chmod -R 770 /app
USER openhands
# Command will be overridden by Kubernetes deployment template

View File

@@ -8,7 +8,7 @@
This directory contains the enterprise server used by [OpenHands Cloud](https://github.com/All-Hands-AI/OpenHands-Cloud/). The official, public version of OpenHands Cloud is available at
[app.all-hands.dev](https://app.all-hands.dev).
You may also want to check out the MIT-licensed [OpenHands](https://github.com/OpenHands/OpenHands)
You may also want to check out the MIT-licensed [OpenHands](https://github.com/All-Hands-AI/OpenHands)
## Extension of OpenHands (OSS)
@@ -16,7 +16,7 @@ The code in `/enterprise` directory builds on top of open source (OSS) code, ext
- Enterprise stacks on top of OSS. For example, the middleware in enterprise is stacked right on top of the middlewares in OSS. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
- Enterprise overrides the implementation in OSS (only one is present at a time). For example, the server config SaasServerConfig which overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) on OSS. This is done through dynamic imports ([see here](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
- Enterprise overrides the implementation in OSS (only one is present at a time). For example, the server config SaasServerConfig which overrides [`ServerConfig`](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/server/config/server_config.py#L8) on OSS. This is done through dynamic imports ([see here](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/server/config/server_config.py#L37-#L45))
Key areas that change on `SAAS` are

View File

@@ -721,7 +721,6 @@
"https://$WEB_HOST/oauth/keycloak/callback",
"https://$WEB_HOST/oauth/keycloak/offline/callback",
"https://$WEB_HOST/slack/keycloak-callback",
"https://$WEB_HOST/oauth/device/keycloak-callback",
"https://$WEB_HOST/api/email/verified",
"/realms/$KEYCLOAK_REALM_NAME/$KEYCLOAK_CLIENT_ID/*"
],

View File

@@ -1,856 +0,0 @@
# OpenHands Enterprise Usage Telemetry Service
## Table of Contents
1. [Introduction](#1-introduction)
- 1.1 [Problem Statement](#11-problem-statement)
- 1.2 [Proposed Solution](#12-proposed-solution)
2. [User Interface](#2-user-interface)
- 2.1 [License Warning Banner](#21-license-warning-banner)
- 2.2 [Administrator Experience](#22-administrator-experience)
3. [Other Context](#3-other-context)
- 3.1 [Replicated Platform Integration](#31-replicated-platform-integration)
- 3.2 [Administrator Email Detection Strategy](#32-administrator-email-detection-strategy)
- 3.3 [Metrics Collection Framework](#33-metrics-collection-framework)
4. [Technical Design](#4-technical-design)
- 4.1 [Database Schema](#41-database-schema)
- 4.1.1 [Telemetry Metrics Table](#411-telemetry-metrics-table)
- 4.1.2 [Telemetry Identity Table](#412-telemetry-identity-table)
- 4.2 [Metrics Collection Framework](#42-metrics-collection-framework)
- 4.2.1 [Base Collector Interface](#421-base-collector-interface)
- 4.2.2 [Collector Registry](#422-collector-registry)
- 4.2.3 [Example Collector Implementation](#423-example-collector-implementation)
- 4.3 [Collection and Upload System](#43-collection-and-upload-system)
- 4.3.1 [Metrics Collection Processor](#431-metrics-collection-processor)
- 4.3.2 [Replicated Upload Processor](#432-replicated-upload-processor)
- 4.4 [License Warning System](#44-license-warning-system)
- 4.4.1 [License Status Endpoint](#441-license-status-endpoint)
- 4.4.2 [UI Integration](#442-ui-integration)
- 4.5 [Cronjob Configuration](#45-cronjob-configuration)
- 4.5.1 [Collection Cronjob](#451-collection-cronjob)
- 4.5.2 [Upload Cronjob](#452-upload-cronjob)
5. [Implementation Plan](#5-implementation-plan)
- 5.1 [Database Schema and Models (M1)](#51-database-schema-and-models-m1)
- 5.1.1 [OpenHands - Database Migration](#511-openhands---database-migration)
- 5.1.2 [OpenHands - Model Tests](#512-openhands---model-tests)
- 5.2 [Metrics Collection Framework (M2)](#52-metrics-collection-framework-m2)
- 5.2.1 [OpenHands - Core Collection Framework](#521-openhands---core-collection-framework)
- 5.2.2 [OpenHands - Example Collectors](#522-openhands---example-collectors)
- 5.2.3 [OpenHands - Framework Tests](#523-openhands---framework-tests)
- 5.3 [Collection and Upload Processors (M3)](#53-collection-and-upload-processors-m3)
- 5.3.1 [OpenHands - Collection Processor](#531-openhands---collection-processor)
- 5.3.2 [OpenHands - Upload Processor](#532-openhands---upload-processor)
- 5.3.3 [OpenHands - Integration Tests](#533-openhands---integration-tests)
- 5.4 [License Warning API (M4)](#54-license-warning-api-m4)
- 5.4.1 [OpenHands - License Status API](#541-openhands---license-status-api)
- 5.4.2 [OpenHands - API Integration](#542-openhands---api-integration)
- 5.5 [UI Warning Banner (M5)](#55-ui-warning-banner-m5)
- 5.5.1 [OpenHands - UI Warning Banner](#551-openhands---ui-warning-banner)
- 5.5.2 [OpenHands - UI Integration](#552-openhands---ui-integration)
- 5.6 [Helm Chart Deployment Configuration (M6)](#56-helm-chart-deployment-configuration-m6)
- 5.6.1 [OpenHands-Cloud - Cronjob Manifests](#561-openhands-cloud---cronjob-manifests)
- 5.6.2 [OpenHands-Cloud - Configuration Management](#562-openhands-cloud---configuration-management)
- 5.7 [Documentation and Enhanced Collectors (M7)](#57-documentation-and-enhanced-collectors-m7)
- 5.7.1 [OpenHands - Advanced Collectors](#571-openhands---advanced-collectors)
- 5.7.2 [OpenHands - Monitoring and Testing](#572-openhands---monitoring-and-testing)
- 5.7.3 [OpenHands - Technical Documentation](#573-openhands---technical-documentation)
## 1. Introduction
### 1.1 Problem Statement
OpenHands Enterprise (OHE) helm charts are publicly available but not open source, creating a visibility gap for the sales team. Unknown users can install and use OHE without the vendor's knowledge, preventing proper customer engagement and sales pipeline management. Without usage telemetry, the vendor cannot identify potential customers, track installation health, or proactively support users who may need assistance.
### 1.2 Proposed Solution
We propose implementing a comprehensive telemetry service that leverages the Replicated metrics platform and Python SDK to track OHE installations and usage. The solution provides automatic customer discovery, instance monitoring, and usage metrics collection while maintaining a clear license compliance pathway.
The system consists of three main components: (1) a pluggable metrics collection framework that allows developers to easily define and register custom metrics collectors, (2) automated cronjobs that periodically collect metrics and upload them to Replicated's vendor portal, and (3) a license compliance warning system that displays UI notifications when telemetry uploads fail, indicating potential license expiration.
The design ensures that telemetry cannot be easily disabled without breaking core OHE functionality by tying the warning system to environment variables that are essential for OHE operation. This approach balances user transparency with business requirements for customer visibility.
## 2. User Interface
### 2.1 License Warning Banner
When telemetry uploads fail for more than 4 days, users will see a prominent warning banner in the OpenHands Enterprise UI:
```
⚠️ Your OpenHands Enterprise license will expire in 30 days. Please contact support if this issue persists.
```
The banner appears at the top of all pages and cannot be permanently dismissed while the condition persists. Users can temporarily dismiss it, but it will reappear on page refresh until telemetry uploads resume successfully.
### 2.2 Administrator Experience
System administrators will not need to configure the telemetry system manually. The service automatically:
1. **Detects OHE installations** using existing required environment variables (`GITHUB_APP_CLIENT_ID`, `KEYCLOAK_SERVER_URL`, etc.)
2. **Generates unique customer identifiers** using administrator contact information:
- Customer email: Determined by the following priority order:
1. `OPENHANDS_ADMIN_EMAIL` environment variable (if set in helm values)
2. Email of the first user who accepted Terms of Service (earliest `accepted_tos` timestamp)
- Instance ID: Automatically generated by Replicated SDK using machine fingerprinting (IOPlatformUUID on macOS, D-Bus machine ID on Linux, Machine GUID on Windows)
- **No Fallback**: If neither email source is available, telemetry collection is skipped until at least one user exists
3. **Collects and uploads metrics transparently** in the background via weekly collection and daily upload cronjobs
4. **Displays warnings only when necessary** for license compliance - no notifications appear during normal operation
## 3. Other Context
### 3.1 Replicated Platform Integration
The Replicated platform provides vendor-hosted infrastructure for collecting customer and instance telemetry. The Python SDK handles authentication, state management, and reliable metric delivery. Key concepts:
- **Customer**: Represents a unique OHE installation, identified by email or installation fingerprint
- **Instance**: Represents a specific deployment of OHE for a customer
- **Metrics**: Custom key-value data points collected from the installation
- **Status**: Instance health indicators (running, degraded, updating, etc.)
The SDK automatically handles machine fingerprinting, local state caching, and retry logic for failed uploads.
### 3.2 Administrator Email Detection Strategy
To identify the appropriate administrator contact for sales outreach, the system uses a three-tier approach that avoids performance penalties on user authentication:
**Tier 1: Explicit Configuration** - The `OPENHANDS_ADMIN_EMAIL` environment variable allows administrators to explicitly specify the contact email during deployment.
**Tier 2: First Active User Detection** - If no explicit email is configured, the system identifies the first user who accepted Terms of Service (earliest `accepted_tos` timestamp with a valid email). This represents the first person to actively engage with the system and is very likely the administrator or installer.
**No Fallback Needed** - If neither email source is available, telemetry collection is skipped entirely. This ensures we only report meaningful usage data when there are actual active users.
**Performance Optimization**: The admin email determination is performed only during telemetry upload attempts, ensuring zero performance impact on user login flows.
### 3.3 Metrics Collection Framework
The proposed collector framework allows developers to define metrics in a single file change:
```python
@register_collector("user_activity")
class UserActivityCollector(MetricsCollector):
def collect(self) -> Dict[str, Any]:
# Query database and return metrics
return {"active_users_7d": count, "conversations_created": total}
```
Collectors are automatically discovered and executed by the collection cronjob, making the system extensible without modifying core collection logic.
## 4. Technical Design
### 4.1 Database Schema
#### 4.1.1 Telemetry Metrics Table
Stores collected metrics with transmission status tracking:
```sql
CREATE TABLE telemetry_metrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
collected_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
metrics_data JSONB NOT NULL,
uploaded_at TIMESTAMP WITH TIME ZONE NULL,
upload_attempts INTEGER DEFAULT 0,
last_upload_error TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_telemetry_metrics_collected_at ON telemetry_metrics(collected_at);
CREATE INDEX idx_telemetry_metrics_uploaded_at ON telemetry_metrics(uploaded_at);
```
#### 4.1.2 Telemetry Identity Table
Stores persistent identity information that must survive container restarts:
```sql
CREATE TABLE telemetry_identity (
id INTEGER PRIMARY KEY DEFAULT 1,
customer_id VARCHAR(255) NULL,
instance_id VARCHAR(255) NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT single_identity_row CHECK (id = 1)
);
```
**Design Rationale:**
- **Separation of Concerns**: Identity data (customer_id, instance_id) is separated from operational data
- **Persistent vs Computed**: Only data that cannot be reliably recomputed is persisted
- **Upload Tracking**: Upload timestamps are tied directly to the metrics they represent
- **Simplified Queries**: System state can be derived from metrics table (e.g., `MAX(uploaded_at)` for last successful upload)
### 4.2 Metrics Collection Framework
#### 4.2.1 Base Collector Interface
```python
from abc import ABC, abstractmethod
from typing import Dict, Any, List
from dataclasses import dataclass
@dataclass
class MetricResult:
key: str
value: Any
class MetricsCollector(ABC):
"""Base class for metrics collectors."""
@abstractmethod
def collect(self) -> List[MetricResult]:
"""Collect metrics and return results."""
pass
@property
@abstractmethod
def collector_name(self) -> str:
"""Unique name for this collector."""
pass
def should_collect(self) -> bool:
"""Override to add collection conditions."""
return True
```
#### 4.2.2 Collector Registry
```python
from typing import Dict, Type, List
import importlib
import pkgutil
class CollectorRegistry:
"""Registry for metrics collectors."""
def __init__(self):
self._collectors: Dict[str, Type[MetricsCollector]] = {}
def register(self, collector_class: Type[MetricsCollector]) -> None:
"""Register a collector class."""
collector = collector_class()
self._collectors[collector.collector_name] = collector_class
def get_all_collectors(self) -> List[MetricsCollector]:
"""Get instances of all registered collectors."""
return [cls() for cls in self._collectors.values()]
def discover_collectors(self, package_path: str) -> None:
"""Auto-discover collectors in a package."""
# Implementation to scan for @register_collector decorators
pass
# Global registry instance
collector_registry = CollectorRegistry()
def register_collector(name: str):
"""Decorator to register a collector."""
def decorator(cls: Type[MetricsCollector]) -> Type[MetricsCollector]:
collector_registry.register(cls)
return cls
return decorator
```
#### 4.2.3 Example Collector Implementation
```python
@register_collector("system_metrics")
class SystemMetricsCollector(MetricsCollector):
"""Collects basic system and usage metrics."""
@property
def collector_name(self) -> str:
return "system_metrics"
def collect(self) -> List[MetricResult]:
results = []
# Collect user count
with session_maker() as session:
user_count = session.query(UserSettings).count()
results.append(MetricResult(
key="total_users",
value=user_count
))
# Collect conversation count (last 30 days)
thirty_days_ago = datetime.now(timezone.utc) - timedelta(days=30)
conversation_count = session.query(StoredConversationMetadata)\
.filter(StoredConversationMetadata.created_at >= thirty_days_ago)\
.count()
results.append(MetricResult(
key="conversations_30d",
value=conversation_count
))
return results
```
### 4.3 Collection and Upload System
#### 4.3.1 Metrics Collection Processor
```python
class TelemetryCollectionProcessor(MaintenanceTaskProcessor):
"""Maintenance task processor for collecting metrics."""
collection_interval_days: int = 7
async def __call__(self, task: MaintenanceTask) -> dict:
"""Collect metrics from all registered collectors."""
# Check if collection is needed
if not self._should_collect():
return {"status": "skipped", "reason": "too_recent"}
# Collect metrics from all registered collectors
all_metrics = {}
collector_results = {}
for collector in collector_registry.get_all_collectors():
try:
if collector.should_collect():
results = collector.collect()
for result in results:
all_metrics[result.key] = result.value
collector_results[collector.collector_name] = len(results)
except Exception as e:
logger.error(f"Collector {collector.collector_name} failed: {e}")
collector_results[collector.collector_name] = f"error: {e}"
# Store metrics in database
with session_maker() as session:
telemetry_record = TelemetryMetrics(
metrics_data=all_metrics,
collected_at=datetime.now(timezone.utc)
)
session.add(telemetry_record)
session.commit()
# Note: No need to track last_collection_at separately
# Can be derived from MAX(collected_at) in telemetry_metrics
return {
"status": "completed",
"metrics_collected": len(all_metrics),
"collectors_run": collector_results
}
def _should_collect(self) -> bool:
"""Check if collection is needed based on interval."""
with session_maker() as session:
# Get last collection time from metrics table
last_collected = session.query(func.max(TelemetryMetrics.collected_at)).scalar()
if not last_collected:
return True
time_since_last = datetime.now(timezone.utc) - last_collected
return time_since_last.days >= self.collection_interval_days
```
#### 4.3.2 Replicated Upload Processor
```python
from replicated import AsyncReplicatedClient, InstanceStatus
class TelemetryUploadProcessor(MaintenanceTaskProcessor):
"""Maintenance task processor for uploading metrics to Replicated."""
replicated_publishable_key: str
replicated_app_slug: str
async def __call__(self, task: MaintenanceTask) -> dict:
"""Upload pending metrics to Replicated."""
# Get pending metrics
with session_maker() as session:
pending_metrics = session.query(TelemetryMetrics)\
.filter(TelemetryMetrics.uploaded_at.is_(None))\
.order_by(TelemetryMetrics.collected_at)\
.all()
if not pending_metrics:
return {"status": "no_pending_metrics"}
# Get admin email - skip if not available
admin_email = self._get_admin_email()
if not admin_email:
logger.info("Skipping telemetry upload - no admin email available")
return {
"status": "skipped",
"reason": "no_admin_email",
"total_processed": 0
}
uploaded_count = 0
failed_count = 0
async with AsyncReplicatedClient(
publishable_key=self.replicated_publishable_key,
app_slug=self.replicated_app_slug
) as client:
# Get or create customer and instance
customer = await client.customer.get_or_create(
email_address=admin_email
)
instance = await customer.get_or_create_instance()
# Store customer/instance IDs for future use
await self._update_telemetry_identity(customer.customer_id, instance.instance_id)
# Upload each metric batch
for metric_record in pending_metrics:
try:
# Send individual metrics
for key, value in metric_record.metrics_data.items():
await instance.send_metric(key, value)
# Update instance status
await instance.set_status(InstanceStatus.RUNNING)
# Mark as uploaded
with session_maker() as session:
record = session.query(TelemetryMetrics)\
.filter(TelemetryMetrics.id == metric_record.id)\
.first()
if record:
record.uploaded_at = datetime.now(timezone.utc)
session.commit()
uploaded_count += 1
except Exception as e:
logger.error(f"Failed to upload metrics {metric_record.id}: {e}")
# Update error info
with session_maker() as session:
record = session.query(TelemetryMetrics)\
.filter(TelemetryMetrics.id == metric_record.id)\
.first()
if record:
record.upload_attempts += 1
record.last_upload_error = str(e)
session.commit()
failed_count += 1
# Note: No need to track last_successful_upload_at separately
# Can be derived from MAX(uploaded_at) in telemetry_metrics
return {
"status": "completed",
"uploaded": uploaded_count,
"failed": failed_count,
"total_processed": len(pending_metrics)
}
def _get_admin_email(self) -> str | None:
"""Get administrator email for customer identification."""
# 1. Check environment variable first
env_admin_email = os.getenv('OPENHANDS_ADMIN_EMAIL')
if env_admin_email:
logger.info("Using admin email from environment variable")
return env_admin_email
# 2. Use first active user's email (earliest accepted_tos)
with session_maker() as session:
first_user = session.query(UserSettings)\
.filter(UserSettings.email.isnot(None))\
.filter(UserSettings.accepted_tos.isnot(None))\
.order_by(UserSettings.accepted_tos.asc())\
.first()
if first_user and first_user.email:
logger.info(f"Using first active user email: {first_user.email}")
return first_user.email
# No admin email available - skip telemetry
logger.info("No admin email available - skipping telemetry collection")
return None
async def _update_telemetry_identity(self, customer_id: str, instance_id: str) -> None:
"""Update or create telemetry identity record."""
with session_maker() as session:
identity = session.query(TelemetryIdentity).first()
if not identity:
identity = TelemetryIdentity()
session.add(identity)
identity.customer_id = customer_id
identity.instance_id = instance_id
session.commit()
```
### 4.4 License Warning System
#### 4.4.1 License Status Endpoint
```python
from fastapi import APIRouter
from datetime import datetime, timezone, timedelta
license_router = APIRouter()
@license_router.get("/license-status")
async def get_license_status():
"""Get license warning status for UI display."""
# Only show warnings for OHE installations
if not _is_openhands_enterprise():
return {"warn": False, "message": ""}
with session_maker() as session:
# Get last successful upload time from metrics table
last_upload = session.query(func.max(TelemetryMetrics.uploaded_at))\
.filter(TelemetryMetrics.uploaded_at.isnot(None))\
.scalar()
if not last_upload:
# No successful uploads yet - show warning after 4 days
return {
"warn": True,
"message": "OpenHands Enterprise license verification pending. Please ensure network connectivity."
}
# Check if last successful upload was more than 4 days ago
days_since_upload = (datetime.now(timezone.utc) - last_upload).days
if days_since_upload > 4:
# Find oldest unsent batch
oldest_unsent = session.query(TelemetryMetrics)\
.filter(TelemetryMetrics.uploaded_at.is_(None))\
.order_by(TelemetryMetrics.collected_at)\
.first()
if oldest_unsent:
# Calculate expiration date (oldest unsent + 34 days)
expiration_date = oldest_unsent.collected_at + timedelta(days=34)
days_until_expiration = (expiration_date - datetime.now(timezone.utc)).days
if days_until_expiration <= 0:
message = "Your OpenHands Enterprise license has expired. Please contact support immediately."
else:
message = f"Your OpenHands Enterprise license will expire in {days_until_expiration} days. Please contact support if this issue persists."
return {"warn": True, "message": message}
return {"warn": False, "message": ""}
def _is_openhands_enterprise() -> bool:
"""Detect if this is an OHE installation."""
# Check for required OHE environment variables
required_vars = [
'GITHUB_APP_CLIENT_ID',
'KEYCLOAK_SERVER_URL',
'KEYCLOAK_REALM_NAME'
]
return all(os.getenv(var) for var in required_vars)
```
#### 4.4.2 UI Integration
The frontend will poll the license status endpoint and display warnings using the existing banner component pattern:
```typescript
// New component: LicenseWarningBanner.tsx
interface LicenseStatus {
warn: boolean;
message: string;
}
export function LicenseWarningBanner() {
const [licenseStatus, setLicenseStatus] = useState<LicenseStatus>({ warn: false, message: "" });
useEffect(() => {
const checkLicenseStatus = async () => {
try {
const response = await fetch('/api/license-status');
const status = await response.json();
setLicenseStatus(status);
} catch (error) {
console.error('Failed to check license status:', error);
}
};
// Check immediately and then every hour
checkLicenseStatus();
const interval = setInterval(checkLicenseStatus, 60 * 60 * 1000);
return () => clearInterval(interval);
}, []);
if (!licenseStatus.warn) {
return null;
}
return (
<div className="bg-red-600 text-white p-4 rounded flex items-center justify-between">
<div className="flex items-center">
<FaExclamationTriangle className="mr-3" />
<span>{licenseStatus.message}</span>
</div>
</div>
);
}
```
### 4.5 Cronjob Configuration
The cronjob configurations will be deployed via the OpenHands-Cloud helm charts.
#### 4.5.1 Collection Cronjob
The collection cronjob runs weekly to gather metrics:
```yaml
# charts/openhands/templates/telemetry-collection-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "openhands.fullname" . }}-telemetry-collection
labels:
{{- include "openhands.labels" . | nindent 4 }}
spec:
schedule: "0 2 * * 0" # Weekly on Sunday at 2 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: telemetry-collector
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
env:
{{- include "openhands.env" . | nindent 12 }}
command:
- python
- -c
- |
from enterprise.storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from enterprise.storage.database import session_maker
from enterprise.server.telemetry.collection_processor import TelemetryCollectionProcessor
# Create collection task
processor = TelemetryCollectionProcessor()
task = MaintenanceTask()
task.set_processor(processor)
task.status = MaintenanceTaskStatus.PENDING
with session_maker() as session:
session.add(task)
session.commit()
restartPolicy: OnFailure
```
#### 4.5.2 Upload Cronjob
The upload cronjob runs daily to send metrics to Replicated:
```yaml
# charts/openhands/templates/telemetry-upload-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "openhands.fullname" . }}-telemetry-upload
labels:
{{- include "openhands.labels" . | nindent 4 }}
spec:
schedule: "0 3 * * *" # Daily at 3 AM
jobTemplate:
spec:
template:
spec:
containers:
- name: telemetry-uploader
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
env:
{{- include "openhands.env" . | nindent 12 }}
- name: REPLICATED_PUBLISHABLE_KEY
valueFrom:
secretKeyRef:
name: {{ include "openhands.fullname" . }}-replicated-config
key: publishable-key
- name: REPLICATED_APP_SLUG
value: {{ .Values.telemetry.replicatedAppSlug | default "openhands-enterprise" | quote }}
command:
- python
- -c
- |
from enterprise.storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from enterprise.storage.database import session_maker
from enterprise.server.telemetry.upload_processor import TelemetryUploadProcessor
import os
# Create upload task
processor = TelemetryUploadProcessor(
replicated_publishable_key=os.getenv('REPLICATED_PUBLISHABLE_KEY'),
replicated_app_slug=os.getenv('REPLICATED_APP_SLUG', 'openhands-enterprise')
)
task = MaintenanceTask()
task.set_processor(processor)
task.status = MaintenanceTaskStatus.PENDING
with session_maker() as session:
session.add(task)
session.commit()
restartPolicy: OnFailure
```
## 5. Implementation Plan
All implementation must pass existing lints and tests. New functionality requires comprehensive unit tests with >90% coverage. Integration tests should verify end-to-end telemetry flow including collection, storage, upload, and warning display.
### 5.1 Database Schema and Models (M1)
**Repository**: OpenHands
Establish the foundational database schema and SQLAlchemy models for telemetry data storage.
#### 5.1.1 OpenHands - Database Migration
- [ ] `enterprise/migrations/versions/077_create_telemetry_tables.py`
- [ ] `enterprise/storage/telemetry_metrics.py`
- [ ] `enterprise/storage/telemetry_config.py`
#### 5.1.2 OpenHands - Model Tests
- [ ] `enterprise/tests/unit/storage/test_telemetry_metrics.py`
- [ ] `enterprise/tests/unit/storage/test_telemetry_config.py`
**Demo**: Database tables created and models can store/retrieve telemetry data.
### 5.2 Metrics Collection Framework (M2)
**Repository**: OpenHands
Implement the pluggable metrics collection system with registry and base classes.
#### 5.2.1 OpenHands - Core Collection Framework
- [ ] `enterprise/server/telemetry/__init__.py`
- [ ] `enterprise/server/telemetry/collector_base.py`
- [ ] `enterprise/server/telemetry/collector_registry.py`
- [ ] `enterprise/server/telemetry/decorators.py`
#### 5.2.2 OpenHands - Example Collectors
- [ ] `enterprise/server/telemetry/collectors/__init__.py`
- [ ] `enterprise/server/telemetry/collectors/system_metrics.py`
- [ ] `enterprise/server/telemetry/collectors/user_activity.py`
#### 5.2.3 OpenHands - Framework Tests
- [ ] `enterprise/tests/unit/telemetry/test_collector_base.py`
- [ ] `enterprise/tests/unit/telemetry/test_collector_registry.py`
- [ ] `enterprise/tests/unit/telemetry/test_system_metrics.py`
**Demo**: Developers can create new collectors with a single file change using the @register_collector decorator.
### 5.3 Collection and Upload Processors (M3)
**Repository**: OpenHands
Implement maintenance task processors for collecting metrics and uploading to Replicated.
#### 5.3.1 OpenHands - Collection Processor
- [ ] `enterprise/server/telemetry/collection_processor.py`
- [ ] `enterprise/tests/unit/telemetry/test_collection_processor.py`
#### 5.3.2 OpenHands - Upload Processor
- [ ] `enterprise/server/telemetry/upload_processor.py`
- [ ] `enterprise/tests/unit/telemetry/test_upload_processor.py`
#### 5.3.3 OpenHands - Integration Tests
- [ ] `enterprise/tests/integration/test_telemetry_flow.py`
**Demo**: Metrics are automatically collected weekly and uploaded daily to Replicated vendor portal.
### 5.4 License Warning API (M4)
**Repository**: OpenHands
Implement the license status endpoint for the warning system.
#### 5.4.1 OpenHands - License Status API
- [ ] `enterprise/server/routes/license.py`
- [ ] `enterprise/tests/unit/routes/test_license.py`
#### 5.4.2 OpenHands - API Integration
- [ ] Update `enterprise/saas_server.py` to include license router
**Demo**: License status API returns warning status based on telemetry upload success.
### 5.5 UI Warning Banner (M5)
**Repository**: OpenHands
Implement the frontend warning banner component and integration.
#### 5.5.1 OpenHands - UI Warning Banner
- [ ] `frontend/src/components/features/license/license-warning-banner.tsx`
- [ ] `frontend/src/components/features/license/license-warning-banner.test.tsx`
#### 5.5.2 OpenHands - UI Integration
- [ ] Update main UI layout to include license warning banner
- [ ] Add license status polling service
**Demo**: License warnings appear in UI when telemetry uploads fail for >4 days, with accurate expiration countdown.
### 5.6 Helm Chart Deployment Configuration (M6)
**Repository**: OpenHands-Cloud
Create Kubernetes cronjob configurations and deployment scripts.
#### 5.6.1 OpenHands-Cloud - Cronjob Manifests
- [ ] `charts/openhands/templates/telemetry-collection-cronjob.yaml`
- [ ] `charts/openhands/templates/telemetry-upload-cronjob.yaml`
#### 5.6.2 OpenHands-Cloud - Configuration Management
- [ ] `charts/openhands/templates/replicated-secret.yaml`
- [ ] Update `charts/openhands/values.yaml` with telemetry configuration options:
```yaml
# Add to values.yaml
telemetry:
enabled: true
replicatedAppSlug: "openhands-enterprise"
adminEmail: "" # Optional: admin email for customer identification
# Add to deployment environment variables
env:
OPENHANDS_ADMIN_EMAIL: "{{ .Values.telemetry.adminEmail }}"
```
**Demo**: Complete telemetry system deployed via helm chart with configurable collection intervals and Replicated integration.
### 5.7 Documentation and Enhanced Collectors (M7)
**Repository**: OpenHands
Add comprehensive metrics collectors, monitoring capabilities, and documentation.
#### 5.7.1 OpenHands - Advanced Collectors
- [ ] `enterprise/server/telemetry/collectors/conversation_metrics.py`
- [ ] `enterprise/server/telemetry/collectors/integration_usage.py`
- [ ] `enterprise/server/telemetry/collectors/performance_metrics.py`
#### 5.7.2 OpenHands - Monitoring and Testing
- [ ] `enterprise/server/telemetry/monitoring.py`
- [ ] `enterprise/tests/e2e/test_telemetry_system.py`
- [ ] Performance tests for large-scale metric collection
#### 5.7.3 OpenHands - Technical Documentation
- [ ] `enterprise/server/telemetry/README.md`
- [ ] Update deployment documentation with telemetry configuration instructions
- [ ] Add troubleshooting guide for telemetry issues
**Demo**: Rich telemetry data flowing to vendor portal with comprehensive monitoring, alerting for system health, and complete documentation.

View File

@@ -1,274 +0,0 @@
# Instructions for developing SAAS locally
You have a few options here, which are expanded on below:
- A simple local development setup, with live reloading for both OSS and this repo
- A more complex setup that includes Redis
- An even more complex setup that includes GitHub events
## Prerequisites
Before starting, make sure you have the following tools installed:
### Required for all options:
- [gcloud CLI](https://cloud.google.com/sdk/docs/install) - For authentication and secrets management
- [sops](https://github.com/mozilla/sops) - For secrets decryption
- macOS: `brew install sops`
- Linux: `sudo apt-get install sops` or download from GitHub releases
- Windows: Install via Chocolatey `choco install sops` or download from GitHub releases
### Additional requirements for enabling GitHub webhook events
- make
- Python development tools (build-essential, python3-dev)
- [ngrok](https://ngrok.com/download) - For creating tunnels to localhost
## Option 1: Simple local development
This option will allow you to modify the both the OSS code and the code in this repo,
and see the changes in real-time.
This option works best for most scenarios. The only thing it's missing is
the GitHub events webhook, which is not necessary for most development.
### 1. OpenHands location
The open source OpenHands repo should be cloned as a sibling directory,
in `../OpenHands`. This is hard-coded in the pyproject.toml (edit if necessary)
If you're doing this the first time, you may need to run
```
poetry update openhands-ai
```
### 2. Set up env
First run this to retrieve Github App secrets
```
gcloud auth application-default login
gcloud config set project global-432717
enterprise_local/decrypt_env.sh /path/to/root/of/deploy/repo
```
Now run this to generate a `.env` file, which will used to run SAAS locally
```
python -m pip install PyYAML
export LITE_LLM_API_KEY=<your LLM API key>
python enterprise_local/convert_to_env.py
```
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
```
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
```
By default the application will log in json, you can override.
```
export LOG_PLAIN_TEXT=1
```
### 3. Start the OpenHands frontend
Start the frontend like you normally would in the open source OpenHands repo.
### 4. Start the SaaS backend
```
make build
make start-backend
```
You should have a server running on `localhost:3000`, similar to the open source backend.
Oauth should work properly.
## Option 2: With Redis
Follow all the steps above, then setup redis:
```bash
docker run -p 6379:6379 --name openhands-redis -d redis
export REDIS_HOST=host.docker.internal # you may want this to be localhost
export REDIS_PORT=6379
```
## Option 3: Work with GitHub events
### 1. Setup env file
(see above)
### 2. Build OSS Openhands
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
```
### 3. Build SAAS Openhands
Build the SAAS image locally inside Deploy repo. Note that `openhands` is the name of the image built in Step 2
```
docker build -t openhands-saas ./app/ --build-arg BASE="openhands"
```
### 4. Create a tunnel
Run in a separate terminal
```
ngrok http 3000
```
There will be a line
```
Forwarding https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app
```
Remember this URL as it will be used in Step 5 and 6
### 5. Setup Staging Github App callback/webhook urls
Using the URL found in Step 4, add another callback URL (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app/oauth/github/callback`)
### 6. Run
This is the last step! Run SAAS openhands locally using
```
docker run --env-file ./app/.env -p 3000:3000 openhands-saas
```
Note `--env-file` is what injects the `.env` file created in Step 1
Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app`)
### Local Debugging with VSCode
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
#### Redis
A Local redis instance is required for clustered communication between server nodes. The standard docker instance will suffice.
`docker run -it -p 6379:6379 --name my-redis -d redis`
#### Postgres
A Local postgres instance is required. I used the official docker image:
`docker run -p 5432:5432 --name my-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=openhands -d postgres`
Run the alembic migrations:
`poetry run alembic upgrade head `
#### VSCode launch.json
The VSCode launch.json below sets up 2 servers to test clustering, running independently on localhost:3030 and localhost:3031. Running only the server on 3030 is usually sufficient unless tests of the clustered functionality are required. Secrets may be harvested directly from staging by connecting...
`kubectl exec --stdin --tty <POD_NAME> -n <NAMESPACE> -- /bin/bash`
And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by the time you read this, nobody will have access.)
```
{
"configurations": [
{
"name": "Python Debugger: Python File",
"type": "debugpy",
"request": "launch",
"program": "${file}"
},
{
"name": "OpenHands Deploy",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3030"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "OpenHands Deploy 2",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3031"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "Unit Tests",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": [
"./tests/unit",
//"./tests/unit/test_clustered_conversation_manager.py",
"--durations=0"
],
"env": {
"DEBUG": "1"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
// set working directory...
]
}
```

View File

@@ -1,127 +0,0 @@
import base64
import os
import sys
import yaml
def convert_yaml_to_env(yaml_file, target_parameters, output_env_file, prefix):
"""Converts a YAML file into .env file format for specified target parameters under 'stringData' and 'data'.
:param yaml_file: Path to the YAML file.
:param target_parameters: List of keys to extract from the YAML file.
:param output_env_file: Path to the output .env file.
:param prefix: Prefix for environment variables.
"""
try:
# Load the YAML file
with open(yaml_file, 'r') as file:
yaml_data = yaml.safe_load(file)
# Extract sections
string_data = yaml_data.get('stringData', None)
data = yaml_data.get('data', None)
if string_data:
env_source = string_data
process_base64 = False
elif data:
env_source = data
process_base64 = True
else:
print(
"Error: Neither 'stringData' nor 'data' section found in the YAML file."
)
return
env_lines = []
for param in target_parameters:
if param in env_source:
value = env_source[param]
if process_base64:
try:
decoded_value = base64.b64decode(value).decode('utf-8')
formatted_value = (
decoded_value.replace('\n', '\\n')
if '\n' in decoded_value
else decoded_value
)
except Exception as decode_error:
print(f"Error decoding base64 for '{param}': {decode_error}")
continue
else:
formatted_value = (
value.replace('\n', '\\n')
if isinstance(value, str) and '\n' in value
else value
)
new_key = prefix + param.upper().replace('-', '_')
env_lines.append(f'{new_key}={formatted_value}')
else:
print(
f"Warning: Parameter '{param}' not found in the selected section."
)
# Write to the .env file
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(env_lines) + '\n')
except Exception as e:
print(f'Error: {e}')
lite_llm_api_key = os.getenv('LITE_LLM_API_KEY')
if not lite_llm_api_key:
print('Set the LITE_LLM_API_KEY environment variable to your API key')
sys.exit(1)
yaml_file = 'github_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'webhook-secret', 'private-key']
output_env_file = './enterprise/.env'
if os.path.exists(output_env_file):
os.remove(output_env_file)
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'GITHUB_APP_')
os.remove(yaml_file)
yaml_file = 'keycloak_realm_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'provider-name', 'realm-name']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
yaml_file = 'keycloak_admin_decrypted.yaml'
target_parameters = ['admin-password']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
lines = []
lines.append('KEYCLOAK_SERVER_URL=https://auth.staging.all-hands.dev/')
lines.append('KEYCLOAK_SERVER_URL_EXT=https://auth.staging.all-hands.dev/')
lines.append('OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig')
lines.append(
'OPENHANDS_GITHUB_SERVICE_CLS=integrations.github.github_service.SaaSGitHubService'
)
lines.append(
'OPENHANDS_GITLAB_SERVICE_CLS=integrations.gitlab.gitlab_service.SaaSGitLabService'
)
lines.append(
'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService'
)
lines.append(
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
)
lines.append('POSTHOG_CLIENT_KEY=test')
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-opus-4-5-20251101')
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
lines.append('LOCAL_DEPLOYMENT=true')
lines.append('DB_HOST=localhost')
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(lines))
print(f'.env file created at: {output_env_file}')

View File

@@ -1,27 +0,0 @@
#!/bin/bash
set -euo pipefail
# Check if DEPLOY_DIR argument was provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <DEPLOY_DIR>"
echo "Example: $0 /path/to/root/of/deploy/repo"
exit 1
fi
# Normalize path (remove trailing slash)
DEPLOY_DIR="${1%/}"
# Function to decrypt and rename
decrypt_and_move() {
local secret_path="$1"
local output_name="$2"
${DEPLOY_DIR}/scripts/decrypt.sh "${DEPLOY_DIR}/${secret_path}"
mv decrypted.yaml "${output_name}"
echo "Moved decrypted.yaml to ${output_name}"
}
# Decrypt each secret file
decrypt_and_move "openhands/envs/feature/secrets/github-app.yaml" "github_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-realm.yaml" "keycloak_realm_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-admin.yaml" "keycloak_admin_decrypted.yaml"

View File

@@ -5,8 +5,12 @@ from experiments.constants import (
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
@@ -27,6 +31,10 @@ class SaaSExperimentManager(ExperimentManager):
)
return agent
agent = handle_condenser_max_step_experiment__v1(
user_id, conversation_id, agent
)
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
@@ -52,7 +60,20 @@ class SaaSExperimentManager(ExperimentManager):
"""
logger.debug(
'experiment_manager:run_conversation_variant_test:started',
extra={'user_id': user_id, 'conversation_id': conversation_id},
extra={'user_id': user_id},
)
# Skip all experiment processing if the experiment manager is disabled
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return conversation_settings
# Apply conversation-scoped experiments
conversation_settings = handle_condenser_max_step_experiment(
user_id, conversation_id, conversation_settings
)
return conversation_settings

View File

@@ -22,7 +22,6 @@ from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
@@ -32,7 +31,7 @@ from server.utils.conversation_callback_utils import register_callback_processor
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.utils.async_utils import call_sync_from_async
@@ -165,13 +164,8 @@ class GithubManager(Manager):
)
if await self.is_job_requested(message):
payload = message.message.get('payload', {})
user_id = payload['sender']['id']
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
github_view = await GithubFactory.create_github_view_from_payload(
message, keycloak_user_id
message, self.token_manager
)
logger.info(
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
@@ -256,7 +250,7 @@ class GithubManager(Manager):
f'[GitHub] Creating new conversation for user {user_info.username}'
)
secret_store = Secrets(
secret_store = UserSecrets(
provider_tokens=MappingProxyType(
{
ProviderType.GITHUB: ProviderToken(
@@ -288,15 +282,8 @@ class GithubManager(Manager):
f'[Github]: Error summarizing issue solvability: {str(e)}'
)
saas_user_auth = await get_saas_user_auth(
github_view.user_info.keycloak_user_id, self.token_manager
)
await github_view.create_new_conversation(
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
self.jinja_env, secret_store.provider_tokens, convo_metadata
)
conversation_id = github_view.conversation_id
@@ -305,19 +292,18 @@ class GithubManager(Manager):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
if not github_view.v1:
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
send_summary_instruction=True,
)
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
send_summary_instruction=True,
)
# Register the callback processor
register_callback_processor(conversation_id, processor)
# Register the callback processor
register_callback_processor(conversation_id, processor)
logger.info(
f'[Github] Registered callback processor for conversation {conversation_id}'
)
logger.info(
f'[Github] Registered callback processor for conversation {conversation_id}'
)
# Send message with conversation link
conversation_link = CONVERSATION_URL.format(conversation_id)

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from uuid import uuid4
from github import Github, GithubIntegration
from github.Issue import Issue
@@ -9,43 +8,32 @@ from integrations.github.github_types import (
WorkflowRunStatus,
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
ENABLE_V1_GITHUB_RESOLVER,
HOST,
HOST_URL,
get_oh_labels,
has_exact_mention,
)
from jinja2 import Environment
from pydantic.dataclasses import dataclass
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.database import session_maker
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
from openhands.agent_server.models import SendMessageRequest
from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartRequest,
AppConversationStartTaskStatus,
)
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_service import GithubServiceImpl
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.integrations.service_types import Comment
from openhands.sdk import TextContent
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
@@ -73,51 +61,20 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
if not user_id:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
def _get_setting():
with session_maker() as session:
settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.enable_proactive_conversation_starters is None:
return False
if not settings or settings.enable_proactive_conversation_starters is None:
return False
return settings.enable_proactive_conversation_starters
return settings.enable_proactive_conversation_starters
async def get_user_v1_enabled_setting(user_id: str) -> bool:
"""Get the user's V1 conversation API setting.
Args:
user_id: The keycloak user ID
Returns:
True if V1 conversations are enabled for this user, False otherwise
Note:
This function checks both the global environment variable kill switch AND
the user's individual setting. Both must be true for the function to return true.
"""
# Check the global environment variable first
if not ENABLE_V1_GITHUB_RESOLVER:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if not settings or settings.v1_enabled is None:
return False
return settings.v1_enabled
return await call_sync_from_async(_get_setting)
# =================================================
@@ -140,7 +97,6 @@ class GithubIssue(ResolverViewInterface):
title: str
description: str
previous_comments: list[Comment]
v1: bool
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
@@ -187,19 +143,6 @@ class GithubIssue(ResolverViewInterface):
async def initialize_new_conversation(self) -> ConversationMetadata:
# FIXME: Handle if initialize_conversation returns None
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
return ConversationMetadata(
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
)
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
@@ -216,36 +159,7 @@ class GithubIssue(ResolverViewInterface):
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
try:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, saas_user_auth, conversation_metadata
)
return
except Exception as e:
logger.warning(f'Error checking V1 settings, falling back to V0: {e}')
# Use existing V0 conversation service
await self._create_v0_conversation(
jinja_env, git_provider_tokens, conversation_metadata
)
async def _create_v0_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitHub]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
@@ -264,78 +178,6 @@ class GithubIssue(ResolverViewInterface):
conversation_instructions=conversation_instructions,
)
async def _create_v1_conversation(
self,
jinja_env: Environment,
saas_user_auth: UserAuth,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
logger.info('[GitHub V1]: Creating V1 conversation')
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
# Create the initial message request
initial_message = SendMessageRequest(
role='user', content=[TextContent(text=user_instructions)]
)
# Create the GitHub V1 callback processor
github_callback_processor = self._create_github_v1_callback_processor()
# Get the app conversation service and start the conversation
injector_state = InjectorState()
# Create the V1 conversation start request with the callback processor
start_request = AppConversationStartRequest(
conversation_id=UUID(conversation_metadata.conversation_id),
system_message_suffix=conversation_instructions,
initial_message=initial_message,
selected_repository=self.full_repo_name,
git_provider=ProviderType.GITHUB,
title=f'GitHub Issue #{self.issue_number}: {self.title}',
trigger=ConversationTrigger.RESOLVER,
processors=[
github_callback_processor
], # Pass the callback processor directly
)
# Set up the GitHub user context for the V1 system
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
injector_state
) as app_conversation_service:
async for task in app_conversation_service.start_app_conversation(
start_request
):
if task.status == AppConversationStartTaskStatus.ERROR:
logger.error(f'Failed to start V1 conversation: {task.detail}')
raise RuntimeError(
f'Failed to start V1 conversation: {task.detail}'
)
self.v1 = True
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
# Create and return the GitHub V1 callback processor
return GithubV1CallbackProcessor(
github_view_data={
'issue_number': self.issue_number,
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
},
send_summary_instruction=self.send_summary_instruction,
)
@dataclass
class GithubIssueComment(GithubIssue):
@@ -391,18 +233,7 @@ class GithubPRComment(GithubIssueComment):
return user_instructions, conversation_instructions
async def initialize_new_conversation(self) -> ConversationMetadata:
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
# Create dummy conversationm metadata
# Don't save to conversation store
# V1 conversations are stored in a separate table
return ConversationMetadata(
conversation_id=uuid4().hex, selected_repository=self.full_repo_name
)
# FIXME: Handle if initialize_conversation returns None
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
user_id=self.user_info.keycloak_user_id,
conversation_id=None,
@@ -462,24 +293,6 @@ class GithubInlinePRComment(GithubPRComment):
return user_instructions, conversation_instructions
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
GithubV1CallbackProcessor,
)
# Create and return the GitHub V1 callback processor
return GithubV1CallbackProcessor(
github_view_data={
'issue_number': self.issue_number,
'full_repo_name': self.full_repo_name,
'installation_id': self.installation_id,
'comment_id': self.comment_id,
},
inline_pr_comment=True,
send_summary_instruction=self.send_summary_instruction,
)
@dataclass
class GithubFailingAction:
@@ -793,7 +606,7 @@ class GithubFactory:
@staticmethod
async def create_github_view_from_payload(
message: Message, keycloak_user_id: str
message: Message, token_manager: TokenManager
) -> ResolverViewInterface:
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
Also return metadata about the event (e.g., action type).
@@ -803,10 +616,17 @@ class GithubFactory:
user_id = payload['sender']['id']
username = payload['sender']['login']
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
if keyloak_user_id is None:
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
is_public_repo = not repo_obj.get('private', True)
user_info = UserData(
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
)
installation_id = message.message['installation']
@@ -830,7 +650,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_issue_comment(message):
@@ -856,7 +675,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_pr_comment(message):
@@ -898,7 +716,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_inline_pr_comment(message):
@@ -932,7 +749,6 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
else:

View File

@@ -25,7 +25,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.data_models.user_secrets import UserSecrets
class GitlabManager(Manager):
@@ -198,7 +198,7 @@ class GitlabManager(Manager):
f'[GitLab] Creating new conversation for user {user_info.username}'
)
secret_store = Secrets(
secret_store = UserSecrets(
provider_tokens=MappingProxyType(
{
ProviderType.GITLAB: ProviderToken(

View File

@@ -32,7 +32,6 @@ from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
@@ -409,7 +408,7 @@ class JiraManager(Manager):
svc_acc_api_key: str,
) -> Tuple[str, str]:
url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key))
response.raise_for_status()
issue_payload = response.json()
@@ -444,7 +443,7 @@ class JiraManager(Manager):
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
data = {'body': message.message}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(
url, auth=(svc_acc_email, svc_acc_api_key), json=data
)

View File

@@ -57,7 +57,7 @@ class JiraNewConversationView(JiraViewInterface):
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
user_secrets = await self.saas_user_auth.get_user_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
try:

View File

@@ -34,7 +34,6 @@ from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
class JiraDcManager(Manager):
@@ -423,7 +422,7 @@ class JiraDcManager(Manager):
"""Get issue details from Jira DC API."""
url = f'{job_context.base_api_url}/rest/api/2/issue/{job_context.issue_key}'
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
issue_payload = response.json()
@@ -453,7 +452,7 @@ class JiraDcManager(Manager):
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
data = {'body': message.message}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()
return response.json()

View File

@@ -60,7 +60,7 @@ class JiraDcNewConversationView(JiraDcViewInterface):
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
user_secrets = await self.saas_user_auth.get_user_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
try:

View File

@@ -31,7 +31,6 @@ from openhands.integrations.service_types import Repository
from openhands.server.shared import server_config
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.server.user_auth.user_auth import UserAuth
from openhands.utils.http_session import httpx_verify_option
class LinearManager(Manager):
@@ -409,7 +408,7 @@ class LinearManager(Manager):
async def _query_api(self, query: str, variables: Dict, api_key: str) -> Dict:
"""Query Linear GraphQL API."""
headers = {'Authorization': api_key}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(
self.api_url,
headers=headers,

View File

@@ -57,7 +57,7 @@ class LinearNewConversationView(LinearViewInterface):
raise StartingConvoException('No repository selected for this conversation')
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
user_secrets = await self.saas_user_auth.get_user_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
try:

View File

@@ -1,63 +0,0 @@
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
class ResolverUserContext(UserContext):
"""User context for resolver operations that inherits from UserContext."""
def __init__(
self,
saas_user_auth: UserAuth,
):
self.saas_user_auth = saas_user_auth
async def get_user_id(self) -> str | None:
return await self.saas_user_auth.get_user_id()
async def get_user_info(self) -> UserInfo:
user_settings = await self.saas_user_auth.get_user_settings()
user_id = await self.saas_user_auth.get_user_id()
if user_settings:
return UserInfo(
id=user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
return UserInfo(id=user_id)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens:
return provider_tokens.get(provider_type)
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:
"""Get secrets for the user, including custom secrets."""
secrets = await self.saas_user_auth.get_secrets()
if secrets:
# Convert custom secrets to StaticSecret objects for SDK compatibility
# secrets.custom_secrets is of type Mapping[str, CustomSecret]
converted_secrets = {}
for key, custom_secret in secrets.custom_secrets.items():
# Extract the secret value from CustomSecret and convert to StaticSecret
secret_value = custom_secret.secret.get_secret_value()
converted_secrets[key] = StaticSecret(value=secret_value)
return converted_secrets
return {}
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()

View File

@@ -87,7 +87,7 @@ class SlackManager(Manager):
return slack_user, saas_user_auth
def _infer_repo_from_message(self, user_msg: str) -> str | None:
# Regular expression to match patterns like "OpenHands/OpenHands" or "deploy repo"
# Regular expression to match patterns like "All-Hands-AI/OpenHands" or "deploy repo"
pattern = r'([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)|([a-zA-Z0-9_-]+)(?=\s+repo)'
match = re.search(pattern, user_msg)

View File

@@ -14,7 +14,6 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
@@ -186,30 +185,22 @@ class SlackNewConversationView(SlackViewInterface):
self._verify_necessary_values_are_set()
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
user_secrets = await self.saas_user_auth.get_user_secrets()
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_instructions,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
conversation_instructions=conversation_instructions
if conversation_instructions
else None,
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id

View File

@@ -19,7 +19,7 @@ class PRStatus(Enum):
class UserData(BaseModel):
user_id: int
username: str
keycloak_user_id: str
keycloak_user_id: str | None
@dataclass

View File

@@ -51,11 +51,6 @@ ENABLE_SOLVABILITY_ANALYSIS = (
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'
)
# Toggle for V1 GitHub resolver feature
ENABLE_V1_GITHUB_RESOLVER = (
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
)
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
@@ -321,7 +316,7 @@ def append_conversation_footer(message: str, conversation_id: str) -> str:
The message with the conversation footer appended
"""
conversation_link = CONVERSATION_URL.format(conversation_id)
footer = f'\n\n[View full conversation]({conversation_link})'
footer = f'\n\n<sub>[View full conversation]({conversation_link})</sub>'
return message + footer
@@ -386,7 +381,7 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
# Captures: protocol, domain, owner, repo (with optional .git extension)
git_url_pattern = r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:[/?#].*?)?(?=\s|$|[^\w.-])'
# Pattern to match direct owner/repo mentions (e.g., "OpenHands/OpenHands")
# Pattern to match direct owner/repo mentions (e.g., "All-Hands-AI/OpenHands")
# Must be surrounded by word boundaries or specific characters to avoid false positives
direct_pattern = (
r'(?:^|\s|[\[\(\'"])([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)(?=\s|$|[\]\)\'",.])'

View File

@@ -1,20 +0,0 @@
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import UserAuth
async def get_saas_user_auth(
keycloak_user_id: str, token_manager: TokenManager
) -> UserAuth:
offline_token = await token_manager.load_offline_token(keycloak_user_id)
if offline_token is None:
logger.info('no_offline_token_found')
user_auth = SaasUserAuth(
user_id=keycloak_user_id,
refresh_token=SecretStr(offline_token),
)
return user_auth

View File

@@ -1,27 +0,0 @@
"""drop settings table
Revision ID: 077
Revises: 076
Create Date: 2025-10-21 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '077'
down_revision: Union[str, None] = '076'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Drop the deprecated settings table."""
op.execute('DROP TABLE IF EXISTS settings')
def downgrade() -> None:
"""No-op downgrade since the settings table is deprecated."""
pass

View File

@@ -1,129 +0,0 @@
"""create telemetry tables
Revision ID: 078
Revises: 077
Create Date: 2025-10-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '078'
down_revision: Union[str, None] = '077'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create telemetry tables for metrics collection and configuration."""
# Create telemetry_metrics table
op.create_table(
'telemetry_metrics',
sa.Column(
'id',
sa.String(), # UUID as string
nullable=False,
primary_key=True,
),
sa.Column(
'collected_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'metrics_data',
sa.JSON(),
nullable=False,
),
sa.Column(
'uploaded_at',
sa.DateTime(timezone=True),
nullable=True,
),
sa.Column(
'upload_attempts',
sa.Integer(),
nullable=False,
server_default='0',
),
sa.Column(
'last_upload_error',
sa.Text(),
nullable=True,
),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'updated_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
)
# Create indexes for telemetry_metrics
op.create_index(
'ix_telemetry_metrics_collected_at', 'telemetry_metrics', ['collected_at']
)
op.create_index(
'ix_telemetry_metrics_uploaded_at', 'telemetry_metrics', ['uploaded_at']
)
# Create telemetry_replicated_identity table (minimal persistent identity data)
op.create_table(
'telemetry_replicated_identity',
sa.Column(
'id',
sa.Integer(),
nullable=False,
primary_key=True,
server_default='1',
),
sa.Column(
'customer_id',
sa.String(255),
nullable=True,
),
sa.Column(
'instance_id',
sa.String(255),
nullable=True,
),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'updated_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
)
# Add constraint to ensure single row in telemetry_replicated_identity
op.create_check_constraint(
'single_identity_row', 'telemetry_replicated_identity', 'id = 1'
)
def downgrade() -> None:
"""Drop telemetry tables."""
# Drop indexes first
op.drop_index('ix_telemetry_metrics_uploaded_at', 'telemetry_metrics')
op.drop_index('ix_telemetry_metrics_collected_at', 'telemetry_metrics')
# Drop tables
op.drop_table('telemetry_replicated_identity')
op.drop_table('telemetry_metrics')

View File

@@ -1,39 +0,0 @@
"""rename user_secrets table to custom_secrets
Revision ID: 079
Revises: 078
Create Date: 2025-10-27 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '079'
down_revision: Union[str, None] = '078'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename the table from user_secrets to custom_secrets
op.rename_table('user_secrets', 'custom_secrets')
# Rename the index to match the new table name
op.drop_index('idx_user_secrets_keycloak_user_id', 'custom_secrets')
op.create_index(
'idx_custom_secrets_keycloak_user_id', 'custom_secrets', ['keycloak_user_id']
)
def downgrade() -> None:
# Rename the index back to the original name
op.drop_index('idx_custom_secrets_keycloak_user_id', 'custom_secrets')
op.create_index(
'idx_user_secrets_keycloak_user_id', 'custom_secrets', ['keycloak_user_id']
)
# Rename the table back from custom_secrets to user_secrets
op.rename_table('custom_secrets', 'user_secrets')

View File

@@ -1,71 +0,0 @@
"""add status and updated_at to callback
Revision ID: 080
Revises: 079
Create Date: 2025-11-05 00:00:00.000000
"""
from enum import Enum
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '080'
down_revision: Union[str, None] = '079'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
class EventCallbackStatus(Enum):
ACTIVE = 'ACTIVE'
DISABLED = 'DISABLED'
COMPLETED = 'COMPLETED'
ERROR = 'ERROR'
def upgrade() -> None:
"""Upgrade schema."""
status = sa.Enum(EventCallbackStatus, name='eventcallbackstatus')
status.create(op.get_bind(), checkfirst=True)
op.add_column(
'event_callback',
sa.Column('status', status, nullable=False, server_default='ACTIVE'),
)
op.add_column(
'event_callback',
sa.Column(
'updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()
),
)
op.drop_index('ix_event_callback_result_event_id')
op.drop_column('event_callback_result', 'event_id')
op.add_column(
'event_callback_result', sa.Column('event_id', sa.String, nullable=True)
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('event_callback', 'status')
op.drop_column('event_callback', 'updated_at')
op.drop_index('ix_event_callback_result_event_id')
op.drop_column('event_callback_result', 'event_id')
op.add_column(
'event_callback_result', sa.Column('event_id', sa.UUID, nullable=True)
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
op.execute('DROP TYPE eventcallbackstatus')

View File

@@ -1,41 +0,0 @@
"""add parent_conversation_id to conversation_metadata
Revision ID: 081
Revises: 080
Create Date: 2025-11-06 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '081'
down_revision: Union[str, None] = '080'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('parent_conversation_id', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
'conversation_metadata',
['parent_conversation_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
op.f('ix_conversation_metadata_parent_conversation_id'),
table_name='conversation_metadata',
)
op.drop_column('conversation_metadata', 'parent_conversation_id')

View File

@@ -1,51 +0,0 @@
"""Add SETTING_UP_SKILLS to appconversationstarttaskstatus enum
Revision ID: 082
Revises: 081
Create Date: 2025-11-19 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = '082'
down_revision: Union[str, Sequence[str], None] = '081'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add SETTING_UP_SKILLS enum value to appconversationstarttaskstatus."""
# Check if the enum value already exists before adding it
# This handles the case where the enum was created with the value already included
connection = op.get_bind()
result = connection.execute(
text(
"SELECT 1 FROM pg_enum WHERE enumlabel = 'SETTING_UP_SKILLS' "
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'appconversationstarttaskstatus')"
)
)
if not result.fetchone():
# Add the new enum value only if it doesn't already exist
op.execute(
"ALTER TYPE appconversationstarttaskstatus ADD VALUE 'SETTING_UP_SKILLS'"
)
def downgrade() -> None:
"""Remove SETTING_UP_SKILLS enum value from appconversationstarttaskstatus.
Note: PostgreSQL doesn't support removing enum values directly.
This would require recreating the enum type and updating all references.
For safety, this downgrade is not implemented.
"""
# PostgreSQL doesn't support removing enum values directly
# This would require a complex migration to recreate the enum
# For now, we'll leave this as a no-op since removing enum values
# is rarely needed and can be dangerous
pass

View File

@@ -1,35 +0,0 @@
"""Add v1_enabled column to user_settings
Revision ID: 083
Revises: 082
Create Date: 2025-11-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '083'
down_revision: Union[str, None] = '082'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add v1_enabled column to user_settings table."""
op.add_column(
'user_settings',
sa.Column(
'v1_enabled',
sa.Boolean(),
nullable=True,
),
)
def downgrade() -> None:
"""Remove v1_enabled column from user_settings table."""
op.drop_column('user_settings', 'v1_enabled')

View File

@@ -1,49 +0,0 @@
"""Create device_codes table for OAuth 2.0 Device Flow
Revision ID: 084
Revises: 083
Create Date: 2024-12-10 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '084'
down_revision = '083'
branch_labels = None
depends_on = None
def upgrade():
"""Create device_codes table for OAuth 2.0 Device Flow."""
op.create_table(
'device_codes',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_code', sa.String(length=128), nullable=False),
sa.Column('user_code', sa.String(length=16), nullable=False),
sa.Column('status', sa.String(length=32), nullable=False),
sa.Column('keycloak_user_id', sa.String(length=255), nullable=True),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('authorized_at', sa.DateTime(timezone=True), nullable=True),
# Rate limiting fields for RFC 8628 section 3.5 compliance
sa.Column('last_poll_time', sa.DateTime(timezone=True), nullable=True),
sa.Column('current_interval', sa.Integer(), nullable=False, default=5),
sa.PrimaryKeyConstraint('id'),
)
# Create indexes for efficient lookups
op.create_index(
'ix_device_codes_device_code', 'device_codes', ['device_code'], unique=True
)
op.create_index(
'ix_device_codes_user_code', 'device_codes', ['user_code'], unique=True
)
def downgrade():
"""Drop device_codes table."""
op.drop_index('ix_device_codes_user_code', table_name='device_codes')
op.drop_index('ix_device_codes_device_code', table_name='device_codes')
op.drop_table('device_codes')

View File

@@ -1,41 +0,0 @@
"""add public column to conversation_metadata
Revision ID: 085
Revises: 084
Create Date: 2025-01-27 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '085'
down_revision: Union[str, None] = '084'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
'conversation_metadata',
sa.Column('public', sa.Boolean(), nullable=True),
)
op.create_index(
op.f('ix_conversation_metadata_public'),
'conversation_metadata',
['public'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(
op.f('ix_conversation_metadata_public'),
table_name='conversation_metadata',
)
op.drop_column('conversation_metadata', 'public')

589
enterprise/poetry.lock generated
View File

@@ -201,20 +201,19 @@ files = [
[[package]]
name = "anthropic"
version = "0.75.0"
version = "0.65.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b"},
{file = "anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb"},
{file = "anthropic-0.65.0-py3-none-any.whl", hash = "sha256:ba9d9f82678046c74ddf5698ca06d9f5b0f599cfac922ab0d5921638eb448d98"},
{file = "anthropic-0.65.0.tar.gz", hash = "sha256:6b6b6942574e54342050dfd42b8d856a8366b171daec147df3b80be4722733b9"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
docstring-parser = ">=0.15,<1"
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
httpx = ">=0.25.0,<1"
jiter = ">=0.4.0,<1"
@@ -223,7 +222,7 @@ sniffio = "*"
typing-extensions = ">=4.10,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
vertex = ["google-auth[requests] (>=2,<3)"]
@@ -682,37 +681,34 @@ crt = ["awscrt (==0.27.6)"]
[[package]]
name = "browser-use"
version = "0.10.1"
version = "0.7.10"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
{file = "browser_use-0.10.1-py3-none-any.whl", hash = "sha256:96e603bfc71098175342cdcb0592519e6f244412e740f0254e4389fdd82a977f"},
{file = "browser_use-0.10.1.tar.gz", hash = "sha256:5f211ecfdf1f9fd186160f10df70dedd661821231e30f1bce40939787abab223"},
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
]
[package.dependencies]
aiohttp = "3.12.15"
anthropic = ">=0.72.1,<1.0.0"
anthropic = ">=0.58.2,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
cdp-use = ">=1.4.4"
click = ">=8.1.8"
cloudpickle = ">=3.1.1"
cdp-use = ">=1.4.0"
google-api-core = ">=2.25.0"
google-api-python-client = ">=2.174.0"
google-auth = ">=2.40.3"
google-auth-oauthlib = ">=1.2.2"
google-genai = ">=1.50.0,<2.0.0"
google-genai = ">=1.29.0,<2.0.0"
groq = ">=0.30.0"
html2text = ">=2025.4.15"
httpx = ">=0.28.1"
inquirerpy = ">=0.3.4"
markdownify = ">=1.2.0"
mcp = ">=1.10.1"
ollama = ">=0.5.1"
openai = ">=2.7.2,<3.0.0"
openai = ">=1.99.2,<2.0.0"
pillow = ">=11.2.1"
portalocker = ">=2.7.0,<3.0.0"
posthog = ">=3.7.0"
@@ -721,24 +717,19 @@ pydantic = ">=2.11.5"
pyobjc = {version = ">=11.0", markers = "platform_system == \"darwin\""}
pyotp = ">=2.9.0"
pypdf = ">=5.7.0"
python-docx = ">=1.2.0"
python-dotenv = ">=1.0.1"
reportlab = ">=4.0.0"
requests = ">=2.32.3"
rich = ">=14.0.0"
screeninfo = {version = ">=0.8.1", markers = "platform_system != \"darwin\""}
typing-extensions = ">=4.12.2"
uuid7 = ">=0.1.0"
[package.extras]
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "oci (>=2.126.4)", "textual (>=3.2.0)"]
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
aws = ["boto3 (>=1.38.45)"]
cli = ["textual (>=3.2.0)"]
cli-oci = ["oci (>=2.126.4)", "textual (>=3.2.0)"]
code = ["matplotlib (>=3.9.0)", "numpy (>=2.3.2)", "pandas (>=2.2.0)", "tabulate (>=0.9.0)"]
eval = ["anyio (>=4.9.0)", "datamodel-code-generator (>=0.26.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
oci = ["oci (>=2.126.4)"]
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
[[package]]
@@ -851,14 +842,14 @@ files = [
[[package]]
name = "cdp-use"
version = "1.4.4"
version = "1.4.3"
description = "Type safe generator/client library for CDP"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "cdp_use-1.4.4-py3-none-any.whl", hash = "sha256:e37e80e067db2653d6fdf953d4ff9e5d80d75daa27b7c6d48c0261cccbef73e1"},
{file = "cdp_use-1.4.4.tar.gz", hash = "sha256:330a848b517006eb9ad1dc468aa6434d913cf0c6918610760c36c3fdfdba0fab"},
{file = "cdp_use-1.4.3-py3-none-any.whl", hash = "sha256:c48664604470c2579aa1e677c3e3e7e24c4f300c54804c093d935abb50479ecd"},
{file = "cdp_use-1.4.3.tar.gz", hash = "sha256:9029c04bdc49fbd3939d2bf1988ad8d88e260729c7d5e35c2f6c87591f5a10e9"},
]
[package.dependencies]
@@ -2979,29 +2970,28 @@ testing = ["pytest"]
[[package]]
name = "google-genai"
version = "1.53.0"
version = "1.32.0"
description = "GenAI Python SDK"
optional = false
python-versions = ">=3.10"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "google_genai-1.53.0-py3-none-any.whl", hash = "sha256:65a3f99e5c03c372d872cda7419f5940e723374bb12a2f3ffd5e3e56e8eb2094"},
{file = "google_genai-1.53.0.tar.gz", hash = "sha256:938a26d22f3fd32c6eeeb4276ef204ef82884e63af9842ce3eac05ceb39cbd8d"},
{file = "google_genai-1.32.0-py3-none-any.whl", hash = "sha256:c0c4b1d45adf3aa99501050dd73da2f0dea09374002231052d81a6765d15e7f6"},
{file = "google_genai-1.32.0.tar.gz", hash = "sha256:349da3f5ff0e981066bd508585fcdd308d28fc4646f318c8f6d1aa6041f4c7e3"},
]
[package.dependencies]
anyio = ">=4.8.0,<5.0.0"
google-auth = {version = ">=2.14.1,<3.0.0", extras = ["requests"]}
google-auth = ">=2.14.1,<3.0.0"
httpx = ">=0.28.1,<1.0.0"
pydantic = ">=2.9.0,<3.0.0"
pydantic = ">=2.0.0,<3.0.0"
requests = ">=2.28.1,<3.0.0"
tenacity = ">=8.2.3,<9.2.0"
typing-extensions = ">=4.11.0,<5.0.0"
websockets = ">=13.0.0,<15.1.0"
[package.extras]
aiohttp = ["aiohttp (<3.13.3)"]
local-tokenizer = ["protobuf", "sentencepiece (>=0.2.0)"]
aiohttp = ["aiohttp (<4.0.0)"]
[[package]]
name = "google-resumable-media"
@@ -3057,8 +3047,6 @@ files = [
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
@@ -3068,8 +3056,6 @@ files = [
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
@@ -3079,8 +3065,6 @@ files = [
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
@@ -3090,8 +3074,6 @@ files = [
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
@@ -3099,8 +3081,6 @@ files = [
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
@@ -3110,8 +3090,6 @@ files = [
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
@@ -3180,87 +3158,83 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
[[package]]
name = "grpcio"
version = "1.67.1"
version = "1.74.0"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
{file = "grpcio-1.74.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:85bd5cdf4ed7b2d6438871adf6afff9af7096486fcf51818a81b77ef4dd30907"},
{file = "grpcio-1.74.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:68c8ebcca945efff9d86d8d6d7bfb0841cf0071024417e2d7f45c5e46b5b08eb"},
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:e154d230dc1bbbd78ad2fdc3039fa50ad7ffcf438e4eb2fa30bce223a70c7486"},
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8978003816c7b9eabe217f88c78bc26adc8f9304bf6a594b02e5a49b2ef9c11"},
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d7bd6e3929fd2ea7fbc3f562e4987229ead70c9ae5f01501a46701e08f1ad9"},
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:136b53c91ac1d02c8c24201bfdeb56f8b3ac3278668cbb8e0ba49c88069e1bdc"},
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fe0f540750a13fd8e5da4b3eaba91a785eea8dca5ccd2bc2ffe978caa403090e"},
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4e4181bfc24413d1e3a37a0b7889bea68d973d4b45dd2bc68bb766c140718f82"},
{file = "grpcio-1.74.0-cp310-cp310-win32.whl", hash = "sha256:1733969040989f7acc3d94c22f55b4a9501a30f6aaacdbccfaba0a3ffb255ab7"},
{file = "grpcio-1.74.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e912d3c993a29df6c627459af58975b2e5c897d93287939b9d5065f000249b5"},
{file = "grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31"},
{file = "grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4"},
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce"},
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3"},
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182"},
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d"},
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f"},
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4"},
{file = "grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b"},
{file = "grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11"},
{file = "grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8"},
{file = "grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6"},
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5"},
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49"},
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7"},
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3"},
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707"},
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b"},
{file = "grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c"},
{file = "grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc"},
{file = "grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89"},
{file = "grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01"},
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e"},
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91"},
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249"},
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362"},
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f"},
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20"},
{file = "grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa"},
{file = "grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24"},
{file = "grpcio-1.74.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:4bc5fca10aaf74779081e16c2bcc3d5ec643ffd528d9e7b1c9039000ead73bae"},
{file = "grpcio-1.74.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:6bab67d15ad617aff094c382c882e0177637da73cbc5532d52c07b4ee887a87b"},
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:655726919b75ab3c34cdad39da5c530ac6fa32696fb23119e36b64adcfca174a"},
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a2b06afe2e50ebfd46247ac3ba60cac523f54ec7792ae9ba6073c12daf26f0a"},
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f251c355167b2360537cf17bea2cf0197995e551ab9da6a0a59b3da5e8704f9"},
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8f7b5882fb50632ab1e48cb3122d6df55b9afabc265582808036b6e51b9fd6b7"},
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:834988b6c34515545b3edd13e902c1acdd9f2465d386ea5143fb558f153a7176"},
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22b834cef33429ca6cc28303c9c327ba9a3fafecbf62fae17e9a7b7163cc43ac"},
{file = "grpcio-1.74.0-cp39-cp39-win32.whl", hash = "sha256:7d95d71ff35291bab3f1c52f52f474c632db26ea12700c2ff0ea0532cb0b5854"},
{file = "grpcio-1.74.0-cp39-cp39-win_amd64.whl", hash = "sha256:ecde9ab49f58433abe02f9ed076c7b5be839cf0153883a6d23995937a82392fa"},
{file = "grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1"},
]
[package.extras]
protobuf = ["grpcio-tools (>=1.67.1)"]
protobuf = ["grpcio-tools (>=1.74.0)"]
[[package]]
name = "grpcio-status"
version = "1.67.1"
version = "1.71.2"
description = "Status proto mapping for gRPC"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd"},
{file = "grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11"},
{file = "grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3"},
{file = "grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50"},
]
[package.dependencies]
googleapis-common-protos = ">=1.5.5"
grpcio = ">=1.67.1"
grpcio = ">=1.71.2"
protobuf = ">=5.26.1,<6.0dev"
[[package]]
@@ -3551,25 +3525,6 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "inquirerpy"
version = "0.3.4"
description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"},
{file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"},
]
[package.dependencies]
pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
version = "0.7.0"
@@ -4558,39 +4513,42 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "litellm"
version = "1.80.11"
version = "1.77.7"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "<4.0,>=3.9"
python-versions = ">=3.8.1,<4.0, !=3.9.7"
groups = ["main"]
files = [
{file = "litellm-1.80.11-py3-none-any.whl", hash = "sha256:406283d66ead77dc7ff0e0b2559c80e9e497d8e7c2257efb1cb9210a20d09d54"},
{file = "litellm-1.80.11.tar.gz", hash = "sha256:c9fc63e7acb6360363238fe291bcff1488c59ff66020416d8376c0ee56414a19"},
]
files = []
develop = false
[package.dependencies]
aiohttp = ">=3.10"
click = "*"
fastuuid = ">=0.13.0"
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.23.0,<5.0.0"
openai = ">=2.8.0"
pydantic = ">=2.5.0,<3.0.0"
jinja2 = "^3.1.2"
jsonschema = "^4.22.0"
openai = ">=1.99.5"
pydantic = "^2.5.0"
python-dotenv = ">=0.2.0"
tiktoken = ">=0.7.0"
tokenizers = "*"
[package.extras]
caching = ["diskcache (>=5.6.1,<6.0.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.27)", "litellm-proxy-extras (==0.4.16)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.20)", "litellm-proxy-extras (==0.2.25)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"]
semantic-router = ["semantic-router ; python_version >= \"3.9\""]
utils = ["numpydoc"]
[package.source]
type = "git"
url = "https://github.com/BerriAI/litellm.git"
reference = "v1.77.7.dev9"
resolved_reference = "763d2f8ccdd8412dbe6d4ac0e136d9ac34dcd4c0"
[[package]]
name = "llvmlite"
version = "0.44.0"
@@ -4622,63 +4580,6 @@ files = [
{file = "llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4"},
]
[[package]]
name = "lmnr"
version = "0.7.24"
description = "Python SDK for Laminar"
optional = false
python-versions = "<4,>=3.10"
groups = ["main"]
files = [
{file = "lmnr-0.7.24-py3-none-any.whl", hash = "sha256:ad780d4a62ece897048811f3368639c240a9329ab31027da8c96545137a3a08a"},
{file = "lmnr-0.7.24.tar.gz", hash = "sha256:aa6973f46fc4ba95c9061c1feceb58afc02eb43c9376c21e32545371ff6123d7"},
]
[package.dependencies]
grpcio = ">=1"
httpx = ">=0.24.0"
opentelemetry-api = ">=1.33.0"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.0"
opentelemetry-exporter-otlp-proto-http = ">=1.33.0"
opentelemetry-instrumentation = ">=0.54b0"
opentelemetry-instrumentation-threading = ">=0.57b0"
opentelemetry-sdk = ">=1.33.0"
opentelemetry-semantic-conventions = ">=0.54b0"
opentelemetry-semantic-conventions-ai = ">=0.4.13"
orjson = ">=3.0.0"
packaging = ">=22.0"
pydantic = ">=2.0.3,<3.0.0"
python-dotenv = ">=1.0"
tenacity = ">=8.0"
tqdm = ">=4.0"
[package.extras]
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
claude-agent-sdk = ["lmnr-claude-code-proxy (>=0.1.0a5)"]
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)"]
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
milvus = ["opentelemetry-instrumentation-milvus (>=0.47.1)"]
mistralai = ["opentelemetry-instrumentation-mistralai (>=0.47.1)"]
ollama = ["opentelemetry-instrumentation-ollama (>=0.47.1)"]
pinecone = ["opentelemetry-instrumentation-pinecone (>=0.47.1)"]
qdrant = ["opentelemetry-instrumentation-qdrant (>=0.47.1)"]
replicate = ["opentelemetry-instrumentation-replicate (>=0.47.1)"]
sagemaker = ["opentelemetry-instrumentation-sagemaker (>=0.47.1)"]
together = ["opentelemetry-instrumentation-together (>=0.47.1)"]
transformers = ["opentelemetry-instrumentation-transformers (>=0.47.1)"]
vertexai = ["opentelemetry-instrumentation-vertexai (>=0.47.1)"]
watsonx = ["opentelemetry-instrumentation-watsonx (>=0.47.1)"]
weaviate = ["opentelemetry-instrumentation-weaviate (>=0.47.1)"]
[[package]]
name = "lxml"
version = "6.0.1"
@@ -5660,28 +5561,28 @@ pydantic = ">=2.9"
[[package]]
name = "openai"
version = "2.8.0"
version = "1.99.9"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main", "test"]
files = [
{file = "openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf"},
{file = "openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad"},
{file = "openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a"},
{file = "openai-1.99.9.tar.gz", hash = "sha256:f2082d155b1ad22e83247c3de3958eb4255b20ccf4a1de2e6681b6957b554e92"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
httpx = ">=0.23.0,<1"
jiter = ">=0.10.0,<1"
jiter = ">=0.4.0,<1"
pydantic = ">=1.9.0,<3"
sniffio = "*"
tqdm = ">4"
typing-extensions = ">=4.11,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
realtime = ["websockets (>=13,<16)"]
voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
@@ -5836,31 +5737,35 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.7.1"
version = "1.0.0a3"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.7.1-py3-none-any.whl", hash = "sha256:e5c57f1b73293d00a68b77f9d290f59d9e2217d9df844fb01c7d2f929c3417f4"},
{file = "openhands_agent_server-1.7.1.tar.gz", hash = "sha256:c82e1e6748ea3b4278ef2ee72f091dc37da6667c854b3aa3c0bc616086a82310"},
]
files = []
develop = false
[package.dependencies]
aiosqlite = ">=0.19"
alembic = ">=1.13"
docker = ">=7.1,<8"
fastapi = ">=0.104"
openhands-sdk = "*"
pydantic = ">=2"
sqlalchemy = ">=2"
uvicorn = ">=0.31.1"
websockets = ">=12"
wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5742+ee50f333b"
version = "0.59.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5877,7 +5782,6 @@ bashlex = "^0.18"
boto3 = "*"
browsergym-core = "0.13.3"
deprecated = "*"
deprecation = "^2.1.0"
dirhash = "*"
docker = "*"
fastapi = "*"
@@ -5896,15 +5800,14 @@ json-repair = "*"
jupyter_kernel_gateway = "*"
kubernetes = "^33.1.0"
libtmux = ">=0.46.2"
litellm = ">=1.74.3, !=1.64.4, !=1.67.*"
lmnr = "^0.7.20"
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
memory-profiler = "^0.61.0"
numpy = "*"
openai = "2.8.0"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = "1.7.1"
openhands-sdk = "1.7.1"
openhands-tools = "1.7.1"
openhands-agent-server = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/All-Hands-AI/agent-sdk.git", rev = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e", subdirectory = "openhands-tools"}
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5960,22 +5863,18 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.7.1"
version = "1.0.0a3"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.7.1-py3-none-any.whl", hash = "sha256:e097e34dfbd45f38225ae2ff4830702424bcf742bc197b5a811540a75265b135"},
{file = "openhands_sdk-1.7.1.tar.gz", hash = "sha256:e13d1fe8bf14dffd91e9080608072a989132c981cf9bfcd124fa4f7a68a13691"},
]
files = []
develop = false
[package.dependencies]
deprecation = ">=2.1.0"
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.80.10"
lmnr = ">=0.7.24"
litellm = ">=1.77.7.dev9"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
python-json-logger = ">=3.3.0"
@@ -5985,28 +5884,39 @@ websockets = ">=12"
[package.extras]
boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.7.1"
version = "1.0.0a3"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.7.1-py3-none-any.whl", hash = "sha256:e25815f24925e94fbd4d8c3fd9b2147a0556fde595bf4f80a7dbba1014ea3c86"},
{file = "openhands_tools-1.7.1.tar.gz", hash = "sha256:f3823f7bd302c78969c454730cf793eb63109ce2d986e78585989c53986cc966"},
]
files = []
develop = false
[package.dependencies]
bashlex = ">=0.18"
binaryornot = ">=0.4.4"
browser-use = ">=0.8.0"
browser-use = ">=0.7.7"
cachetools = "*"
func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
tom-swe = ">=1.0.3"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/agent-sdk.git"
reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
resolved_reference = "8d8134ca5a87cc3e90e3ff968327a7f4c961e22e"
subdirectory = "openhands-tools"
[[package]]
name = "openpyxl"
@@ -6078,62 +5988,6 @@ opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
typing-extensions = ">=4.6.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.36.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0-py3-none-any.whl", hash = "sha256:3d769f68e2267e7abe4527f70deb6f598f40be3ea34c6adc35789bea94a32902"},
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0.tar.gz", hash = "sha256:dd3637f72f774b9fc9608ab1ac479f8b44d09b6fb5b2f3df68a24ad1da7d356e"},
]
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.36.0"
opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
requests = ">=2.7,<3.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-instrumentation"
version = "0.57b0"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation-0.57b0-py3-none-any.whl", hash = "sha256:9109280f44882e07cec2850db28210b90600ae9110b42824d196de357cbddf7e"},
{file = "opentelemetry_instrumentation-0.57b0.tar.gz", hash = "sha256:f2a30135ba77cdea2b0e1df272f4163c154e978f57214795d72f40befd4fcf05"},
]
[package.dependencies]
opentelemetry-api = ">=1.4,<2.0"
opentelemetry-semantic-conventions = "0.57b0"
packaging = ">=18.0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-instrumentation-threading"
version = "0.57b0"
description = "Thread context propagation support for OpenTelemetry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_threading-0.57b0-py3-none-any.whl", hash = "sha256:adfd64857c8c78d6111cf80552311e1713bad64272dd81abdd61f07b892a161b"},
{file = "opentelemetry_instrumentation_threading-0.57b0.tar.gz", hash = "sha256:06fa4c98d6bfe4670e7532497670ac202db42afa647ff770aedce0e422421c6e"},
]
[package.dependencies]
opentelemetry-api = ">=1.12,<2.0"
opentelemetry-instrumentation = "0.57b0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-proto"
version = "1.36.0"
@@ -6182,115 +6036,6 @@ files = [
opentelemetry-api = "1.36.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions-ai"
version = "0.4.13"
description = "OpenTelemetry Semantic Conventions Extension for Large Language Models"
optional = false
python-versions = "<4,>=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5"},
{file = "opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036"},
]
[[package]]
name = "orjson"
version = "3.11.4"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"},
{file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"},
{file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"},
{file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"},
{file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"},
{file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"},
{file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"},
{file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"},
{file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"},
{file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"},
{file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"},
{file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"},
{file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"},
{file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"},
{file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"},
{file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"},
{file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"},
{file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"},
{file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"},
{file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"},
{file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"},
{file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"},
{file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"},
{file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"},
{file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"},
{file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"},
{file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"},
]
[[package]]
name = "packaging"
version = "25.0"
@@ -6507,21 +6252,6 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
name = "pfzy"
version = "0.3.4"
description = "Python port of the fzy fuzzy string matching algorithm"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"},
{file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"},
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
version = "1.31.5"
@@ -13323,31 +13053,6 @@ dev = ["tokenizers[testing]"]
docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"]
testing = ["black (==22.3)", "datasets", "numpy", "pytest", "pytest-asyncio", "requests", "ruff"]
[[package]]
name = "tom-swe"
version = "1.0.3"
description = "Theory of Mind modeling for Software Engineering assistants"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "tom_swe-1.0.3-py3-none-any.whl", hash = "sha256:7b1172b29eb5c8fb7f1975016e7b6a238511b9ac2a7a980bd400dcb4e29773f2"},
{file = "tom_swe-1.0.3.tar.gz", hash = "sha256:57c97d0104e563f15bd39edaf2aa6ac4c3e9444afd437fb92458700d22c6c0f5"},
]
[package.dependencies]
jinja2 = ">=3.0.0"
json-repair = ">=0.1.0"
litellm = ">=1.0.0"
pydantic = ">=2.0.0"
python-dotenv = ">=1.0.0"
tiktoken = ">=0.8.0"
tqdm = ">=4.65.0"
[package.extras]
dev = ["aiofiles (>=23.0.0)", "black (>=22.0.0)", "datasets (>=2.0.0)", "fastapi (>=0.104.0)", "httpx (>=0.25.0)", "huggingface-hub (>=0.0.0)", "isort (>=5.0.0)", "mypy (>=1.0.0)", "numpy (>=1.24.0)", "pandas (>=2.0.0)", "pre-commit (>=3.6.0)", "pytest (>=7.0.0)", "pytest-cov (>=6.2.1)", "rich (>=13.0.0)", "ruff (>=0.3.0)", "typing-extensions (>=4.0.0)", "uvicorn (>=0.24.0)"]
search = ["bm25s (>=0.2.0)", "pystemmer (>=2.2.0)"]
[[package]]
name = "toml"
version = "0.10.2"

View File

@@ -11,7 +11,7 @@ description = "Deploy OpenHands"
authors = [ "OpenHands" ]
license = "POLYFORM"
readme = "README.md"
repository = "https://github.com/OpenHands/OpenHands"
repository = "https://github.com/All-Hands-AI/OpenHands"
packages = [
{ include = "server" },
{ include = "storage" },

View File

@@ -34,15 +34,8 @@ from server.routes.integration.jira_dc import jira_dc_integration_router # noqa
from server.routes.integration.linear import linear_integration_router # noqa: E402
from server.routes.integration.slack import slack_router # noqa: E402
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
from server.routes.oauth_device import oauth_device_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
from server.sharing.shared_event_router import ( # noqa: E402
router as shared_event_router,
)
from openhands.server.app import app as base_app # noqa: E402
from openhands.server.listen_socket import sio # noqa: E402
@@ -67,13 +60,10 @@ base_app.mount('/internal/metrics', metrics_app())
base_app.include_router(readiness_router) # Add routes for readiness checks
base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
base_app.include_router(
billing_router
) # Add routes for credit management and Stripe payment integration
base_app.include_router(shared_conversation_router)
base_app.include_router(shared_event_router)
# Add GitHub integration router only if GITHUB_APP_CLIENT_ID is set
if GITHUB_APP_CLIENT_ID:
@@ -107,7 +97,6 @@ base_app.include_router(
event_webhook_router
) # Add routes for Events in nested runtimes
base_app.add_middleware(
CORSMiddleware,
allow_origins=PERMITTED_CORS_ORIGINS,

View File

@@ -30,16 +30,3 @@ JIRA_DC_CLIENT_SECRET = os.getenv('JIRA_DC_CLIENT_SECRET', '').strip()
JIRA_DC_BASE_URL = os.getenv('JIRA_DC_BASE_URL', '').strip()
JIRA_DC_ENABLE_OAUTH = os.getenv('JIRA_DC_ENABLE_OAUTH', '1') in ('1', 'true')
AUTH_URL = os.getenv('AUTH_URL', '').rstrip('/')
ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
'1',
'true',
't',
'yes',
'y',
'on',
)
BLOCKED_EMAIL_DOMAINS = [
domain.strip().lower()
for domain in os.getenv('BLOCKED_EMAIL_DOMAINS', '').split(',')
if domain.strip()
]

View File

@@ -1,56 +0,0 @@
from server.auth.constants import BLOCKED_EMAIL_DOMAINS
from openhands.core.logger import openhands_logger as logger
class DomainBlocker:
def __init__(self) -> None:
logger.debug('Initializing DomainBlocker')
self.blocked_domains: list[str] = BLOCKED_EMAIL_DOMAINS
if self.blocked_domains:
logger.info(
f'Successfully loaded {len(self.blocked_domains)} blocked email domains: {self.blocked_domains}'
)
def is_active(self) -> bool:
"""Check if domain blocking is enabled"""
return bool(self.blocked_domains)
def _extract_domain(self, email: str) -> str | None:
"""Extract and normalize email domain from email address"""
if not email:
return None
try:
# Extract domain part after @
if '@' not in email:
return None
domain = email.split('@')[1].strip().lower()
return domain if domain else None
except Exception:
logger.debug(f'Error extracting domain from email: {email}', exc_info=True)
return None
def is_domain_blocked(self, email: str) -> bool:
"""Check if email domain is blocked"""
if not self.is_active():
return False
if not email:
logger.debug('No email provided for domain check')
return False
domain = self._extract_domain(email)
if not domain:
logger.debug(f'Could not extract domain from email: {email}')
return False
is_blocked = domain in self.blocked_domains
if is_blocked:
logger.warning(f'Email domain {domain} is blocked for email: {email}')
else:
logger.debug(f'Email domain {domain} is not blocked')
return is_blocked
domain_blocker = DomainBlocker()

View File

@@ -1,109 +0,0 @@
"""Email validation utilities for preventing duplicate signups with + modifier."""
import re
def extract_base_email(email: str) -> str | None:
"""Extract base email from an email address.
For emails with + modifier, extracts the base email (local part before + and @, plus domain).
For emails without + modifier, returns the email as-is.
Examples:
extract_base_email("joe+test@example.com") -> "joe@example.com"
extract_base_email("joe@example.com") -> "joe@example.com"
extract_base_email("joe+openhands+test@example.com") -> "joe@example.com"
Args:
email: The email address to process
Returns:
The base email address, or None if email format is invalid
"""
if not email or '@' not in email:
return None
try:
local_part, domain = email.rsplit('@', 1)
# Extract the part before + if it exists
base_local = local_part.split('+', 1)[0]
return f'{base_local}@{domain}'
except (ValueError, AttributeError):
return None
def has_plus_modifier(email: str) -> bool:
"""Check if an email address contains a + modifier.
Args:
email: The email address to check
Returns:
True if email contains + before @, False otherwise
"""
if not email or '@' not in email:
return False
try:
local_part, _ = email.rsplit('@', 1)
return '+' in local_part
except (ValueError, AttributeError):
return False
def matches_base_email(email: str, base_email: str) -> bool:
"""Check if an email matches a base email pattern.
An email matches if:
- It is exactly the base email (e.g., joe@example.com)
- It has the same base local part and domain, with or without + modifier
(e.g., joe+test@example.com matches base joe@example.com)
Args:
email: The email address to check
base_email: The base email to match against
Returns:
True if email matches the base pattern, False otherwise
"""
if not email or not base_email:
return False
# Extract base from both emails for comparison
email_base = extract_base_email(email)
base_email_normalized = extract_base_email(base_email)
if not email_base or not base_email_normalized:
return False
# Emails match if they have the same base
return email_base.lower() == base_email_normalized.lower()
def get_base_email_regex_pattern(base_email: str) -> re.Pattern | None:
"""Generate a regex pattern to match emails with the same base.
For base_email "joe@example.com", the pattern will match:
- joe@example.com
- joe+anything@example.com
Args:
base_email: The base email address
Returns:
A compiled regex pattern, or None if base_email is invalid
"""
base = extract_base_email(base_email)
if not base:
return None
try:
local_part, domain = base.rsplit('@', 1)
# Escape special regex characters in local part and domain
escaped_local = re.escape(local_part)
escaped_domain = re.escape(domain)
# Pattern: joe@example.com OR joe+anything@example.com
pattern = rf'^{escaped_local}(\+[^@\s]+)?@{escaped_domain}$'
return re.compile(pattern, re.IGNORECASE)
except (ValueError, AttributeError):
return None

View File

@@ -13,7 +13,6 @@ from server.auth.auth_error import (
ExpiredError,
NoCredentialsError,
)
from server.auth.domain_blocker import domain_blocker
from server.auth.token_manager import TokenManager
from server.config import get_config
from server.logger import logger
@@ -32,7 +31,7 @@ from openhands.integrations.provider import (
)
from openhands.server.settings import Settings
from openhands.server.user_auth.user_auth import AuthType, UserAuth
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.data_models.user_secrets import UserSecrets
from openhands.storage.settings.settings_store import SettingsStore
token_manager = TokenManager()
@@ -53,7 +52,7 @@ class SaasUserAuth(UserAuth):
settings_store: SaasSettingsStore | None = None
secrets_store: SaasSecretsStore | None = None
_settings: Settings | None = None
_secrets: Secrets | None = None
_user_secrets: UserSecrets | None = None
accepted_tos: bool | None = None
auth_type: AuthType = AuthType.COOKIE
@@ -120,13 +119,13 @@ class SaasUserAuth(UserAuth):
self.secrets_store = secrets_store
return secrets_store
async def get_secrets(self):
user_secrets = self._secrets
async def get_user_secrets(self):
user_secrets = self._user_secrets
if user_secrets:
return user_secrets
secrets_store = await self.get_secrets_store()
user_secrets = await secrets_store.load()
self._secrets = user_secrets
self._user_secrets = user_secrets
return user_secrets
async def get_access_token(self) -> SecretStr | None:
@@ -149,15 +148,13 @@ class SaasUserAuth(UserAuth):
if not access_token:
raise AuthError()
user_secrets = await self.get_secrets()
user_secrets = await self.get_user_secrets()
try:
# TODO: I think we can do this in a single request if we refactor
with session_maker() as session:
tokens = (
session.query(AuthTokens)
.where(AuthTokens.keycloak_user_id == self.user_id)
.all()
tokens = session.query(AuthTokens).where(
AuthTokens.keycloak_user_id == self.user_id
)
for token in tokens:
@@ -206,15 +203,6 @@ class SaasUserAuth(UserAuth):
self.settings_store = settings_store
return settings_store
async def get_mcp_api_key(self) -> str:
api_key_store = ApiKeyStore.get_instance()
mcp_api_key = api_key_store.retrieve_mcp_api_key(self.user_id)
if not mcp_api_key:
mcp_api_key = api_key_store.create_api_key(
self.user_id, 'MCP_API_KEY', None
)
return mcp_api_key
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
logger.debug('saas_user_auth_get_instance')
@@ -255,12 +243,7 @@ def get_api_key_from_header(request: Request):
# This is a temp hack
# Streamable HTTP MCP Client works via redirect requests, but drops the Authorization header for reason
# We include `X-Session-API-Key` header by default due to nested runtimes, so it used as a drop in replacement here
session_api_key = request.headers.get('X-Session-API-Key')
if session_api_key:
return session_api_key
# Fallback to X-Access-Token header as an additional option
return request.headers.get('X-Access-Token')
return request.headers.get('X-Session-API-Key')
async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
@@ -315,16 +298,6 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
user_id = access_token_payload['sub']
email = access_token_payload['email']
email_verified = access_token_payload['email_verified']
# Check if email domain is blocked
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for existing user with email: {email}'
)
raise AuthError(
'Access denied: Your email domain is not allowed to access this service'
)
logger.debug('saas_user_auth_from_signed_token:return')
return SaasUserAuth(

View File

@@ -1,4 +1,3 @@
import asyncio
import base64
import hashlib
import json
@@ -26,11 +25,6 @@ from server.auth.constants import (
KEYCLOAK_SERVER_URL,
KEYCLOAK_SERVER_URL_EXT,
)
from server.auth.email_validation import (
extract_base_email,
get_base_email_regex_pattern,
matches_base_email,
)
from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
from server.config import get_config
from server.logger import logger
@@ -43,7 +37,6 @@ from storage.offline_token_store import OfflineTokenStore
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
from openhands.integrations.service_types import ProviderType
from openhands.utils.http_session import httpx_verify_option
def _before_sleep_callback(retry_state: RetryCallState) -> None:
@@ -198,7 +191,7 @@ class TokenManager:
access_token: str,
idp: ProviderType,
) -> dict[str, str | int]:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL
url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token'
headers = {
@@ -300,12 +293,11 @@ class TokenManager:
refresh_token_expires_at: int,
) -> dict[str, str | int] | None:
current_time = int(time.time())
# expire access_token four hours before actual expiration
# This ensures tokens are refreshed on resume to have at least 4 hours validity
# expire access_token ten minutes before actual expiration
access_expired = (
False
if access_token_expires_at == 0
else access_token_expires_at < current_time + 14400
else access_token_expires_at < current_time + 600
)
refresh_expired = (
False
@@ -357,7 +349,7 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitHub token')
@@ -383,7 +375,7 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitLab token')
@@ -411,7 +403,7 @@ class TokenManager:
'refresh_token': refresh_token,
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(url, data=data, headers=headers)
response.raise_for_status()
logger.info('Successfully refreshed Bitbucket token')
@@ -515,183 +507,6 @@ class TokenManager:
logger.info(f'Got user ID {keycloak_user_id} from email: {email}')
return keycloak_user_id
async def _query_users_by_wildcard_pattern(
self, local_part: str, domain: str
) -> dict[str, dict]:
"""Query Keycloak for users matching a wildcard email pattern.
Tries multiple query methods to find users with emails matching
the pattern {local_part}*@{domain}. This catches the base email
and all + modifier variants.
Args:
local_part: The local part of the email (before @)
domain: The domain part of the email (after @)
Returns:
Dictionary mapping user IDs to user objects
"""
keycloak_admin = get_keycloak_admin(self.external)
all_users = {}
# Query for users with emails matching the base pattern using wildcard
# Pattern: {local_part}*@{domain} - catches base email and all + variants
# This may also catch unintended matches (e.g., joesmith@example.com), but
# they will be filtered out by the regex pattern check later
# Use 'search' parameter for Keycloak 26+ (better wildcard support)
wildcard_queries = [
{'search': f'{local_part}*@{domain}'}, # Try 'search' parameter first
{'q': f'email:{local_part}*@{domain}'}, # Fallback to 'q' parameter
]
for query_params in wildcard_queries:
try:
users = await keycloak_admin.a_get_users(query_params)
for user in users:
all_users[user.get('id')] = user
break # Success, no need to try fallback
except Exception as e:
logger.debug(
f'Wildcard query failed with {list(query_params.keys())[0]}: {e}'
)
continue # Try next query method
return all_users
def _find_duplicate_in_users(
self, users: dict[str, dict], base_email: str, current_user_id: str
) -> bool:
"""Check if any user in the provided list matches the base email pattern.
Filters users to find duplicates that match the base email pattern,
excluding the current user.
Args:
users: Dictionary mapping user IDs to user objects
base_email: The base email to match against
current_user_id: The user ID to exclude from the check
Returns:
True if a duplicate is found, False otherwise
"""
regex_pattern = get_base_email_regex_pattern(base_email)
if not regex_pattern:
logger.warning(
f'Could not generate regex pattern for base email: {base_email}'
)
# Fallback to simple matching
for user in users.values():
user_email = user.get('email', '').lower()
if (
user_email
and user.get('id') != current_user_id
and matches_base_email(user_email, base_email)
):
logger.info(
f'Found duplicate email: {user_email} matches base {base_email}'
)
return True
else:
for user in users.values():
user_email = user.get('email', '')
if (
user_email
and user.get('id') != current_user_id
and regex_pattern.match(user_email)
):
logger.info(
f'Found duplicate email: {user_email} matches base {base_email}'
)
return True
return False
@retry(
stop=stop_after_attempt(2),
retry=retry_if_exception_type(KeycloakConnectionError),
before_sleep=_before_sleep_callback,
)
async def check_duplicate_base_email(
self, email: str, current_user_id: str
) -> bool:
"""Check if a user with the same base email already exists.
This method checks for duplicate signups using email + modifier.
It checks if any user exists with the same base email, regardless of whether
the provided email has a + modifier or not.
Examples:
- If email is "joe+test@example.com", it checks for existing users with
base email "joe@example.com" (e.g., "joe@example.com", "joe+1@example.com")
- If email is "joe@example.com", it checks for existing users with
base email "joe@example.com" (e.g., "joe+1@example.com", "joe+test@example.com")
Args:
email: The email address to check (may or may not contain + modifier)
current_user_id: The user ID of the current user (to exclude from check)
Returns:
True if a duplicate is found (excluding current user), False otherwise
"""
if not email:
return False
base_email = extract_base_email(email)
if not base_email:
logger.warning(f'Could not extract base email from: {email}')
return False
try:
local_part, domain = base_email.rsplit('@', 1)
users = await self._query_users_by_wildcard_pattern(local_part, domain)
return self._find_duplicate_in_users(users, base_email, current_user_id)
except KeycloakConnectionError:
logger.exception('KeycloakConnectionError when checking duplicate email')
raise
except Exception as e:
logger.exception(f'Unexpected error checking duplicate email: {e}')
# On any error, allow signup to proceed (fail open)
return False
@retry(
stop=stop_after_attempt(2),
retry=retry_if_exception_type(KeycloakConnectionError),
before_sleep=_before_sleep_callback,
)
async def delete_keycloak_user(self, user_id: str) -> bool:
"""Delete a user from Keycloak.
This method is used to clean up user accounts that were created
but should not exist (e.g., duplicate email signups).
Args:
user_id: The Keycloak user ID to delete
Returns:
True if deletion was successful, False otherwise
"""
try:
keycloak_admin = get_keycloak_admin(self.external)
# Use the sync method (python-keycloak doesn't have async delete_user)
# Run it in a thread executor to avoid blocking the event loop
await asyncio.to_thread(keycloak_admin.delete_user, user_id)
logger.info(f'Successfully deleted Keycloak user {user_id}')
return True
except KeycloakConnectionError:
logger.exception(f'KeycloakConnectionError when deleting user {user_id}')
raise
except KeycloakError as e:
# User might not exist or already deleted
logger.warning(
f'KeycloakError when deleting user {user_id}: {e}',
extra={'user_id': user_id, 'error': str(e)},
)
return False
except Exception as e:
logger.exception(f'Unexpected error deleting Keycloak user {user_id}: {e}')
return False
async def get_user_info_from_user_id(self, user_id: str) -> dict | None:
keycloak_admin = get_keycloak_admin(self.external)
user = await keycloak_admin.a_get_user(user_id)
@@ -710,49 +525,6 @@ class TokenManager:
github_id = github_ids[0]
return github_id
async def disable_keycloak_user(
self, user_id: str, email: str | None = None
) -> None:
"""Disable a Keycloak user account.
Args:
user_id: The Keycloak user ID to disable
email: Optional email address for logging purposes
This method attempts to disable the user account but will not raise exceptions.
Errors are logged but do not prevent the operation from completing.
"""
try:
keycloak_admin = get_keycloak_admin(self.external)
# Get current user to preserve other fields
user = await keycloak_admin.a_get_user(user_id)
if user:
# Update user with enabled=False to disable the account
await keycloak_admin.a_update_user(
user_id=user_id,
payload={
'enabled': False,
'username': user.get('username', ''),
'email': user.get('email', ''),
'emailVerified': user.get('emailVerified', False),
},
)
email_str = f', email: {email}' if email else ''
logger.info(
f'Disabled Keycloak account for user_id: {user_id}{email_str}'
)
else:
logger.warning(
f'User not found in Keycloak when attempting to disable: {user_id}'
)
except Exception as e:
# Log error but don't raise - the caller should handle the blocking regardless
email_str = f', email: {email}' if email else ''
logger.error(
f'Failed to disable Keycloak account for user_id: {user_id}{email_str}: {str(e)}',
exc_info=True,
)
def store_org_token(self, installation_id: int, installation_token: str):
"""Store a GitHub App installation token.

View File

@@ -25,7 +25,6 @@ USER_SETTINGS_VERSION_TO_MODEL = {
2: 'claude-3-7-sonnet-20250219',
3: 'claude-sonnet-4-20250514',
4: 'claude-sonnet-4-20250514',
5: 'claude-opus-4-5-20251101',
}
LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL')
@@ -51,7 +50,7 @@ SUBSCRIPTION_PRICE_DATA = {
},
}
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '20'))
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')

View File

@@ -0,0 +1,331 @@
from __future__ import annotations
import time
from dataclasses import dataclass, field
import socketio
from server.clustered_conversation_manager import ClusteredConversationManager
from server.saas_nested_conversation_manager import SaasNestedConversationManager
from openhands.core.config import LLMConfig, OpenHandsConfig
from openhands.events.action import MessageAction
from openhands.server.config.server_config import ServerConfig
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.data_models.agent_loop_info import AgentLoopInfo
from openhands.server.monitoring import MonitoringListener
from openhands.server.session.conversation import ServerConversation
from openhands.storage.data_models.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import wait_all
_LEGACY_ENTRY_TIMEOUT_SECONDS = 3600
@dataclass
class LegacyCacheEntry:
"""Cache entry for legacy mode status."""
is_legacy: bool
timestamp: float
@dataclass
class LegacyConversationManager(ConversationManager):
"""
Conversation manager for use while migrating - since existing conversations are not nested!
Separate class from SaasNestedConversationManager so it can be easliy removed in a few weeks.
(As of 2025-07-23)
"""
sio: socketio.AsyncServer
config: OpenHandsConfig
server_config: ServerConfig
file_store: FileStore
conversation_manager: SaasNestedConversationManager
legacy_conversation_manager: ClusteredConversationManager
_legacy_cache: dict[str, LegacyCacheEntry] = field(default_factory=dict)
async def __aenter__(self):
await wait_all(
[
self.conversation_manager.__aenter__(),
self.legacy_conversation_manager.__aenter__(),
]
)
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await wait_all(
[
self.conversation_manager.__aexit__(exc_type, exc_value, traceback),
self.legacy_conversation_manager.__aexit__(
exc_type, exc_value, traceback
),
]
)
async def request_llm_completion(
self,
sid: str,
service_id: str,
llm_config: LLMConfig,
messages: list[dict[str, str]],
) -> str:
session = self.get_agent_session(sid)
llm_registry = session.llm_registry
return llm_registry.request_extraneous_completion(
service_id, llm_config, messages
)
async def attach_to_conversation(
self, sid: str, user_id: str | None = None
) -> ServerConversation | None:
if await self.should_start_in_legacy_mode(sid):
return await self.legacy_conversation_manager.attach_to_conversation(
sid, user_id
)
return await self.conversation_manager.attach_to_conversation(sid, user_id)
async def detach_from_conversation(self, conversation: ServerConversation):
if await self.should_start_in_legacy_mode(conversation.sid):
return await self.legacy_conversation_manager.detach_from_conversation(
conversation
)
return await self.conversation_manager.detach_from_conversation(conversation)
async def join_conversation(
self,
sid: str,
connection_id: str,
settings: Settings,
user_id: str | None,
) -> AgentLoopInfo:
if await self.should_start_in_legacy_mode(sid):
return await self.legacy_conversation_manager.join_conversation(
sid, connection_id, settings, user_id
)
return await self.conversation_manager.join_conversation(
sid, connection_id, settings, user_id
)
def get_agent_session(self, sid: str):
session = self.legacy_conversation_manager.get_agent_session(sid)
if session is None:
session = self.conversation_manager.get_agent_session(sid)
return session
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
if filter_to_sids and len(filter_to_sids) == 1:
sid = next(iter(filter_to_sids))
if await self.should_start_in_legacy_mode(sid):
return await self.legacy_conversation_manager.get_running_agent_loops(
user_id, filter_to_sids
)
return await self.conversation_manager.get_running_agent_loops(
user_id, filter_to_sids
)
# Get all running agent loops from both managers
agent_loops, legacy_agent_loops = await wait_all(
[
self.conversation_manager.get_running_agent_loops(
user_id, filter_to_sids
),
self.legacy_conversation_manager.get_running_agent_loops(
user_id, filter_to_sids
),
]
)
# Combine the results
result = set()
for sid in legacy_agent_loops:
if await self.should_start_in_legacy_mode(sid):
result.add(sid)
for sid in agent_loops:
if not await self.should_start_in_legacy_mode(sid):
result.add(sid)
return result
async def is_agent_loop_running(self, sid: str) -> bool:
return bool(await self.get_running_agent_loops(filter_to_sids={sid}))
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
if filter_to_sids and len(filter_to_sids) == 1:
sid = next(iter(filter_to_sids))
if await self.should_start_in_legacy_mode(sid):
return await self.legacy_conversation_manager.get_connections(
user_id, filter_to_sids
)
return await self.conversation_manager.get_connections(
user_id, filter_to_sids
)
agent_loops, legacy_agent_loops = await wait_all(
[
self.conversation_manager.get_connections(user_id, filter_to_sids),
self.legacy_conversation_manager.get_connections(
user_id, filter_to_sids
),
]
)
legacy_agent_loops.update(agent_loops)
return legacy_agent_loops
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str, # type: ignore[override]
initial_user_msg: MessageAction | None = None,
replay_json: str | None = None,
) -> AgentLoopInfo:
if await self.should_start_in_legacy_mode(sid):
return await self.legacy_conversation_manager.maybe_start_agent_loop(
sid, settings, user_id, initial_user_msg, replay_json
)
return await self.conversation_manager.maybe_start_agent_loop(
sid, settings, user_id, initial_user_msg, replay_json
)
async def send_to_event_stream(self, connection_id: str, data: dict):
return await self.legacy_conversation_manager.send_to_event_stream(
connection_id, data
)
async def send_event_to_conversation(self, sid: str, data: dict):
if await self.should_start_in_legacy_mode(sid):
await self.legacy_conversation_manager.send_event_to_conversation(sid, data)
await self.conversation_manager.send_event_to_conversation(sid, data)
async def disconnect_from_session(self, connection_id: str):
return await self.legacy_conversation_manager.disconnect_from_session(
connection_id
)
async def close_session(self, sid: str):
if await self.should_start_in_legacy_mode(sid):
await self.legacy_conversation_manager.close_session(sid)
await self.conversation_manager.close_session(sid)
async def get_agent_loop_info(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> list[AgentLoopInfo]:
if filter_to_sids and len(filter_to_sids) == 1:
sid = next(iter(filter_to_sids))
if await self.should_start_in_legacy_mode(sid):
return await self.legacy_conversation_manager.get_agent_loop_info(
user_id, filter_to_sids
)
return await self.conversation_manager.get_agent_loop_info(
user_id, filter_to_sids
)
agent_loops, legacy_agent_loops = await wait_all(
[
self.conversation_manager.get_agent_loop_info(user_id, filter_to_sids),
self.legacy_conversation_manager.get_agent_loop_info(
user_id, filter_to_sids
),
]
)
# Combine results
result = []
legacy_sids = set()
# Add legacy agent loops
for agent_loop in legacy_agent_loops:
if await self.should_start_in_legacy_mode(agent_loop.conversation_id):
result.append(agent_loop)
legacy_sids.add(agent_loop.conversation_id)
# Add non-legacy agent loops
for agent_loop in agent_loops:
if (
agent_loop.conversation_id not in legacy_sids
and not await self.should_start_in_legacy_mode(
agent_loop.conversation_id
)
):
result.append(agent_loop)
return result
def _cleanup_expired_cache_entries(self):
"""Remove expired entries from the local cache."""
current_time = time.time()
expired_keys = [
key
for key, entry in self._legacy_cache.items()
if current_time - entry.timestamp > _LEGACY_ENTRY_TIMEOUT_SECONDS
]
for key in expired_keys:
del self._legacy_cache[key]
async def should_start_in_legacy_mode(self, conversation_id: str) -> bool:
"""
Check if a conversation should run in legacy mode by directly checking the runtime.
The /list method does not include stopped conversations even though the PVC for these
may not yet have been deleted, so we need to check /sessions/{session_id} directly.
"""
# Clean up expired entries periodically
self._cleanup_expired_cache_entries()
# First check the local cache
if conversation_id in self._legacy_cache:
cached_entry = self._legacy_cache[conversation_id]
# Check if the cached value is still valid
if time.time() - cached_entry.timestamp <= _LEGACY_ENTRY_TIMEOUT_SECONDS:
return cached_entry.is_legacy
# If not in cache or expired, check the runtime directly
runtime = await self.conversation_manager._get_runtime(conversation_id)
is_legacy = self.is_legacy_runtime(runtime)
# Cache the result with current timestamp
self._legacy_cache[conversation_id] = LegacyCacheEntry(is_legacy, time.time())
return is_legacy
def is_legacy_runtime(self, runtime: dict | None) -> bool:
"""
Determine if a runtime is a legacy runtime based on its command.
Args:
runtime: The runtime dictionary or None if not found
Returns:
bool: True if this is a legacy runtime, False otherwise
"""
if runtime is None:
return False
return 'openhands.server' not in runtime['command']
@classmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: OpenHandsConfig,
file_store: FileStore,
server_config: ServerConfig,
monitoring_listener: MonitoringListener,
) -> ConversationManager:
return LegacyConversationManager(
sio=sio,
config=config,
server_config=server_config,
file_store=file_store,
conversation_manager=SaasNestedConversationManager.get_instance(
sio, config, file_store, server_config, monitoring_listener
),
legacy_conversation_manager=ClusteredConversationManager.get_instance(
sio, config, file_store, server_config, monitoring_listener
),
)

View File

@@ -152,23 +152,17 @@ class SetAuthCookieMiddleware:
return False
path = request.url.path
ignore_paths = (
is_api_that_should_attach = path.startswith('/api') and path not in (
'/api/options/config',
'/api/keycloak/callback',
'/api/billing/success',
'/api/billing/cancel',
'/api/billing/customer-setup-success',
'/api/billing/stripe-webhook',
'/api/email/resend',
'/oauth/device/authorize',
'/oauth/device/token',
)
if path in ignore_paths:
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp
return is_api_that_should_attach or is_mcp
async def _logout(self, request: Request):
# Log out of keycloak - this prevents issues where you did not log in with the idp you believe you used

View File

@@ -3,45 +3,43 @@ from datetime import UTC, datetime
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from server.config import get_config
from server.constants import LITE_LLM_API_KEY, LITE_LLM_API_URL
from storage.api_key_store import ApiKeyStore
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.http_session import httpx_verify_option
# Helper functions for BYOR API key management
async def get_byor_key_from_db(user_id: str) -> str | None:
"""Get the BYOR key from the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
user_db_settings = await call_sync_from_async(
settings_store.get_user_settings_by_keycloak_id, user_id
)
if user_db_settings and user_db_settings.llm_api_key_for_byor:
return user_db_settings.llm_api_key_for_byor
return None
def _get_byor_key():
with session_maker() as session:
user_db_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if user_db_settings and user_db_settings.llm_api_key_for_byor:
return user_db_settings.llm_api_key_for_byor
return None
return await call_sync_from_async(_get_byor_key)
async def store_byor_key_in_db(user_id: str, key: str) -> None:
"""Store the BYOR key in the database for a user."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
def _update_user_settings():
with session_maker() as session:
user_db_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
user_db_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if user_db_settings:
user_db_settings.llm_api_key_for_byor = key
@@ -69,10 +67,9 @@ async def generate_byor_key(user_id: str) -> str | None:
try:
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={
'x-goog-api-key': LITE_LLM_API_KEY,
},
}
) as client:
response = await client.post(
f'{LITE_LLM_API_URL}/key/generate',
@@ -122,10 +119,9 @@ async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool:
try:
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={
'x-goog-api-key': LITE_LLM_API_KEY,
},
}
) as client:
# Delete the key directly using the key value
delete_url = f'{LITE_LLM_API_URL}/key/delete'

View File

@@ -12,17 +12,14 @@ from server.auth.constants import (
KEYCLOAK_CLIENT_ID,
KEYCLOAK_REALM_NAME,
KEYCLOAK_SERVER_URL_EXT,
ROLE_CHECK_ENABLED,
)
from server.auth.domain_blocker import domain_blocker
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from server.config import get_config, sign_token
from server.config import sign_token
from server.constants import IS_FEATURE_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
@@ -134,88 +131,13 @@ async def keycloak_callback(
user_info = await token_manager.get_user_info(keycloak_access_token)
logger.debug(f'user_info: {user_info}')
if ROLE_CHECK_ENABLED and 'roles' not in user_info:
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'Missing required role'},
)
if 'sub' not in user_info or 'preferred_username' not in user_info:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={'error': 'Missing user ID or username in response'},
)
email = user_info.get('email')
user_id = user_info['sub']
# Check if email domain is blocked
email = user_info.get('email')
if email and domain_blocker.is_active() and domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
)
# Disable the Keycloak account
await token_manager.disable_keycloak_user(user_id, email)
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
'error': 'Access denied: Your email domain is not allowed to access this service'
},
)
# Check for duplicate email with + modifier
if email:
try:
has_duplicate = await token_manager.check_duplicate_base_email(
email, user_id
)
if has_duplicate:
logger.warning(
f'Blocked signup attempt for email {email} - duplicate base email found',
extra={'user_id': user_id, 'email': email},
)
# Delete the Keycloak user that was automatically created during OAuth
# This prevents orphaned accounts in Keycloak
# The delete_keycloak_user method already handles all errors internally
deletion_success = await token_manager.delete_keycloak_user(user_id)
if deletion_success:
logger.info(
f'Deleted Keycloak user {user_id} after detecting duplicate email {email}'
)
else:
logger.warning(
f'Failed to delete Keycloak user {user_id} after detecting duplicate email {email}. '
f'User may need to be manually cleaned up.'
)
# Redirect to home page with query parameter indicating the issue
home_url = f'{request.base_url}?duplicated_email=true'
return RedirectResponse(home_url, status_code=302)
except Exception as e:
# Log error but allow signup to proceed (fail open)
logger.error(
f'Error checking duplicate email for {email}: {e}',
extra={'user_id': user_id, 'email': email},
)
# Check email verification status
email_verified = user_info.get('email_verified', False)
if not email_verified:
# Send verification email
# Import locally to avoid circular import with email.py
from server.routes.email import verify_email
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
redirect_url = (
f'{request.base_url}?email_verification_required=true&user_id={user_id}'
)
response = RedirectResponse(redirect_url, status_code=302)
return response
# default to github IDP for now.
# TODO: remove default once Keycloak is updated universally with the new attribute.
idp: str = user_info.get('identity_provider', ProviderType.GITHUB.value)
@@ -290,14 +212,16 @@ async def keycloak_callback(
f'&state={state}'
)
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
user_settings = settings_store.get_user_settings_by_keycloak_id(user_id)
has_accepted_tos = (
user_settings is not None and user_settings.accepted_tos is not None
)
has_accepted_tos = False
with session_maker() as session:
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
has_accepted_tos = (
user_settings is not None and user_settings.accepted_tos is not None
)
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:

View File

@@ -11,7 +11,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, RedirectResponse
from integrations import stripe_service
from pydantic import BaseModel
from server.config import get_config
from server.constants import (
LITE_LLM_API_KEY,
LITE_LLM_API_URL,
@@ -23,47 +22,15 @@ from server.constants import (
from server.logger import logger
from storage.billing_session import BillingSession
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from storage.subscription_access import SubscriptionAccess
from storage.user_settings import UserSettings
from openhands.server.user_auth import get_user_id
from openhands.utils.http_session import httpx_verify_option
stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
# TODO: Add a new app_mode named "ON_PREM" to support self-hosted customers instead of doing this
# and members should comment out the "validate_saas_environment" function if they are developing and testing locally.
def is_all_hands_saas_environment(request: Request) -> bool:
"""Check if the current domain is an All Hands SaaS environment.
Args:
request: FastAPI Request object
Returns:
True if the current domain contains "all-hands.dev" or "openhands.dev" postfix
"""
hostname = request.url.hostname or ''
return hostname.endswith('all-hands.dev') or hostname.endswith('openhands.dev')
def validate_saas_environment(request: Request) -> None:
"""Validate that the request is coming from an All Hands SaaS environment.
Args:
request: FastAPI Request object
Raises:
HTTPException: If the request is not from an All Hands SaaS environment
"""
if not is_all_hands_saas_environment(request):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Checkout sessions are only available for All Hands SaaS environments',
)
class BillingSessionType(Enum):
DIRECT_PAYMENT = 'DIRECT_PAYMENT'
MONTHLY_SUBSCRIPTION = 'MONTHLY_SUBSCRIPTION'
@@ -111,24 +78,10 @@ def calculate_credits(user_info: LiteLlmUserInfo) -> float:
async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse:
if not stripe_service.STRIPE_API_KEY:
return GetCreditsResponse()
try:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
user_json = await _get_litellm_user(client, user_id)
credits = calculate_credits(user_json['user_info'])
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
except httpx.HTTPStatusError as e:
logger.error(
f'litellm_get_user_failed: {type(e).__name__}: {e}',
extra={
'user_id': user_id,
'status_code': e.response.status_code,
},
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve credit balance from billing service',
)
async with httpx.AsyncClient() as client:
user_json = await _get_litellm_user(client, user_id)
credits = calculate_credits(user_json['user_info'])
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
# Endpoint to retrieve user's current subscription access
@@ -243,8 +196,6 @@ async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONRespon
async def create_customer_setup_session(
request: Request, user_id: str = Depends(get_user_id)
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
@@ -263,8 +214,6 @@ async def create_checkout_session(
request: Request,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
customer_id = await stripe_service.find_or_create_customer(user_id)
checkout_session = await stripe.checkout.Session.create_async(
customer=customer_id,
@@ -319,8 +268,6 @@ async def create_subscription_checkout_session(
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
user_id: str = Depends(get_user_id),
) -> CreateBillingSessionResponse:
validate_saas_environment(request)
# Prevent duplicate subscriptions for the same user
with session_maker() as session:
now = datetime.now(UTC)
@@ -396,8 +343,6 @@ async def create_subscription_checkout_session_via_get(
user_id: str = Depends(get_user_id),
) -> RedirectResponse:
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
validate_saas_environment(request)
response = await create_subscription_checkout_session(
request, billing_session_type, user_id
)
@@ -445,7 +390,7 @@ async def success_callback(session_id: str, request: Request):
)
raise HTTPException(status.HTTP_400_BAD_REQUEST)
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
# Update max budget in litellm
user_json = await _get_litellm_user(client, billing_session.user_id)
amount_subtotal = stripe_session.amount_subtotal or 0
@@ -633,14 +578,11 @@ async def stripe_webhook(request: Request) -> JSONResponse:
def reset_user_to_free_tier_settings(user_id: str) -> None:
"""Reset user settings to free tier defaults when subscription ends."""
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
)
with session_maker() as session:
user_settings = settings_store.get_user_settings_by_keycloak_id(
user_id, session
user_settings = (
session.query(UserSettings)
.filter(UserSettings.keycloak_user_id == user_id)
.first()
)
if user_settings:

View File

@@ -7,7 +7,6 @@ from server.auth.constants import KEYCLOAK_CLIENT_ID
from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -29,11 +28,6 @@ class EmailUpdate(BaseModel):
return v
class ResendEmailVerificationRequest(BaseModel):
user_id: str | None = None
is_auth_flow: bool = False
@api_router.post('')
async def update_email(
email_data: EmailUpdate, request: Request, user_id: str = Depends(get_user_id)
@@ -80,7 +74,7 @@ async def update_email(
accepted_tos=user_auth.accepted_tos,
)
await verify_email(request=request, user_id=user_id)
await _verify_email(request=request, user_id=user_id)
logger.info(f'Updating email address for {user_id} to {email}')
return response
@@ -96,41 +90,9 @@ async def update_email(
)
@api_router.put('/resend')
async def resend_email_verification(
request: Request,
body: ResendEmailVerificationRequest | None = None,
):
# Get user_id from body if provided, otherwise from auth
user_id: str | None = None
if body and body.user_id:
user_id = body.user_id
else:
try:
user_id = await get_user_id(request)
except Exception:
pass
if not user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='user_id is required in request body or user must be authenticated',
)
# Check rate limit (uses user_id if available, otherwise falls back to IP)
# Use 30 seconds for user-based rate limiting to match frontend cooldown
await check_rate_limit_by_user_id(
request=request,
key_prefix='email_resend',
user_id=user_id,
user_rate_limit_seconds=30,
ip_rate_limit_seconds=60, # 1 minute for IP-based limiting (more lenient)
)
# Get is_auth_flow from body if provided, default to False
is_auth_flow = body.is_auth_flow if body else False
await verify_email(request=request, user_id=user_id, is_auth_flow=is_auth_flow)
@api_router.put('/verify')
async def verify_email(request: Request, user_id: str = Depends(get_user_id)):
await _verify_email(request=request, user_id=user_id)
logger.info(f'Resending verification email for {user_id}')
return JSONResponse(
@@ -162,13 +124,10 @@ async def verified_email(request: Request):
return response
async def verify_email(request: Request, user_id: str, is_auth_flow: bool = False):
async def _verify_email(request: Request, user_id: str):
keycloak_admin = get_keycloak_admin()
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
if is_auth_flow:
redirect_uri = f'{scheme}://{request.url.netloc}?email_verified=true'
else:
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
redirect_uri = f'{scheme}://{request.url.netloc}/api/email/verified'
logger.info(f'Redirect URI: {redirect_uri}')
await keycloak_admin.a_send_verify_email(
user_id=user_id,

View File

@@ -134,12 +134,12 @@ async def _process_batch_operations_background(
)
except Exception as e:
logger.error(
f'error_processing_batch_operation: {type(e).__name__}: {e}',
'error_processing_batch_operation',
extra={
'path': batch_op.path,
'method': str(batch_op.method),
'error': str(e),
},
exc_info=True,
)

View File

@@ -11,7 +11,6 @@ from fastapi.responses import RedirectResponse
from server.logger import logger
from openhands.server.shared import config
from openhands.utils.http_session import httpx_verify_option
GITHUB_PROXY_ENDPOINTS = bool(os.environ.get('GITHUB_PROXY_ENDPOINTS'))
@@ -88,7 +87,7 @@ def add_github_proxy_routes(app: FastAPI):
]
body = urlencode(query_params, doseq=True)
url = 'https://github.com/login/oauth/access_token'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(url, content=body)
return Response(
response.content,
@@ -102,7 +101,7 @@ def add_github_proxy_routes(app: FastAPI):
logger.info(f'github_proxy_post:1:{path}')
body = await request.body()
url = f'https://github.com/{path}'
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient() as client:
response = await client.post(url, content=body, headers=request.headers)
return Response(
response.content,

View File

@@ -1,4 +1,3 @@
import asyncio
import hashlib
import hmac
import os
@@ -59,8 +58,7 @@ async def github_events(
)
try:
# Add timeout to prevent hanging on slow/stalled clients
payload = await asyncio.wait_for(request.body(), timeout=15.0)
payload = await request.body()
verify_github_signature(payload, x_hub_signature_256)
payload_data = await request.json()
@@ -80,12 +78,6 @@ async def github_events(
status_code=200,
content={'message': 'GitHub events endpoint reached successfully.'},
)
except asyncio.TimeoutError:
logger.warning('GitHub webhook request timed out waiting for request body')
return JSONResponse(
status_code=408,
content={'error': 'Request timeout - client took too long to send data.'},
)
except Exception as e:
logger.exception(f'Error processing GitHub event: {e}')
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})

View File

@@ -1,324 +0,0 @@
"""OAuth 2.0 Device Flow endpoints for CLI authentication."""
from datetime import UTC, datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from storage.api_key_store import ApiKeyStore
from storage.database import session_maker
from storage.device_code_store import DeviceCodeStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEVICE_CODE_EXPIRES_IN = 600 # 10 minutes
DEVICE_TOKEN_POLL_INTERVAL = 5 # seconds
API_KEY_NAME = 'Device Link Access Key'
KEY_EXPIRATION_TIME = timedelta(days=1) # Key expires in 24 hours
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class DeviceAuthorizationResponse(BaseModel):
device_code: str
user_code: str
verification_uri: str
verification_uri_complete: str
expires_in: int
interval: int
class DeviceTokenResponse(BaseModel):
access_token: str # This will be the user's API key
token_type: str = 'Bearer'
expires_in: Optional[int] = None # API keys may not have expiration
class DeviceTokenErrorResponse(BaseModel):
error: str
error_description: Optional[str] = None
interval: Optional[int] = None # Required for slow_down error
# ---------------------------------------------------------------------------
# Router + stores
# ---------------------------------------------------------------------------
oauth_device_router = APIRouter(prefix='/oauth/device')
device_code_store = DeviceCodeStore(session_maker)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _oauth_error(
status_code: int,
error: str,
description: str,
interval: Optional[int] = None,
) -> JSONResponse:
"""Return a JSON OAuth-style error response."""
return JSONResponse(
status_code=status_code,
content=DeviceTokenErrorResponse(
error=error,
error_description=description,
interval=interval,
).model_dump(),
)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@oauth_device_router.post('/authorize', response_model=DeviceAuthorizationResponse)
async def device_authorization(
http_request: Request,
) -> DeviceAuthorizationResponse:
"""Start device flow by generating device and user codes."""
try:
device_code_entry = device_code_store.create_device_code(
expires_in=DEVICE_CODE_EXPIRES_IN,
)
base_url = str(http_request.base_url).rstrip('/')
verification_uri = f'{base_url}/oauth/device/verify'
verification_uri_complete = (
f'{verification_uri}?user_code={device_code_entry.user_code}'
)
logger.info(
'Device authorization initiated',
extra={'user_code': device_code_entry.user_code},
)
return DeviceAuthorizationResponse(
device_code=device_code_entry.device_code,
user_code=device_code_entry.user_code,
verification_uri=verification_uri,
verification_uri_complete=verification_uri_complete,
expires_in=DEVICE_CODE_EXPIRES_IN,
interval=device_code_entry.current_interval,
)
except Exception as e:
logger.exception('Error in device authorization: %s', str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Internal server error',
) from e
@oauth_device_router.post('/token')
async def device_token(device_code: str = Form(...)):
"""Poll for a token until the user authorizes or the code expires."""
try:
device_code_entry = device_code_store.get_by_device_code(device_code)
if not device_code_entry:
return _oauth_error(
status.HTTP_400_BAD_REQUEST,
'invalid_grant',
'Invalid device code',
)
# Check rate limiting (RFC 8628 section 3.5)
is_too_fast, current_interval = device_code_entry.check_rate_limit()
if is_too_fast:
# Update poll time and increase interval
device_code_store.update_poll_time(device_code, increase_interval=True)
logger.warning(
'Client polling too fast, returning slow_down error',
extra={
'device_code': device_code[:8] + '...', # Log partial for privacy
'new_interval': current_interval,
},
)
return _oauth_error(
status.HTTP_400_BAD_REQUEST,
'slow_down',
f'Polling too frequently. Wait at least {current_interval} seconds between requests.',
interval=current_interval,
)
# Update poll time for successful rate limit check
device_code_store.update_poll_time(device_code, increase_interval=False)
if device_code_entry.is_expired():
return _oauth_error(
status.HTTP_400_BAD_REQUEST,
'expired_token',
'Device code has expired',
)
if device_code_entry.status == 'denied':
return _oauth_error(
status.HTTP_400_BAD_REQUEST,
'access_denied',
'User denied the authorization request',
)
if device_code_entry.status == 'pending':
return _oauth_error(
status.HTTP_400_BAD_REQUEST,
'authorization_pending',
'User has not yet completed authorization',
)
if device_code_entry.status == 'authorized':
# Retrieve the specific API key for this device using the user_code
api_key_store = ApiKeyStore.get_instance()
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'
device_api_key = api_key_store.retrieve_api_key_by_name(
device_code_entry.keycloak_user_id, device_key_name
)
if not device_api_key:
logger.error(
'No device API key found for authorized device',
extra={
'user_id': device_code_entry.keycloak_user_id,
'user_code': device_code_entry.user_code,
},
)
return _oauth_error(
status.HTTP_500_INTERNAL_SERVER_ERROR,
'server_error',
'API key not found',
)
# Return the API key as access_token
return DeviceTokenResponse(
access_token=device_api_key,
)
# Fallback for unexpected status values
logger.error(
'Unknown device code status',
extra={'status': device_code_entry.status},
)
return _oauth_error(
status.HTTP_500_INTERNAL_SERVER_ERROR,
'server_error',
'Unknown device code status',
)
except Exception as e:
logger.exception('Error in device token: %s', str(e))
return _oauth_error(
status.HTTP_500_INTERNAL_SERVER_ERROR,
'server_error',
'Internal server error',
)
@oauth_device_router.post('/verify-authenticated')
async def device_verification_authenticated(
user_code: str = Form(...),
user_id: str = Depends(get_user_id),
):
"""Process device verification for authenticated users (called by frontend)."""
try:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Authentication required',
)
# Validate device code
device_code_entry = device_code_store.get_by_user_code(user_code)
if not device_code_entry:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='The device code is invalid or has expired.',
)
if not device_code_entry.is_pending():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This device code has already been processed.',
)
# First, authorize the device code
success = device_code_store.authorize_device_code(
user_code=user_code,
user_id=user_id,
)
if not success:
logger.error(
'Failed to authorize device code',
extra={'user_code': user_code, 'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to authorize the device. Please try again.',
)
# Only create API key AFTER successful authorization
api_key_store = ApiKeyStore.get_instance()
try:
# Create a unique API key for this device using user_code in the name
device_key_name = f'{API_KEY_NAME} ({user_code})'
api_key_store.create_api_key(
user_id,
name=device_key_name,
expires_at=datetime.now(UTC) + KEY_EXPIRATION_TIME,
)
logger.info(
'Created new device API key for user after successful authorization',
extra={'user_id': user_id, 'user_code': user_code},
)
except Exception as e:
logger.exception(
'Failed to create device API key after authorization: %s', str(e)
)
# Clean up: revert the device authorization since API key creation failed
# This prevents the device from being in an authorized state without an API key
try:
device_code_store.deny_device_code(user_code)
logger.info(
'Reverted device authorization due to API key creation failure',
extra={'user_code': user_code, 'user_id': user_id},
)
except Exception as cleanup_error:
logger.exception(
'Failed to revert device authorization during cleanup: %s',
str(cleanup_error),
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create API key for device access.',
)
logger.info(
'Device code authorized with API key successfully',
extra={'user_code': user_code, 'user_id': user_id},
)
return JSONResponse(
status_code=status.HTTP_200_OK,
content={'message': 'Device authorized successfully!'},
)
except HTTPException:
raise
except Exception as e:
logger.exception('Error in device verification: %s', str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred. Please try again.',
)

View File

@@ -31,7 +31,6 @@ from openhands.events.event_store import EventStore
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime
from openhands.runtime.plugins.vscode import VSCodeRequirement
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.server.config.server_config import ServerConfig
from openhands.server.constants import ROOM_KEY
@@ -53,7 +52,6 @@ from openhands.storage.locations import (
get_conversation_events_dir,
)
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.http_session import httpx_verify_option
from openhands.utils.import_utils import get_impl
from openhands.utils.shutdown_listener import should_continue
from openhands.utils.utils import create_registry_and_conversation_stats
@@ -71,14 +69,6 @@ RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + (
else '/api/conversations/{conversation_id}'
)
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'false')
truthy = {'1', 'true', 't', 'yes', 'y', 'on'}
SU_TO_USER = str(SU_TO_USER.lower() in truthy).lower()
DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true'
# Time in seconds before a Redis entry is considered expired if not refreshed
_REDIS_ENTRY_TIMEOUT_SECONDS = 300
@@ -276,10 +266,9 @@ class SaasNestedConversationManager(ConversationManager):
):
logger.info('starting_nested_conversation', extra={'sid': sid})
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={
'X-Session-API-Key': session_api_key,
},
}
) as client:
await self._setup_nested_settings(client, api_url, settings)
await self._setup_provider_tokens(client, api_url, settings)
@@ -495,10 +484,9 @@ class SaasNestedConversationManager(ConversationManager):
raise ValueError(f'no_such_conversation:{sid}')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={
'X-Session-API-Key': runtime['session_api_key'],
},
}
) as client:
response = await client.post(f'{nested_url}/events', json=data)
response.raise_for_status()
@@ -563,10 +551,9 @@ class SaasNestedConversationManager(ConversationManager):
return None
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={
'X-Session-API-Key': session_api_key,
},
}
) as client:
# Query the nested runtime for conversation info
response = await client.get(nested_url)
@@ -781,11 +768,7 @@ class SaasNestedConversationManager(ConversationManager):
env_vars['SERVE_FRONTEND'] = '0'
env_vars['RUNTIME'] = 'local'
# TODO: In the long term we may come up with a more secure strategy for user management within the nested runtime.
env_vars['USER'] = (
RUNTIME_USERNAME
if RUNTIME_USERNAME
else ('openhands' if config.run_as_openhands else 'root')
)
env_vars['USER'] = 'openhands' if config.run_as_openhands else 'root'
env_vars['PERMITTED_CORS_ORIGINS'] = ','.join(PERMITTED_CORS_ORIGINS)
env_vars['port'] = '60000'
# TODO: These values are static in the runtime-api project, but do not get copied into the runtime ENV
@@ -802,10 +785,6 @@ class SaasNestedConversationManager(ConversationManager):
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
env_vars['ENABLE_V1'] = '0'
env_vars['SU_TO_USER'] = SU_TO_USER
env_vars['DISABLE_VSCODE_PLUGIN'] = str(DISABLE_VSCODE_PLUGIN).lower()
env_vars['BROWSERGYM_DOWNLOAD_DIR'] = '/workspace/.downloads/'
env_vars['PLAYWRIGHT_BROWSERS_PATH'] = '/opt/playwright-browsers'
# We need this for LLM traces tracking to identify the source of the LLM calls
env_vars['WEB_HOST'] = WEB_HOST
@@ -821,18 +800,11 @@ class SaasNestedConversationManager(ConversationManager):
if self._runtime_container_image:
config.sandbox.runtime_container_image = self._runtime_container_image
plugins = [
plugin
for plugin in agent.sandbox_plugins
if not (DISABLE_VSCODE_PLUGIN and isinstance(plugin, VSCodeRequirement))
]
logger.info(f'Loaded plugins for runtime {sid}: {plugins}')
runtime = RemoteRuntime(
config=config,
event_stream=None, # type: ignore[arg-type]
sid=sid,
plugins=plugins,
plugins=agent.sandbox_plugins,
# env_vars=env_vars,
# status_callback: Callable[..., None] | None = None,
attach_to_existing=False,
@@ -856,7 +828,6 @@ class SaasNestedConversationManager(ConversationManager):
@contextlib.asynccontextmanager
async def _httpx_client(self):
async with httpx.AsyncClient(
verify=httpx_verify_option(),
headers={'X-API-Key': self.config.sandbox.api_key or ''},
timeout=_HTTP_TIMEOUT,
) as client:

View File

@@ -1,20 +0,0 @@
# Sharing Package
This package contains functionality for sharing conversations.
## Components
- **shared.py**: Data models for shared conversations
- **shared_conversation_info_service.py**: Service interface for accessing shared conversation info
- **sql_shared_conversation_info_service.py**: SQL implementation of the shared conversation info service
- **shared_event_service.py**: Service interface for accessing shared events
- **shared_event_service_impl.py**: Implementation of the shared event service
- **shared_conversation_router.py**: REST API endpoints for shared conversations
- **shared_event_router.py**: REST API endpoints for shared events
## Features
- Read-only access to shared conversations
- Event access for shared conversations
- Search and filtering capabilities
- Pagination support

View File

@@ -1,142 +0,0 @@
"""Implementation of SharedEventService.
This implementation provides read-only access to events from shared conversations:
- Validates that the conversation is shared before returning events
- Uses existing EventService for actual event retrieval
- Uses SharedConversationInfoService for shared conversation validation
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import AsyncGenerator
from uuid import UUID
from fastapi import Request
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_event_service import (
SharedEventService,
SharedEventServiceInjector,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoService,
)
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event.event_service import EventService
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.app_server.services.injector import InjectorState
from openhands.sdk import Event
logger = logging.getLogger(__name__)
@dataclass
class SharedEventServiceImpl(SharedEventService):
"""Implementation of SharedEventService that validates shared access."""
shared_conversation_info_service: SharedConversationInfoService
event_service: EventService
async def get_shared_event(
self, conversation_id: UUID, event_id: str
) -> Event | None:
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
# First check if the conversation is shared
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return None
# If conversation is shared, get the event
return await self.event_service.get_event(event_id)
async def search_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
page_id: str | None = None,
limit: int = 100,
) -> EventPage:
"""Search events for a specific shared conversation."""
# First check if the conversation is shared
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
# Return empty page if conversation is not shared
return EventPage(items=[], next_page_id=None)
# If conversation is shared, search events for this conversation
return await self.event_service.search_events(
conversation_id__eq=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
async def count_shared_events(
self,
conversation_id: UUID,
kind__eq: EventKind | None = None,
timestamp__gte: datetime | None = None,
timestamp__lt: datetime | None = None,
sort_order: EventSortOrder = EventSortOrder.TIMESTAMP,
) -> int:
"""Count events for a specific shared conversation."""
# First check if the conversation is shared
shared_conversation_info = (
await self.shared_conversation_info_service.get_shared_conversation_info(
conversation_id
)
)
if shared_conversation_info is None:
return 0
# If conversation is shared, count events for this conversation
return await self.event_service.count_events(
conversation_id__eq=conversation_id,
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
)
class SharedEventServiceImplInjector(SharedEventServiceInjector):
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[SharedEventService, None]:
# Define inline to prevent circular lookup
from openhands.app_server.config import (
get_db_session,
get_event_service,
)
async with (
get_db_session(state, request) as db_session,
get_event_service(state, request) as event_service,
):
shared_conversation_info_service = SQLSharedConversationInfoService(
db_session=db_session
)
service = SharedEventServiceImpl(
shared_conversation_info_service=shared_conversation_info_service,
event_service=event_service,
)
yield service

View File

@@ -1,66 +0,0 @@
import asyncio
from abc import ABC, abstractmethod
from datetime import datetime
from uuid import UUID
from server.sharing.shared_conversation_models import (
SharedConversation,
SharedConversationPage,
SharedConversationSortOrder,
)
from openhands.app_server.services.injector import Injector
from openhands.sdk.utils.models import DiscriminatedUnionMixin
class SharedConversationInfoService(ABC):
"""Service for accessing shared conversation info without user restrictions."""
@abstractmethod
async def search_shared_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
sort_order: SharedConversationSortOrder = SharedConversationSortOrder.CREATED_AT_DESC,
page_id: str | None = None,
limit: int = 100,
include_sub_conversations: bool = False,
) -> SharedConversationPage:
"""Search for shared conversations."""
@abstractmethod
async def count_shared_conversation_info(
self,
title__contains: str | None = None,
created_at__gte: datetime | None = None,
created_at__lt: datetime | None = None,
updated_at__gte: datetime | None = None,
updated_at__lt: datetime | None = None,
) -> int:
"""Count shared conversations."""
@abstractmethod
async def get_shared_conversation_info(
self, conversation_id: UUID
) -> SharedConversation | None:
"""Get a single shared conversation info, returning None if missing or not shared."""
async def batch_get_shared_conversation_info(
self, conversation_ids: list[UUID]
) -> list[SharedConversation | None]:
"""Get a batch of shared conversation info, return None for any missing or non-shared."""
return await asyncio.gather(
*[
self.get_shared_conversation_info(conversation_id)
for conversation_id in conversation_ids
]
)
class SharedConversationInfoServiceInjector(
DiscriminatedUnionMixin, Injector[SharedConversationInfoService], ABC
):
pass

View File

@@ -1,56 +0,0 @@
from datetime import datetime
from enum import Enum
# Simplified imports to avoid dependency chain issues
# from openhands.integrations.service_types import ProviderType
# from openhands.sdk.llm import MetricsSnapshot
# from openhands.storage.data_models.conversation_metadata import ConversationTrigger
# For now, use Any to avoid import issues
from typing import Any
from uuid import uuid4
from pydantic import BaseModel, Field
from openhands.agent_server.utils import OpenHandsUUID, utc_now
ProviderType = Any
MetricsSnapshot = Any
ConversationTrigger = Any
class SharedConversation(BaseModel):
"""Shared conversation info model with all fields from AppConversationInfo."""
id: OpenHandsUUID = Field(default_factory=uuid4)
created_by_user_id: str | None
sandbox_id: str
selected_repository: str | None = None
selected_branch: str | None = None
git_provider: ProviderType | None = None
title: str | None = None
pr_number: list[int] = Field(default_factory=list)
llm_model: str | None = None
metrics: MetricsSnapshot | None = None
parent_conversation_id: OpenHandsUUID | None = None
sub_conversation_ids: list[OpenHandsUUID] = Field(default_factory=list)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class SharedConversationSortOrder(Enum):
CREATED_AT = 'CREATED_AT'
CREATED_AT_DESC = 'CREATED_AT_DESC'
UPDATED_AT = 'UPDATED_AT'
UPDATED_AT_DESC = 'UPDATED_AT_DESC'
TITLE = 'TITLE'
TITLE_DESC = 'TITLE_DESC'
class SharedConversationPage(BaseModel):
items: list[SharedConversation]
next_page_id: str | None = None

View File

@@ -1,135 +0,0 @@
"""Shared Conversation router for OpenHands Server."""
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from server.sharing.shared_conversation_info_service import (
SharedConversationInfoService,
)
from server.sharing.shared_conversation_models import (
SharedConversation,
SharedConversationPage,
SharedConversationSortOrder,
)
from server.sharing.sql_shared_conversation_info_service import (
SQLSharedConversationInfoServiceInjector,
)
router = APIRouter(prefix='/api/shared-conversations', tags=['Sharing'])
shared_conversation_info_service_dependency = Depends(
SQLSharedConversationInfoServiceInjector().depends
)
# Read methods
@router.get('/search')
async def search_shared_conversations(
title__contains: Annotated[
str | None,
Query(title='Filter by title containing this string'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
created_at__lt: Annotated[
datetime | None,
Query(title='Filter by created_at less than this datetime'),
] = None,
updated_at__gte: Annotated[
datetime | None,
Query(title='Filter by updated_at greater than or equal to this datetime'),
] = None,
updated_at__lt: Annotated[
datetime | None,
Query(title='Filter by updated_at less than this datetime'),
] = None,
sort_order: Annotated[
SharedConversationSortOrder,
Query(title='Sort order for results'),
] = SharedConversationSortOrder.CREATED_AT_DESC,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(
title='The max number of results in the page',
gt=0,
lte=100,
),
] = 100,
include_sub_conversations: Annotated[
bool,
Query(
title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.'
),
] = False,
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> SharedConversationPage:
"""Search / List shared conversations."""
assert limit > 0
assert limit <= 100
return await shared_conversation_service.search_shared_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
include_sub_conversations=include_sub_conversations,
)
@router.get('/count')
async def count_shared_conversations(
title__contains: Annotated[
str | None,
Query(title='Filter by title containing this string'),
] = None,
created_at__gte: Annotated[
datetime | None,
Query(title='Filter by created_at greater than or equal to this datetime'),
] = None,
created_at__lt: Annotated[
datetime | None,
Query(title='Filter by created_at less than this datetime'),
] = None,
updated_at__gte: Annotated[
datetime | None,
Query(title='Filter by updated_at greater than or equal to this datetime'),
] = None,
updated_at__lt: Annotated[
datetime | None,
Query(title='Filter by updated_at less than this datetime'),
] = None,
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> int:
"""Count shared conversations matching the given filters."""
return await shared_conversation_service.count_shared_conversation_info(
title__contains=title__contains,
created_at__gte=created_at__gte,
created_at__lt=created_at__lt,
updated_at__gte=updated_at__gte,
updated_at__lt=updated_at__lt,
)
@router.get('')
async def batch_get_shared_conversations(
ids: Annotated[list[str], Query()],
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
) -> list[SharedConversation | None]:
"""Get a batch of shared conversations given their ids. Return None for any missing or non-shared."""
assert len(ids) <= 100
uuids = [UUID(id_) for id_ in ids]
shared_conversation_info = (
await shared_conversation_service.batch_get_shared_conversation_info(uuids)
)
return shared_conversation_info

View File

@@ -1,126 +0,0 @@
"""Shared Event router for OpenHands Server."""
from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from server.sharing.filesystem_shared_event_service import (
SharedEventServiceImplInjector,
)
from server.sharing.shared_event_service import SharedEventService
from openhands.agent_server.models import EventPage, EventSortOrder
from openhands.app_server.event_callback.event_callback_models import EventKind
from openhands.sdk import Event
router = APIRouter(prefix='/api/shared-events', tags=['Sharing'])
shared_event_service_dependency = Depends(SharedEventServiceImplInjector().depends)
# Read methods
@router.get('/search')
async def search_shared_events(
conversation_id: Annotated[
str,
Query(title='Conversation ID to search events for'),
],
kind__eq: Annotated[
EventKind | None,
Query(title='Optional filter by event kind'),
] = None,
timestamp__gte: Annotated[
datetime | None,
Query(title='Optional filter by timestamp greater than or equal to'),
] = None,
timestamp__lt: Annotated[
datetime | None,
Query(title='Optional filter by timestamp less than'),
] = None,
sort_order: Annotated[
EventSortOrder,
Query(title='Sort order for results'),
] = EventSortOrder.TIMESTAMP,
page_id: Annotated[
str | None,
Query(title='Optional next_page_id from the previously returned page'),
] = None,
limit: Annotated[
int,
Query(title='The max number of results in the page', gt=0, lte=100),
] = 100,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> EventPage:
"""Search / List events for a shared conversation."""
assert limit > 0
assert limit <= 100
return await shared_event_service.search_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
page_id=page_id,
limit=limit,
)
@router.get('/count')
async def count_shared_events(
conversation_id: Annotated[
str,
Query(title='Conversation ID to count events for'),
],
kind__eq: Annotated[
EventKind | None,
Query(title='Optional filter by event kind'),
] = None,
timestamp__gte: Annotated[
datetime | None,
Query(title='Optional filter by timestamp greater than or equal to'),
] = None,
timestamp__lt: Annotated[
datetime | None,
Query(title='Optional filter by timestamp less than'),
] = None,
sort_order: Annotated[
EventSortOrder,
Query(title='Sort order for results'),
] = EventSortOrder.TIMESTAMP,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> int:
"""Count events for a shared conversation matching the given filters."""
return await shared_event_service.count_shared_events(
conversation_id=UUID(conversation_id),
kind__eq=kind__eq,
timestamp__gte=timestamp__gte,
timestamp__lt=timestamp__lt,
sort_order=sort_order,
)
@router.get('')
async def batch_get_shared_events(
conversation_id: Annotated[
UUID,
Query(title='Conversation ID to get events for'),
],
id: Annotated[list[str], Query()],
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> list[Event | None]:
"""Get a batch of events for a shared conversation given their ids, returning null for any missing event."""
assert len(id) <= 100
events = await shared_event_service.batch_get_shared_events(conversation_id, id)
return events
@router.get('/{conversation_id}/{event_id}')
async def get_shared_event(
conversation_id: UUID,
event_id: str,
shared_event_service: SharedEventService = shared_event_service_dependency,
) -> Event | None:
"""Get a single event from a shared conversation by conversation_id and event_id."""
return await shared_event_service.get_shared_event(conversation_id, event_id)

Some files were not shown because too many files have changed in this diff Show More