mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
enterprise
...
openhands/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d97ae6c5d | ||
|
|
eeb81ecc49 | ||
|
|
4d0f2e7a6d | ||
|
|
2c6d1e97e8 | ||
|
|
180557265f |
12
.github/CODEOWNERS
vendored
12
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
10
.github/pull_request_template.md
vendored
10
.github/pull_request_template.md
vendored
@@ -1,16 +1,10 @@
|
||||
<!-- Ideally you should open a PR when it is ready for review. Draft PRs will not be reviewed -->
|
||||
|
||||
## Summary of PR
|
||||
|
||||
<!-- Summarize what the PR does -->
|
||||
|
||||
## Demo Screenshots/Videos
|
||||
|
||||
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
|
||||
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
|
||||
|
||||
## Change Type
|
||||
|
||||
<!-- Choose the types that apply to your PR -->
|
||||
<!-- Choose the types that apply to your PR and remove the rest. -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
|
||||
2
.github/workflows/check-package-versions.yml
vendored
2
.github/workflows/check-package-versions.yml
vendored
@@ -15,7 +15,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"
|
||||
|
||||
|
||||
8
.github/workflows/e2e-tests.yml
vendored
8
.github/workflows/e2e-tests.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
2
.github/workflows/enterprise-preview.yml
vendored
2
.github/workflows/enterprise-preview.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
|
||||
-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
|
||||
|
||||
47
.github/workflows/fe-e2e-tests.yml
vendored
47
.github/workflows/fe-e2e-tests.yml
vendored
@@ -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
|
||||
146
.github/workflows/ghcr-build.yml
vendored
146
.github/workflows/ghcr-build.yml
vendored
@@ -64,7 +64,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
|
||||
@@ -102,7 +102,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 +161,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
|
||||
@@ -247,20 +247,156 @@ jobs:
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
|
||||
-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
|
||||
|
||||
# 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
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
# Forked repos can't push to GHCR, so we need to rebuild using cache
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
|
||||
- name: Run docker runtime tests
|
||||
shell: bash
|
||||
run: |
|
||||
# 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
|
||||
poetry run pip install pytest-rerunfailures
|
||||
|
||||
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
|
||||
# Setting RUN_AS_OPENHANDS to false means use root.
|
||||
# That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.
|
||||
|
||||
TEST_RUNTIME=docker \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
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
|
||||
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
|
||||
needs: [ghcr_build_runtime, define-matrix]
|
||||
strategy:
|
||||
matrix:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Download runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
path: containers/runtime
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
# Forked repos can't push to GHCR, so we need to rebuild using cache
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
with:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
|
||||
- name: Run runtime tests
|
||||
shell: bash
|
||||
run: |
|
||||
# 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 flaky tests
|
||||
poetry run pip install pytest-rerunfailures
|
||||
|
||||
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
|
||||
TEST_RUNTIME=docker \
|
||||
SANDBOX_USER_ID=$(id -u) \
|
||||
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
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
# The two following jobs (named identically) are to check whether all the runtime tests have passed as the
|
||||
# "All Runtime Tests Passed" is a required job for PRs to merge
|
||||
# We can remove this once the config changes
|
||||
# Due to this bug: https://github.com/actions/runner/issues/2566, we want to create a job that runs when the
|
||||
# prerequisites have been cancelled or failed so merging is disallowed, otherwise Github considers "skipped" as "success"
|
||||
runtime_tests_check_success:
|
||||
name: All Runtime Tests Passed
|
||||
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
|
||||
runtime_tests_check_fail:
|
||||
name: All Runtime Tests Passed
|
||||
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: Some tests failed
|
||||
run: |
|
||||
echo "Some runtime tests failed or were cancelled"
|
||||
exit 1
|
||||
update_pr_description:
|
||||
name: Update PR Description
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
|
||||
|
||||
6
.github/workflows/openhands-resolver.yml
vendored
6
.github/workflows/openhands-resolver.yml
vendored
@@ -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') }}
|
||||
@@ -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
|
||||
|
||||
6
.github/workflows/py-tests.yml
vendored
6
.github/workflows/py-tests.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
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: |
|
||||
@@ -95,7 +95,7 @@ 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 }}"
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
- uses: actions/download-artifact@v5
|
||||
id: download
|
||||
with:
|
||||
pattern: coverage-*
|
||||
|
||||
1
.github/workflows/stale.yml
vendored
1
.github/workflows/stale.yml
vendored
@@ -9,7 +9,6 @@ on:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: github.repository == 'OpenHands/OpenHands'
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
|
||||
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
# Workflow that validates the VSCode extension builds correctly
|
||||
name: VSCode Extension CI
|
||||
|
||||
# * Always run on "main"
|
||||
# * Run on PRs that have changes in the VSCode extension folder or this workflow
|
||||
# * Run on tags that start with "ext-v"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'ext-v*'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openhands/integrations/vscode/**'
|
||||
- 'build_vscode.py'
|
||||
- '.github/workflows/vscode-extension-build.yml'
|
||||
|
||||
# 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:
|
||||
# Validate VSCode extension builds correctly
|
||||
validate-vscode-extension:
|
||||
name: Validate VSCode Extension Build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install VSCode extension dependencies
|
||||
working-directory: ./openhands/integrations/vscode
|
||||
run: npm ci
|
||||
|
||||
- name: Build VSCode extension via build_vscode.py
|
||||
run: python build_vscode.py
|
||||
env:
|
||||
# Ensure we don't skip the build
|
||||
SKIP_VSCODE_BUILD: ""
|
||||
|
||||
- name: Validate .vsix file
|
||||
run: |
|
||||
# Verify the .vsix was created and is valid
|
||||
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
|
||||
echo "✅ VSCode extension built successfully"
|
||||
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
# Basic validation that the .vsix is a valid zip file
|
||||
echo "🔍 Validating .vsix structure..."
|
||||
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
echo "✅ VSCode extension validation passed"
|
||||
else
|
||||
echo "❌ VSCode extension build failed - .vsix not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload VSCode extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment on PR with artifact link
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Get file size for display
|
||||
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
|
||||
const stats = fs.statSync(vsixPath);
|
||||
const fileSizeKB = Math.round(stats.size / 1024);
|
||||
|
||||
const comment = `## 🔧 VSCode Extension Built Successfully!
|
||||
|
||||
The VSCode extension has been built and is ready for testing.
|
||||
|
||||
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
|
||||
|
||||
**🚀 To install**:
|
||||
1. Download the artifact from the workflow run above
|
||||
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
|
||||
3. Select the downloaded \`.vsix\` file
|
||||
|
||||
**✅ Tested with**: Node.js 22
|
||||
**🔍 Validation**: File structure and integrity verified
|
||||
|
||||
---
|
||||
*Built from commit ${{ github.sha }}*`;
|
||||
|
||||
// Check if we already commented on this PR and delete it
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('VSCode Extension Built Successfully')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: validate-vscode-extension
|
||||
if: startsWith(github.ref, 'refs/tags/ext-v')
|
||||
|
||||
steps:
|
||||
- name: Download .vsix artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: ./
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1.16.0
|
||||
with:
|
||||
artifacts: "*.vsix"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](https://openhands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -150,9 +150,9 @@ Each integration follows a consistent pattern with service classes, storage mode
|
||||
|
||||
**Important Notes:**
|
||||
- Enterprise code is licensed under Polyform Free Trial License (30-day limit)
|
||||
- The enterprise server extends the OpenHands server through dynamic imports
|
||||
- The enterprise server extends the OSS server through dynamic imports
|
||||
- Database changes require careful migration planning in `enterprise/migrations/`
|
||||
- Always test changes in both OpenHands and enterprise contexts
|
||||
- Always test changes in both OSS and enterprise contexts
|
||||
- Use the enterprise-specific Makefile commands for development
|
||||
|
||||
**Enterprise Testing Best Practices:**
|
||||
@@ -166,7 +166,7 @@ Each integration follows a consistent pattern with service classes, storage mode
|
||||
**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 OpenHands and enterprise contexts
|
||||
- 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
|
||||
@@ -13,6 +13,7 @@ STAGED_FILES=$(git diff --cached --name-only)
|
||||
# Check if any files match specific patterns
|
||||
has_frontend_changes=false
|
||||
has_backend_changes=false
|
||||
has_vscode_changes=false
|
||||
|
||||
# Check each file individually to avoid issues with grep
|
||||
for file in $STAGED_FILES; do
|
||||
@@ -20,12 +21,17 @@ for file in $STAGED_FILES; do
|
||||
has_frontend_changes=true
|
||||
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
|
||||
has_backend_changes=true
|
||||
# Check for VSCode extension changes (subset of backend changes)
|
||||
if [[ $file == openhands/integrations/vscode/* ]]; then
|
||||
has_vscode_changes=true
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Analyzing changes..."
|
||||
echo "- Frontend changes: $has_frontend_changes"
|
||||
echo "- Backend changes: $has_backend_changes"
|
||||
echo "- VSCode extension changes: $has_vscode_changes"
|
||||
|
||||
# Run frontend linting if needed
|
||||
if [ "$has_frontend_changes" = true ]; then
|
||||
@@ -86,6 +92,51 @@ else
|
||||
echo "Skipping backend checks (no backend changes detected)."
|
||||
fi
|
||||
|
||||
# Run VSCode extension checks if needed
|
||||
if [ "$has_vscode_changes" = true ]; then
|
||||
# Check if we're in a CI environment
|
||||
if [ -n "$CI" ]; then
|
||||
echo "Skipping VSCode extension checks (CI environment detected)."
|
||||
echo "WARNING: VSCode extension files have changed but checks are being skipped."
|
||||
echo "Please run VSCode extension checks manually before submitting your PR."
|
||||
else
|
||||
echo "Running VSCode extension checks..."
|
||||
if [ -d "openhands/integrations/vscode" ]; then
|
||||
cd openhands/integrations/vscode || exit 1
|
||||
|
||||
echo "Running npm lint:fix..."
|
||||
npm run lint:fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "VSCode extension linting failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
else
|
||||
echo "VSCode extension linting passed!"
|
||||
fi
|
||||
|
||||
echo "Running npm typecheck..."
|
||||
npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "VSCode extension type checking failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
else
|
||||
echo "VSCode extension type checking passed!"
|
||||
fi
|
||||
|
||||
echo "Running npm compile..."
|
||||
npm run compile
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "VSCode extension compilation failed. Please fix the issues before committing."
|
||||
EXIT_CODE=1
|
||||
else
|
||||
echo "VSCode extension compilation passed!"
|
||||
fi
|
||||
|
||||
cd ../../..
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "Skipping VSCode extension checks (no VSCode extension changes detected)."
|
||||
fi
|
||||
|
||||
# If no specific code changes detected, run basic checks
|
||||
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then
|
||||
|
||||
@@ -61,7 +61,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
contact@openhands.dev.
|
||||
contact@all-hands.dev.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
@@ -115,9 +115,7 @@ community.
|
||||
|
||||
### Slack Etiquettes
|
||||
|
||||
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all
|
||||
community members. By following these best practices, we ensure effective communication and collaboration while
|
||||
minimizing disruptions. Let’s work together to build a supportive and welcoming community!
|
||||
These Slack etiquette guidelines are designed to foster an inclusive, respectful, and productive environment for all community members. By following these best practices, we ensure effective communication and collaboration while minimizing disruptions. Let’s work together to build a supportive and welcoming community!
|
||||
|
||||
- Communicate respectfully and professionally, avoiding sarcasm or harsh language, and remember that tone can be difficult to interpret in text.
|
||||
- Use threads for specific discussions to keep channels organized and easier to follow.
|
||||
@@ -127,10 +125,7 @@ minimizing disruptions. Let’s work together to build a supportive and welcomin
|
||||
- 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.
|
||||
- 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 it’s too busy, but set notifications to
|
||||
alert you only when “LLMs” appears in messages.
|
||||
- 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 it’s too busy, but set notifications to alert you only when “LLMs” appears in messages.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
37
COMMUNITY.md
37
COMMUNITY.md
@@ -1,44 +1,36 @@
|
||||
# The OpenHands Community
|
||||
|
||||
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered
|
||||
world.
|
||||
OpenHands is a community of engineers, academics, and enthusiasts reimagining software development for an AI-powered world.
|
||||
|
||||
## Mission
|
||||
|
||||
It’s very clear that AI is changing software development. We want the developer community to drive that change
|
||||
organically, through open source.
|
||||
It’s very clear that AI is changing software development. We want the developer community to drive that change organically, through open source.
|
||||
|
||||
So we’re not just building friendly interfaces for AI-driven development. We’re publishing _building blocks_ that
|
||||
empower developers to create new experiences, tailored to your own habits, needs, and imagination.
|
||||
So we’re not just building friendly interfaces for AI-driven development. We’re publishing _building blocks_ that empower developers to create new experiences, tailored to your own habits, needs, and imagination.
|
||||
|
||||
## Ethos
|
||||
|
||||
We have two core values: **high openness** and **high agency**. While we don’t expect everyone in the community to
|
||||
embody these values, we want to establish them as norms.
|
||||
We have two core values: **high openness** and **high agency**. While we don’t expect everyone in the community to embody these values, we want to establish them as norms.
|
||||
|
||||
### High Openness
|
||||
|
||||
We welcome anyone and everyone into our community by default. You don’t have to be a software developer to help us
|
||||
build. You don’t have to be pro-AI to help us learn.
|
||||
We welcome anyone and everyone into our community by default. You don’t have to be a software developer to help us build. You don’t have to be pro-AI to help us learn.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
We welcome thoughtful criticism, whether it’s a comment on a PR or feedback on the community as a whole.
|
||||
|
||||
### High Agency
|
||||
|
||||
Everyone should feel empowered to contribute to OpenHands. Whether it’s by making a PR, hosting an event, sharing
|
||||
feedback, or just asking a question, don’t hold back!
|
||||
Everyone should feel empowered to contribute to OpenHands. Whether it’s by making a PR, hosting an event, sharing feedback, or just asking a question, don’t hold back!
|
||||
|
||||
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly
|
||||
and love building new things.
|
||||
OpenHands gives everyone the building blocks to create state-of-the-art developer experiences. We experiment constantly and love building new things.
|
||||
|
||||
Coding, development practices, and communities are changing rapidly. We won’t 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.openhands.dev/).
|
||||
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:
|
||||
|
||||
@@ -46,13 +38,8 @@ All Hands was founded by three of the first major contributors to OpenHands:
|
||||
- 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. We’ve 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/))
|
||||
All Hands is an important part of the OpenHands ecosystem. We’ve 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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -13,32 +13,28 @@ 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/OpenHands/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.
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
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.
|
||||
|
||||
## What Can I Build?
|
||||
|
||||
Here are a few ways you can help improve the codebase.
|
||||
|
||||
#### UI/UX
|
||||
|
||||
We're always looking to improve the look and feel of the application. If you've got a small fix
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
|
||||
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
|
||||
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).
|
||||
|
||||
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
|
||||
@@ -50,12 +46,10 @@ We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. Y
|
||||
channel in Slack to learn more.
|
||||
|
||||
#### Adding a new agent
|
||||
|
||||
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
|
||||
to help expand the capabilities of OpenHands.
|
||||
|
||||
#### Adding a new runtime
|
||||
|
||||
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
|
||||
to do this by default. But there are other ways of creating a sandbox for the agent.
|
||||
|
||||
@@ -63,11 +57,8 @@ If you work for a company that provides a cloud-based runtime, you could help us
|
||||
by implementing the [interface specified here](https://github.com/OpenHands/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.
|
||||
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.
|
||||
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
@@ -75,8 +66,7 @@ You'll need to fork our repository to send us a Pull Request. You can learn more
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
|
||||
|
||||
### Pull Request title
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
@@ -97,7 +87,6 @@ For example, a PR title could be:
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
|
||||
### Pull Request description
|
||||
|
||||
- If your PR is small (such as a typo fix), you can go brief.
|
||||
- If it contains a lot of changes, it's better to write more details.
|
||||
|
||||
@@ -108,9 +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/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.
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
@@ -121,13 +108,11 @@ We're generally happy to consider all pull requests with the evaluation process
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
|
||||
before getting a review.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
|
||||
evaluated based on three key metrics:
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
|
||||
44
CREDITS.md
44
CREDITS.md
@@ -2,13 +2,11 @@
|
||||
|
||||
## 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/OpenHands/OpenHands/graphs/contributors) who have helped make OpenHands possible. We greatly appreciate your dedication and hard work.
|
||||
|
||||
## Open Source Projects
|
||||
|
||||
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the
|
||||
open source community:
|
||||
OpenHands includes and adapts the following open source projects. We are grateful for their contributions to the open source community:
|
||||
|
||||
#### [SWE Agent](https://github.com/princeton-nlp/swe-agent)
|
||||
- License: MIT License
|
||||
@@ -22,8 +20,8 @@ open source community:
|
||||
- License: Apache License 2.0
|
||||
- Description: Adapted in implementing the browsing agent
|
||||
|
||||
### Reference Implementations for Evaluation Benchmarks
|
||||
|
||||
### Reference Implementations for Evaluation Benchmarks
|
||||
OpenHands integrates code of the reference implementations for the following agent evaluation benchmarks:
|
||||
|
||||
#### [HumanEval](https://github.com/openai/human-eval)
|
||||
@@ -54,44 +52,28 @@ OpenHands integrates code of the reference implementations for the following age
|
||||
#### [ProntoQA](https://github.com/asaparov/prontoqa)
|
||||
- License: Apache License 2.0
|
||||
|
||||
|
||||
## Open Source licenses
|
||||
|
||||
### MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
||||
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
### BSD 3-Clause License
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
|
||||
following conditions are met:
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following
|
||||
disclaimer.
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote
|
||||
products derived from this software without specific prior written permission.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
### Apache License 2.0
|
||||
|
||||
@@ -286,6 +268,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
|
||||
|
||||
### Non-Open Source Reference Implementations:
|
||||
|
||||
#### [MultiPL-E](https://github.com/nuprl/MultiPL-E)
|
||||
|
||||
@@ -76,7 +76,7 @@ variables in your terminal. The final configurations are set from highest to low
|
||||
Environment variables > config.toml variables > default variables
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
See [our documentation](https://docs.all-hands.dev/usage/llms) for recommended models.
|
||||
|
||||
### 4. Running the application
|
||||
|
||||
@@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.1-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
@@ -195,7 +195,7 @@ 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/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
|
||||
- [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
|
||||
- [/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
|
||||
|
||||
@@ -3,14 +3,14 @@ These are the procedures and guidelines on how issues are triaged in this repo b
|
||||
|
||||
## General
|
||||
* All issues must be tagged with **enhancement**, **bug** or **troubleshooting/help**.
|
||||
* Issues may be tagged with what it relates to (**llm**, **app tab**, **UI/UX**, etc.).
|
||||
* Issues may be tagged with what it relates to (**agent quality**, **resolver**, **CLI**, etc.).
|
||||
|
||||
## Severity
|
||||
* **High**: High visibility issues or affecting many users.
|
||||
* **Critical**: Affecting all users or potential security issues.
|
||||
|
||||
## Difficulty
|
||||
* Issues good for newcomers may be tagged with **good first issue**.
|
||||
* Issues with low implementation difficulty may be tagged with **good first issue**.
|
||||
|
||||
## Not Enough Information
|
||||
* User is asked to provide more information (logs, how to reproduce, etc.) when the issue is not clear.
|
||||
@@ -22,6 +22,6 @@ the issue may be closed as **not planned** (Usually after a week).
|
||||
* Issues may be broken down into multiple issues if required.
|
||||
|
||||
## Stale and Auto Closures
|
||||
* In order to keep a maintainable backlog, issues that have no activity within 40 days are automatically marked as **Stale**.
|
||||
* If issues marked as **Stale** continue to have no activity for 10 more days, they will automatically be closed as not planned.
|
||||
* In order to keep a maintainable backlog, issues that have no activity within 30 days are automatically marked as **Stale**.
|
||||
* If issues marked as **Stale** continue to have no activity for 7 more days, they will automatically be closed as not planned.
|
||||
* Issues may be reopened by maintainers if deemed important.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<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://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=811504672#gid=811504672"><img src="https://img.shields.io/badge/SWEBench-72.8-00cc00?logoColor=FFE165&style=for-the-badge" alt="Benchmark Score"></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>
|
||||
@@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules.
|
||||
### OpenHands Cloud
|
||||
This is a deployment of OpenHands GUI, running on hosted infrastructure.
|
||||
|
||||
You can try it with a free $10 credit by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
|
||||
You can try it with a free $10 credit by [signing in with your GitHub account](https://app.all-hands.dev).
|
||||
|
||||
OpenHands Cloud comes with source-available features and integrations:
|
||||
- Integrations with Slack, Jira, and Linear
|
||||
|
||||
113
build_vscode.py
Normal file
113
build_vscode.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
# This script is intended to be run by Poetry during the build process.
|
||||
|
||||
# Define the expected name of the .vsix file based on the extension's package.json
|
||||
# This should match the name and version in openhands-vscode/package.json
|
||||
EXTENSION_NAME = 'openhands-vscode'
|
||||
EXTENSION_VERSION = '0.0.1'
|
||||
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
|
||||
|
||||
# Paths
|
||||
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
|
||||
|
||||
|
||||
def check_node_version():
|
||||
"""Check if Node.js version is sufficient for building the extension."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['node', '--version'], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Extract major version number (e.g., "v12.22.9" -> 12)
|
||||
major_version = int(version_str.lstrip('v').split('.')[0])
|
||||
return major_version >= 18 # Align with frontend actual usage (18.20.1)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def build_vscode_extension():
|
||||
"""Builds the VS Code extension."""
|
||||
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
|
||||
|
||||
# Check if VSCode extension build is disabled via environment variable
|
||||
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
|
||||
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
|
||||
if vsix_path.exists():
|
||||
print(f'--- Using existing VS Code extension: {vsix_path} ---')
|
||||
else:
|
||||
print('--- No pre-built VS Code extension found ---')
|
||||
return
|
||||
|
||||
# Check Node.js version - if insufficient, use pre-built extension as fallback
|
||||
if not check_node_version():
|
||||
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
|
||||
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
|
||||
print('--- Using pre-built extension if available ---')
|
||||
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
else:
|
||||
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
|
||||
return
|
||||
|
||||
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
|
||||
|
||||
try:
|
||||
# Ensure npm dependencies are installed
|
||||
print('--- Running npm install for VS Code extension ---')
|
||||
subprocess.run(
|
||||
['npm', 'install'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Package the extension
|
||||
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
|
||||
subprocess.run(
|
||||
['npm', 'run', 'package-vsix'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Verify the generated .vsix file exists
|
||||
if not vsix_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f'VS Code extension package not found after build: {vsix_path}'
|
||||
)
|
||||
|
||||
print(f'--- VS Code extension built successfully: {vsix_path} ---')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
|
||||
print('--- Continuing without building extension ---')
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
|
||||
|
||||
def build(setup_kwargs):
|
||||
"""This function is called by Poetry during the build process.
|
||||
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
|
||||
"""
|
||||
print('--- Running custom Poetry build script (build_vscode.py) ---')
|
||||
|
||||
# Build the VS Code extension and place the .vsix file
|
||||
build_vscode_extension()
|
||||
|
||||
# Poetry will handle including files based on pyproject.toml `include` patterns.
|
||||
# Ensure openhands/integrations/vscode/*.vsix is included there.
|
||||
|
||||
print('--- Custom Poetry build script (build_vscode.py) finished ---')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Running build_vscode.py directly for testing VS Code extension packaging...')
|
||||
build_vscode_extension()
|
||||
print('Direct execution of build_vscode.py finished.')
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_BUILD_VERSION=dev
|
||||
FROM node:25.2-trixie-slim AS frontend-builder
|
||||
FROM node:24.8-trixie-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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/openhands/runtime:0.62-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -10,15 +10,6 @@ repos:
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: warn-appmode-oss
|
||||
name: "Warn on AppMode.OSS in backend (use AppMode.OPENHANDS)"
|
||||
language: system
|
||||
entry: bash -lc 'if rg -n "\\bAppMode\\.OSS\\b" openhands tests/unit; then echo "Found AppMode.OSS usage. Prefer AppMode.OPENHANDS."; exit 1; fi'
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: v2.5.1
|
||||
hooks:
|
||||
|
||||
@@ -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.openhands.dev/openhands/runtime:0.62-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:
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# ============================================
|
||||
# OpenHands Enterprise Local Development
|
||||
# Environment Variables Example
|
||||
# ============================================
|
||||
# Copy this to .env and fill in your values
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# ============================================
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASS=postgres
|
||||
DB_NAME=openhands
|
||||
|
||||
# ============================================
|
||||
# Redis Configuration
|
||||
# ============================================
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# ============================================
|
||||
# Keycloak Configuration
|
||||
# ============================================
|
||||
# For local development with the docker-compose setup
|
||||
KEYCLOAK_SERVER_URL=http://localhost:8080
|
||||
KEYCLOAK_SERVER_URL_EXT=http://localhost:8080
|
||||
KEYCLOAK_REALM_NAME=openhands
|
||||
KEYCLOAK_CLIENT_ID=openhands-client
|
||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
KEYCLOAK_PROVIDER_NAME=github
|
||||
KEYCLOAK_ADMIN_PASSWORD=admin
|
||||
|
||||
# ============================================
|
||||
# LiteLLM Configuration
|
||||
# ============================================
|
||||
LITE_LLM_API_URL=http://localhost:4000
|
||||
LITE_LLM_API_KEY=sk-local-dev-master-key
|
||||
LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514
|
||||
|
||||
# ============================================
|
||||
# OpenHands Configuration
|
||||
# ============================================
|
||||
OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig
|
||||
OPENHANDS_GITHUB_SERVICE_CLS=integrations.github.github_service.SaaSGitHubService
|
||||
OPENHANDS_GITLAB_SERVICE_CLS=integrations.gitlab.gitlab_service.SaaSGitLabService
|
||||
OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService
|
||||
OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator
|
||||
|
||||
# ============================================
|
||||
# GitHub App Configuration (Optional)
|
||||
# Required for GitHub OAuth and GitHub integration features
|
||||
# Create a GitHub App at: https://github.com/settings/apps
|
||||
# ============================================
|
||||
# GITHUB_APP_CLIENT_ID=
|
||||
# GITHUB_APP_CLIENT_SECRET=
|
||||
# GITHUB_APP_WEBHOOK_SECRET=
|
||||
# GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# ============================================
|
||||
# GitLab App Configuration (Optional)
|
||||
# Required for GitLab OAuth and integration features
|
||||
# ============================================
|
||||
# GITLAB_APP_CLIENT_ID=
|
||||
# GITLAB_APP_CLIENT_SECRET=
|
||||
|
||||
# ============================================
|
||||
# Bitbucket App Configuration (Optional)
|
||||
# ============================================
|
||||
# BITBUCKET_APP_CLIENT_ID=
|
||||
# BITBUCKET_APP_CLIENT_SECRET=
|
||||
|
||||
# ============================================
|
||||
# Feature Flags
|
||||
# ============================================
|
||||
ENABLE_BILLING=false
|
||||
HIDE_LLM_SETTINGS=false
|
||||
ENABLE_JIRA=false
|
||||
ENABLE_JIRA_DC=false
|
||||
ENABLE_LINEAR=false
|
||||
LOCAL_DEPLOYMENT=true
|
||||
|
||||
# ============================================
|
||||
# Frontend Configuration
|
||||
# ============================================
|
||||
# Path to the frontend build directory
|
||||
# FRONTEND_DIRECTORY=../frontend/build
|
||||
|
||||
# ============================================
|
||||
# Logging
|
||||
# ============================================
|
||||
LOG_PLAIN_TEXT=1
|
||||
|
||||
# ============================================
|
||||
# Runtime Configuration
|
||||
# ============================================
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
|
||||
# ============================================
|
||||
# PostHog (Analytics - can use dummy for local)
|
||||
# ============================================
|
||||
POSTHOG_CLIENT_KEY=test
|
||||
|
||||
# ============================================
|
||||
# LLM API Keys (for LiteLLM proxy)
|
||||
# Add these to litellm container environment
|
||||
# ============================================
|
||||
# OPENAI_API_KEY=
|
||||
# ANTHROPIC_API_KEY=
|
||||
# GOOGLE_API_KEY=
|
||||
|
||||
# ============================================
|
||||
# CORS Configuration
|
||||
# ============================================
|
||||
WEB_HOST=localhost:3000
|
||||
PERMITTED_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
@@ -24,15 +24,16 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python packages with security fixes
|
||||
RUN /app/.venv/bin/pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy google-cloud-recaptcha-enterprise && \
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
# Update packages with known CVE fixes
|
||||
/app/.venv/bin/pip install --upgrade \
|
||||
pip install --upgrade \
|
||||
"mcp>=1.10.0" \
|
||||
"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
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
# OpenHands Enterprise Local Development Setup
|
||||
|
||||
This guide provides instructions for setting up OpenHands Enterprise locally for development.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** and **Docker Compose** (v2+)
|
||||
- **Python 3.12+**
|
||||
- **Poetry** (Python package manager)
|
||||
- **Node.js 18+** and **npm** (for frontend)
|
||||
- An LLM API key (OpenAI, Anthropic, or other supported provider)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start Infrastructure Services
|
||||
|
||||
Start all required services using Docker Compose:
|
||||
|
||||
```bash
|
||||
cd enterprise
|
||||
|
||||
# Start PostgreSQL, Redis, Keycloak, and LiteLLM
|
||||
docker-compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- **PostgreSQL** on port `5432` (database for OpenHands and Keycloak)
|
||||
- **Redis** on port `6379` (caching and pub/sub)
|
||||
- **Keycloak** on port `8080` (authentication)
|
||||
- **LiteLLM** on port `4000` (LLM proxy)
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Copy the example environment file and configure it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your LLM API keys. For LiteLLM to work, you need at least one of:
|
||||
- `OPENAI_API_KEY` - for GPT models
|
||||
- `ANTHROPIC_API_KEY` - for Claude models
|
||||
|
||||
Update the docker-compose to include your API keys:
|
||||
|
||||
```bash
|
||||
# Add to litellm service environment in docker-compose.local.yml
|
||||
# Or create a .env file for docker-compose
|
||||
|
||||
# Then restart litellm
|
||||
docker-compose -f docker-compose.local.yml up -d litellm
|
||||
```
|
||||
|
||||
### 3. Initialize the Database
|
||||
|
||||
Run Alembic migrations to set up the database schema:
|
||||
|
||||
```bash
|
||||
cd enterprise
|
||||
poetry install
|
||||
poetry run alembic upgrade head
|
||||
```
|
||||
|
||||
### 4. Configure Keycloak (Authentication)
|
||||
|
||||
#### Option A: Skip Keycloak (Simplified Setup)
|
||||
|
||||
For basic local development without authentication, you can skip Keycloak by not setting `GITHUB_APP_CLIENT_ID` and related variables. The app will still work but OAuth login won't be available.
|
||||
|
||||
#### Option B: Set Up Keycloak Realm
|
||||
|
||||
1. Access Keycloak Admin Console at `http://localhost:8080`
|
||||
2. Login with `admin` / `admin`
|
||||
3. Create a new realm called `openhands`
|
||||
4. Create a client:
|
||||
- Client ID: `openhands-client`
|
||||
- Client authentication: ON
|
||||
- Valid redirect URIs: `http://localhost:3000/*`, `http://localhost:3001/*`
|
||||
5. Copy the client secret and update your `.env`:
|
||||
```
|
||||
KEYCLOAK_CLIENT_SECRET=<your-client-secret>
|
||||
```
|
||||
|
||||
### 5. Build the Frontend
|
||||
|
||||
```bash
|
||||
# From the repository root (not enterprise/)
|
||||
cd ..
|
||||
make build-frontend
|
||||
# OR
|
||||
cd frontend && npm install && npm run build
|
||||
```
|
||||
|
||||
### 6. Start the Enterprise Backend
|
||||
|
||||
```bash
|
||||
cd enterprise
|
||||
|
||||
# Option A: Using Make
|
||||
OPENHANDS_PATH=../ make start-backend
|
||||
|
||||
# Option B: Using Poetry directly
|
||||
FRONTEND_DIRECTORY=../frontend/build poetry run uvicorn saas_server:app --host 127.0.0.1 --port 3000 --reload
|
||||
```
|
||||
|
||||
### 7. Access the Application
|
||||
|
||||
- **Frontend**: http://localhost:3000 (or 3001 if using dev server)
|
||||
- **Backend API**: http://localhost:3000/api
|
||||
- **Keycloak Admin**: http://localhost:8080
|
||||
- **LiteLLM Proxy**: http://localhost:4000
|
||||
|
||||
## Individual Service Setup
|
||||
|
||||
If you prefer to run services individually:
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name openhands-postgres \
|
||||
-p 5432:5432 \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=openhands \
|
||||
postgres:15
|
||||
```
|
||||
|
||||
### Redis
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name openhands-redis \
|
||||
-p 6379:6379 \
|
||||
redis:7-alpine
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name openhands-keycloak \
|
||||
-p 8080:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
-e KC_HTTP_ENABLED=true \
|
||||
-e KC_HOSTNAME_STRICT=false \
|
||||
quay.io/keycloak/keycloak:26.1.1 start-dev
|
||||
```
|
||||
|
||||
### LiteLLM
|
||||
|
||||
```bash
|
||||
# Create litellm-config.yaml first (see file in this directory)
|
||||
docker run -d \
|
||||
--name openhands-litellm \
|
||||
-p 4000:4000 \
|
||||
-v $(pwd)/litellm-config.yaml:/app/config.yaml \
|
||||
-e LITELLM_MASTER_KEY=sk-local-dev-master-key \
|
||||
-e OPENAI_API_KEY=your-openai-key \
|
||||
-e ANTHROPIC_API_KEY=your-anthropic-key \
|
||||
ghcr.io/berriai/litellm:main-latest \
|
||||
--config /app/config.yaml --port 4000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
## Configuration Details
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DB_HOST` | PostgreSQL host | `localhost` |
|
||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||
| `DB_USER` | PostgreSQL user | `postgres` |
|
||||
| `DB_PASS` | PostgreSQL password | `postgres` |
|
||||
| `DB_NAME` | PostgreSQL database name | `openhands` |
|
||||
| `REDIS_HOST` | Redis host | `localhost` |
|
||||
| `REDIS_PORT` | Redis port | `6379` |
|
||||
| `KEYCLOAK_SERVER_URL` | Keycloak internal URL | - |
|
||||
| `KEYCLOAK_SERVER_URL_EXT` | Keycloak external URL | - |
|
||||
| `KEYCLOAK_REALM_NAME` | Keycloak realm | - |
|
||||
| `KEYCLOAK_CLIENT_ID` | Keycloak client ID | - |
|
||||
| `KEYCLOAK_CLIENT_SECRET` | Keycloak client secret | - |
|
||||
| `LITE_LLM_API_URL` | LiteLLM proxy URL | `http://localhost:4000` |
|
||||
| `LITE_LLM_API_KEY` | LiteLLM master key | - |
|
||||
| `LITELLM_DEFAULT_MODEL` | Default model to use | `litellm_proxy/claude-sonnet-4-20250514` |
|
||||
| `OPENHANDS_CONFIG_CLS` | Server config class | `server.config.SaaSServerConfig` |
|
||||
| `LOCAL_DEPLOYMENT` | Flag for local mode | `true` |
|
||||
|
||||
### LiteLLM Configuration
|
||||
|
||||
The `litellm-config.yaml` file defines which LLM models are available through the proxy. You can add or modify models as needed.
|
||||
|
||||
To test if LiteLLM is working:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4000/v1/models
|
||||
```
|
||||
|
||||
### Keycloak Realm Setup for GitHub OAuth
|
||||
|
||||
To enable GitHub OAuth through Keycloak:
|
||||
|
||||
1. Create a GitHub OAuth App at https://github.com/settings/developers
|
||||
2. In Keycloak:
|
||||
- Go to Identity Providers > Add provider > GitHub
|
||||
- Enter your GitHub Client ID and Secret
|
||||
- Set redirect URI to match your Keycloak URL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker ps | grep postgres
|
||||
|
||||
# Check logs
|
||||
docker logs openhands-postgres
|
||||
|
||||
# Test connection
|
||||
psql -h localhost -U postgres -d openhands
|
||||
```
|
||||
|
||||
### Redis Connection Issues
|
||||
|
||||
```bash
|
||||
# Check if Redis is running
|
||||
docker ps | grep redis
|
||||
|
||||
# Test connection
|
||||
redis-cli -h localhost ping
|
||||
```
|
||||
|
||||
### Keycloak Issues
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs openhands-keycloak
|
||||
|
||||
# Access admin console
|
||||
open http://localhost:8080/admin
|
||||
```
|
||||
|
||||
### LiteLLM Issues
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs openhands-litellm
|
||||
|
||||
# Check health
|
||||
curl http://localhost:4000/health
|
||||
|
||||
# List available models
|
||||
curl http://localhost:4000/v1/models
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Connection refused" errors**: Ensure all Docker containers are running and ports are not blocked.
|
||||
|
||||
2. **Authentication failures**: Check Keycloak configuration and client credentials.
|
||||
|
||||
3. **LLM errors**: Verify your API keys are correctly set in the LiteLLM environment.
|
||||
|
||||
4. **Database migration errors**: Ensure PostgreSQL is running before running migrations.
|
||||
|
||||
## Stopping Services
|
||||
|
||||
```bash
|
||||
# Stop all services
|
||||
docker-compose -f docker-compose.local.yml down
|
||||
|
||||
# Stop and remove volumes (clears all data)
|
||||
docker-compose -f docker-compose.local.yml down -v
|
||||
```
|
||||
|
||||
## Development Tips
|
||||
|
||||
1. **Hot reloading**: Use `--reload` flag with uvicorn for automatic server restarts.
|
||||
|
||||
2. **Frontend development**: Run the frontend dev server separately for faster iteration:
|
||||
```bash
|
||||
cd ../frontend && npm run dev
|
||||
```
|
||||
|
||||
3. **Database changes**: After modifying models, create a new migration:
|
||||
```bash
|
||||
poetry run alembic revision --autogenerate -m "description"
|
||||
poetry run alembic upgrade head
|
||||
```
|
||||
|
||||
4. **Debugging**: Set `LOG_PLAIN_TEXT=1` for readable logs.
|
||||
|
||||
## Minimal Setup (Without Authentication)
|
||||
|
||||
For the simplest possible setup without OAuth:
|
||||
|
||||
```bash
|
||||
# Start only PostgreSQL and Redis
|
||||
docker run -d --name openhands-postgres -p 5432:5432 \
|
||||
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=openhands postgres:15
|
||||
|
||||
docker run -d --name openhands-redis -p 6379:6379 redis:7-alpine
|
||||
|
||||
# Create minimal .env
|
||||
cat > .env << 'EOF'
|
||||
DB_HOST=localhost
|
||||
REDIS_HOST=localhost
|
||||
OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig
|
||||
LOCAL_DEPLOYMENT=true
|
||||
POSTHOG_CLIENT_KEY=test
|
||||
LOG_PLAIN_TEXT=1
|
||||
EOF
|
||||
|
||||
# Run migrations and start server
|
||||
poetry install
|
||||
poetry run alembic upgrade head
|
||||
FRONTEND_DIRECTORY=../frontend/build poetry run uvicorn saas_server:app --host 0.0.0.0 --port 3000 --reload
|
||||
```
|
||||
|
||||
This provides a working enterprise server without Keycloak authentication or LiteLLM proxy. You can configure LLM directly through the UI or environment variables.
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenHands Enterprise Server
|
||||
> [!WARNING]
|
||||
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.openhands.dev/contact).
|
||||
> This software is licensed under the [Polyform Free Trial License](./LICENSE). This is **NOT** an open source license. Usage is limited to 30 days per calendar year without a commercial license. If you would like to use it beyond 30 days, please [contact us](https://www.all-hands.dev/contact).
|
||||
|
||||
> [!WARNING]
|
||||
> This is a work in progress and may contain bugs, incomplete features, or breaking changes.
|
||||
@@ -10,13 +10,13 @@ This directory contains the enterprise server used by [OpenHands Cloud](https://
|
||||
|
||||
You may also want to check out the MIT-licensed [OpenHands](https://github.com/OpenHands/OpenHands)
|
||||
|
||||
## Extension of OpenHands
|
||||
## Extension of OpenHands (OSS)
|
||||
|
||||
The code in `/enterprise` builds on top of OpenHands (MIT-licensed), extending its functionality. The enterprise code is entangled with OpenHands in two ways:
|
||||
The code in `/enterprise` directory builds on top of open source (OSS) code, extending its functionality. The enterprise code is entangled with the OSS code in two ways
|
||||
|
||||
- Enterprise stacks on top of OpenHands. For example, the middleware in enterprise is stacked right on top of the middlewares in OpenHands. In `SAAS`, the middleware from BOTH repos will be present and running (which can sometimes cause conflicts)
|
||||
- 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 OpenHands (only one is present at a time). For example, the server config SaasServerConfig overrides [`ServerConfig`](https://github.com/OpenHands/OpenHands/blob/main/openhands/server/config/server_config.py#L8) in OpenHands. 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/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))
|
||||
|
||||
Key areas that change on `SAAS` are
|
||||
|
||||
@@ -26,11 +26,11 @@ Key areas that change on `SAAS` are
|
||||
|
||||
### Authentication
|
||||
|
||||
| Aspect | OpenHands | Enterprise |
|
||||
| Aspect | OSS | Enterprise |
|
||||
| ------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The GitHub app provides a short-lived access token and refresh token |
|
||||
| **Authentication Method** | User adds a personal access token (PAT) through the UI | User performs OAuth through the UI. The Github app provides a short-lived access token and refresh token |
|
||||
| **Token Storage** | PAT is stored in **Settings** | Token is stored in **GithubTokenManager** (a file store in our backend) |
|
||||
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during OAuth, so subsequent requests with the cookie can be considered authenticated |
|
||||
| **Authenticated status** | We simply check if token exists in `Settings` | We issue a signed cookie with `github_user_id` during oauth, so subsequent requests with the cookie can be considered authenticated |
|
||||
|
||||
Note that in the future, authentication will happen via keycloak. All modifications for authentication will happen in enterprise.
|
||||
|
||||
@@ -38,7 +38,7 @@ Note that in the future, authentication will happen via keycloak. All modificati
|
||||
|
||||
The github service is responsible for interacting with Github APIs. As a consequence, it uses the user's token and refreshes it if need be
|
||||
|
||||
| Aspect | OpenHands | Enterprise |
|
||||
| Aspect | OSS | Enterprise |
|
||||
| ------------------------- | -------------------------------------- | ---------------------------------------------- |
|
||||
| **Class used** | `GitHubService` | `SaaSGitHubService` |
|
||||
| **Token used** | User's PAT fetched from `Settings` | User's token fetched from `GitHubTokenManager` |
|
||||
@@ -50,7 +50,7 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
|
||||
|
||||
## User ID vs User Token
|
||||
|
||||
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- On OSS, the entire APP revolves around the Github token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
|
||||
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.
|
||||
Note that introducing Github User ID on OSS, for instance, will cause large breakages.
|
||||
|
||||
@@ -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/*"
|
||||
],
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Docker Compose for running OpenHands Enterprise locally
|
||||
# Usage: docker-compose -f docker-compose.local.yml up -d
|
||||
|
||||
services:
|
||||
# PostgreSQL database
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: openhands-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: openhands
|
||||
# Create additional databases for keycloak and litellm
|
||||
POSTGRES_MULTIPLE_DATABASES: keycloak,litellm
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis for caching and pub/sub
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: openhands-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Keycloak for authentication
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.1.1
|
||||
container_name: openhands-keycloak
|
||||
environment:
|
||||
# Admin credentials
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
# Database configuration (using embedded H2 for simplicity)
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
|
||||
KC_DB_USERNAME: postgres
|
||||
KC_DB_PASSWORD: postgres
|
||||
# Features
|
||||
KC_FEATURES: token-exchange
|
||||
KC_HEALTH_ENABLED: true
|
||||
KC_METRICS_ENABLED: true
|
||||
# HTTP settings (for local dev, disable HTTPS requirement)
|
||||
KC_HTTP_ENABLED: true
|
||||
KC_HTTP_PORT: 8080
|
||||
KC_HOSTNAME_STRICT: false
|
||||
KC_HOSTNAME_STRICT_HTTPS: false
|
||||
KC_PROXY_HEADERS: xforwarded
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command: start-dev
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# LiteLLM Proxy for LLM routing
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-latest
|
||||
container_name: openhands-litellm
|
||||
environment:
|
||||
# Master key for admin access
|
||||
LITELLM_MASTER_KEY: sk-local-dev-master-key
|
||||
# Database for tracking spend/keys (using same postgres)
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/litellm
|
||||
# General settings
|
||||
LITELLM_LOG: DEBUG
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./litellm-config.yaml:/app/config.yaml
|
||||
command: --config /app/config.yaml --port 4000 --host 0.0.0.0 --detailed_debug
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: openhands-network
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
You have a few options here, which are expanded on below:
|
||||
|
||||
- A simple local development setup, with live reloading for both OpenHands and this repo
|
||||
- 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
|
||||
|
||||
@@ -26,7 +26,7 @@ Before starting, make sure you have the following tools installed:
|
||||
|
||||
## Option 1: Simple local development
|
||||
|
||||
This option will allow you to modify both the OpenHands code and the code in this repo,
|
||||
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
|
||||
@@ -50,7 +50,7 @@ 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
|
||||
local/decrypt_env.sh
|
||||
```
|
||||
|
||||
Now run this to generate a `.env` file, which will used to run SAAS locally
|
||||
@@ -105,9 +105,9 @@ export REDIS_PORT=6379
|
||||
|
||||
(see above)
|
||||
|
||||
### 2. Build OpenHands
|
||||
### 2. Build OSS Openhands
|
||||
|
||||
Develop on [Openhands](https://github.com/OpenHands/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
|
||||
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 .
|
||||
@@ -155,7 +155,7 @@ Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-
|
||||
|
||||
### 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 OpenHands running.
|
||||
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
|
||||
|
||||
@@ -201,8 +201,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"DEBUG": "1",
|
||||
"FILE_STORE": "local",
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"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",
|
||||
@@ -235,8 +235,8 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"DEBUG": "1",
|
||||
"FILE_STORE": "local",
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"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",
|
||||
|
||||
@@ -116,7 +116,7 @@ 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('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
|
||||
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
|
||||
lines.append('LOCAL_DEPLOYMENT=true')
|
||||
lines.append('DB_HOST=localhost')
|
||||
|
||||
4
enterprise/enterprise_local/decrypt_env.sh
Executable file → Normal file
4
enterprise/enterprise_local/decrypt_env.sh
Executable file → Normal file
@@ -4,12 +4,12 @@ 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"
|
||||
echo "Example: $0 /path/to/deploy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Normalize path (remove trailing slash)
|
||||
DEPLOY_DIR="${1%/}"
|
||||
DEPLOY_DIR="${DEPLOY_DIR%/}"
|
||||
|
||||
# Function to decrypt and rename
|
||||
decrypt_and_move() {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Initialize additional databases for OpenHands Enterprise
|
||||
|
||||
set -e
|
||||
|
||||
# Create keycloak database
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE DATABASE keycloak;
|
||||
CREATE DATABASE litellm;
|
||||
GRANT ALL PRIVILEGES ON DATABASE keycloak TO $POSTGRES_USER;
|
||||
GRANT ALL PRIVILEGES ON DATABASE litellm TO $POSTGRES_USER;
|
||||
EOSQL
|
||||
|
||||
echo "Additional databases created: keycloak, litellm"
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from github import Github, GithubIntegration
|
||||
from integrations.github.github_view import (
|
||||
GithubIssue,
|
||||
)
|
||||
@@ -84,7 +84,7 @@ class GitHubDataCollector:
|
||||
# self.full_saved_pr_path = 'github_data/prs/{}-{}/data.json'
|
||||
self.full_saved_pr_path = 'prs/github/{}-{}/data.json'
|
||||
self.github_integration = GithubIntegration(
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
)
|
||||
self.conversation_id = None
|
||||
|
||||
@@ -143,7 +143,7 @@ class GitHubDataCollector:
|
||||
try:
|
||||
installation_token = self._get_installation_access_token(installation_id)
|
||||
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
with Github(installation_token) as github_client:
|
||||
repo = github_client.get_repo(repo_name)
|
||||
issue = repo.get_issue(issue_number)
|
||||
comments = []
|
||||
@@ -237,7 +237,7 @@ class GitHubDataCollector:
|
||||
def _get_pr_commits(self, installation_id: str, repo_name: str, pr_number: int):
|
||||
commits = []
|
||||
installation_token = self._get_installation_access_token(installation_id)
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
with Github(installation_token) as github_client:
|
||||
repo = github_client.get_repo(repo_name)
|
||||
pr = repo.get_pull(pr_number)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from types import MappingProxyType
|
||||
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from github import Github, GithubIntegration
|
||||
from integrations.github.data_collector import GitHubDataCollector
|
||||
from integrations.github.github_solvability import summarize_issue_solvability
|
||||
from integrations.github.github_view import (
|
||||
@@ -21,9 +21,7 @@ from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
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,11 +30,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,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
@@ -48,7 +42,7 @@ class GithubManager(Manager):
|
||||
self.token_manager = token_manager
|
||||
self.data_collector = data_collector
|
||||
self.github_integration = GithubIntegration(
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
)
|
||||
|
||||
self.jinja_env = Environment(
|
||||
@@ -82,7 +76,7 @@ class GithubManager(Manager):
|
||||
reaction: The reaction to add (e.g. "eyes", "+1", "-1", "laugh", "confused", "heart", "hooray", "rocket")
|
||||
installation_token: GitHub installation access token for API access
|
||||
"""
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
with Github(installation_token) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
# Add reaction based on view type
|
||||
if isinstance(github_view, GithubInlinePRComment):
|
||||
@@ -170,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}'
|
||||
@@ -204,7 +193,7 @@ class GithubManager(Manager):
|
||||
outgoing_message = message.message
|
||||
|
||||
if isinstance(github_view, GithubInlinePRComment):
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
with Github(installation_token) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
pr = repo.get_pull(github_view.issue_number)
|
||||
pr.create_review_comment_reply(
|
||||
@@ -216,7 +205,7 @@ class GithubManager(Manager):
|
||||
or isinstance(github_view, GithubIssueComment)
|
||||
or isinstance(github_view, GithubIssue)
|
||||
):
|
||||
with Github(auth=Auth.Token(installation_token)) as github_client:
|
||||
with Github(installation_token) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(number=github_view.issue_number)
|
||||
issue.create_comment(outgoing_message)
|
||||
@@ -293,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
|
||||
@@ -310,7 +292,14 @@ class GithubManager(Manager):
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
if not github_view.v1_enabled:
|
||||
from openhands.server.shared import ConversationStoreImpl, config
|
||||
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, github_view.user_info.keycloak_user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
|
||||
if metadata.conversation_version != 'v1':
|
||||
# Create a GithubCallbackProcessor
|
||||
processor = GithubCallbackProcessor(
|
||||
github_view=github_view,
|
||||
@@ -347,13 +336,6 @@ class GithubManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[GitHub] Session expired for user {user_info.username}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, github_view)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from github import Auth, Github
|
||||
from github import Github
|
||||
from integrations.github.github_view import (
|
||||
GithubInlinePRComment,
|
||||
GithubIssueComment,
|
||||
@@ -47,7 +47,7 @@ def fetch_github_issue_context(
|
||||
context_parts.append(f'Title: {github_view.title}')
|
||||
context_parts.append(f'Description:\n{github_view.description}')
|
||||
|
||||
with Github(auth=Auth.Token(user_token)) as github_client:
|
||||
with Github(user_token) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(github_view.issue_number)
|
||||
if issue.labels:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from github import Github, GithubIntegration
|
||||
from github.Issue import Issue
|
||||
from integrations.github.github_types import (
|
||||
WorkflowRun,
|
||||
@@ -9,18 +8,16 @@ 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,
|
||||
get_user_v1_enabled_setting,
|
||||
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
|
||||
@@ -37,16 +34,18 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
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.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
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.sdk.conversation.secret_source import SecretSource
|
||||
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,
|
||||
@@ -56,8 +55,50 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
async def is_v1_enabled_for_github_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITHUB_RESOLVER
|
||||
class GithubUserContext(UserContext):
|
||||
"""User context for GitHub integration that provides user info without web request."""
|
||||
|
||||
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
|
||||
self.keycloak_user_id = keycloak_user_id
|
||||
self.git_provider_tokens = git_provider_tokens
|
||||
self.settings_store = SaasSettingsStore(
|
||||
user_id=self.keycloak_user_id,
|
||||
session_maker=session_maker,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
self.secrets_store = SaasSecretsStore(
|
||||
self.keycloak_user_id, session_maker, get_config()
|
||||
)
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
return self.keycloak_user_id
|
||||
|
||||
async def get_user_info(self) -> UserInfo:
|
||||
user_settings = await self.settings_store.load()
|
||||
return UserInfo(
|
||||
id=self.keycloak_user_id,
|
||||
**user_settings.model_dump(context={'expose_secrets': True}),
|
||||
)
|
||||
|
||||
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
|
||||
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
|
||||
return self.git_provider_tokens.get(ProviderType.GITHUB)
|
||||
return None
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
# Return empty dict for now - GitHub integration handles secrets separately
|
||||
user_secrets = await self.secrets_store.load()
|
||||
return dict(user_secrets.custom_secrets) if user_secrets else {}
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
@@ -93,6 +134,35 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
return settings.enable_proactive_conversation_starters
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str | None) -> 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
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
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
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Github view types
|
||||
# =================================================
|
||||
@@ -113,10 +183,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
title: str
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return getattr(self, 'branch_name', None)
|
||||
|
||||
async def _load_resolver_context(self):
|
||||
github_service = GithubServiceImpl(
|
||||
@@ -163,29 +229,11 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
self.conversation_id = uuid4().hex
|
||||
return ConversationMetadata(
|
||||
conversation_id=self.conversation_id,
|
||||
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,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
selected_branch=None,
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
@@ -197,20 +245,24 @@ class GithubIssue(ResolverViewInterface):
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
|
||||
|
||||
if v1_enabled:
|
||||
try:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, git_provider_tokens, 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
|
||||
)
|
||||
if self.v1_enabled:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
else:
|
||||
await self._create_v0_conversation(
|
||||
jinja_env, git_provider_tokens, conversation_metadata
|
||||
)
|
||||
|
||||
async def _create_v0_conversation(
|
||||
self,
|
||||
@@ -219,7 +271,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
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(
|
||||
@@ -241,12 +292,10 @@ class GithubIssue(ResolverViewInterface):
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
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
|
||||
)
|
||||
@@ -268,7 +317,6 @@ class GithubIssue(ResolverViewInterface):
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
title=f'GitHub Issue #{self.issue_number}: {self.title}',
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
@@ -278,7 +326,10 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
github_user_context = GithubUserContext(
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -295,7 +346,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from integrations.github.github_v1_callback_processor import (
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
GithubV1CallbackProcessor,
|
||||
)
|
||||
|
||||
@@ -363,6 +414,20 @@ class GithubPRComment(GithubIssueComment):
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# 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,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self.branch_name,
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
|
||||
@dataclass
|
||||
class GithubInlinePRComment(GithubPRComment):
|
||||
@@ -412,7 +477,7 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
|
||||
def _create_github_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for GitHub integration."""
|
||||
from integrations.github.github_v1_callback_processor import (
|
||||
from openhands.app_server.event_callback.github_v1_callback_processor import (
|
||||
GithubV1CallbackProcessor,
|
||||
)
|
||||
|
||||
@@ -677,13 +742,13 @@ class GithubFactory:
|
||||
|
||||
def _interact_with_github() -> Issue | None:
|
||||
with GithubIntegration(
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
) as integration:
|
||||
access_token = integration.get_access_token(
|
||||
payload['installation']['id']
|
||||
).token
|
||||
|
||||
with Github(auth=Auth.Token(access_token)) as gh:
|
||||
with Github(access_token) as gh:
|
||||
repo = gh.get_repo(selected_repo)
|
||||
login = (
|
||||
payload['organization']['login']
|
||||
@@ -741,7 +806,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).
|
||||
@@ -751,10 +816,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']
|
||||
@@ -778,7 +850,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@@ -804,7 +875,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@@ -815,12 +885,12 @@ class GithubFactory:
|
||||
|
||||
access_token = ''
|
||||
with GithubIntegration(
|
||||
auth=Auth.AppAuth(GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY)
|
||||
GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
) as integration:
|
||||
access_token = integration.get_access_token(installation_id).token
|
||||
|
||||
head_ref = None
|
||||
with Github(auth=Auth.Token(access_token)) as gh:
|
||||
with Github(access_token) as gh:
|
||||
repo = gh.get_repo(selected_repo)
|
||||
pull_request = repo.get_pull(issue_number)
|
||||
head_ref = pull_request.head.ref
|
||||
@@ -846,7 +916,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@@ -880,7 +949,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -15,7 +15,6 @@ from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
@@ -25,11 +24,7 @@ from server.utils.conversation_callback_utils import register_callback_processor
|
||||
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,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@@ -254,13 +249,6 @@ class GitlabManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[GitLab] Session expired for user {user_info.username}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.username)
|
||||
|
||||
# Send the acknowledgment message
|
||||
msg = self.create_outgoing_message(msg_info)
|
||||
await self.send_message(msg, gitlab_view)
|
||||
|
||||
@@ -80,52 +80,22 @@ class SaaSGitLabService(GitLabService):
|
||||
logger.warning('external_auth_token and user_id not set!')
|
||||
return gitlab_token
|
||||
|
||||
async def get_owned_groups(self, min_access_level: int = 40) -> list[dict]:
|
||||
async def get_owned_groups(self) -> list[dict]:
|
||||
"""
|
||||
Get all top-level groups where the current user has admin access.
|
||||
|
||||
This method supports pagination and fetches all groups where the user has
|
||||
at least the specified access level.
|
||||
|
||||
Args:
|
||||
min_access_level: Minimum access level required (default: 40 for Maintainer or Owner)
|
||||
- 40: Maintainer or Owner
|
||||
- 50: Owner only
|
||||
Get all groups for which the current user is the owner.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of groups where user has the specified access level or higher.
|
||||
list[dict]: A list of groups owned by the current user.
|
||||
"""
|
||||
groups_with_admin_access = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {'owned': 'true', 'per_page': 100, 'top_level_only': 'true'}
|
||||
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/groups'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'min_access_level': min_access_level,
|
||||
'top_level_only': 'true',
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
groups_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching groups on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
return groups_with_admin_access
|
||||
try:
|
||||
response, headers = await self._make_request(url, params)
|
||||
return response
|
||||
except Exception:
|
||||
logger.warning('Error fetching owned groups', exc_info=True)
|
||||
return []
|
||||
|
||||
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
|
||||
"""
|
||||
@@ -557,55 +527,3 @@ class SaaSGitLabService(GitLabService):
|
||||
await self._make_request(url=url, params=params, method=RequestMethod.POST)
|
||||
except Exception as e:
|
||||
logger.exception(f'[GitLab]: Reply to MR failed {e}')
|
||||
|
||||
async def get_user_resources_with_admin_access(
|
||||
self,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Get all projects and groups where the current user has admin access (maintainer or owner).
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], list[dict]]: A tuple containing:
|
||||
- list of projects where user has admin access
|
||||
- list of groups where user has admin access
|
||||
"""
|
||||
projects_with_admin_access = []
|
||||
groups_with_admin_access = []
|
||||
|
||||
# Fetch all projects the user is a member of
|
||||
page = 1
|
||||
per_page = 100
|
||||
while True:
|
||||
try:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'membership': 1,
|
||||
'min_access_level': 40, # Maintainer or Owner
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
if not response:
|
||||
break
|
||||
|
||||
projects_with_admin_access.extend(response)
|
||||
page += 1
|
||||
|
||||
# Check if we've reached the last page
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
except Exception:
|
||||
logger.warning(f'Error fetching projects on page {page}', exc_info=True)
|
||||
break
|
||||
|
||||
# Fetch all groups where user is owner or maintainer
|
||||
groups_with_admin_access = await self.get_owned_groups(min_access_level=40)
|
||||
|
||||
logger.info(
|
||||
f'Found {len(projects_with_admin_access)} projects and {len(groups_with_admin_access)} groups with admin access'
|
||||
)
|
||||
|
||||
return projects_with_admin_access, groups_with_admin_access
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Shared utilities for GitLab webhook installation.
|
||||
|
||||
This module contains reusable functions and classes for installing GitLab webhooks
|
||||
that can be used by both the cron job and API routes.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import GitService
|
||||
|
||||
# Webhook configuration constants
|
||||
WEBHOOK_NAME = 'OpenHands Resolver'
|
||||
SCOPES: list[str] = [
|
||||
'note_events',
|
||||
'merge_requests_events',
|
||||
'confidential_issues_events',
|
||||
'issues_events',
|
||||
'confidential_note_events',
|
||||
'job_events',
|
||||
'pipeline_events',
|
||||
]
|
||||
|
||||
|
||||
class BreakLoopException(Exception):
|
||||
"""Exception raised when webhook installation conditions are not met or rate limited."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
async def verify_webhook_conditions(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> None:
|
||||
"""
|
||||
Verify all conditions are met for webhook installation.
|
||||
Raises BreakLoopException if any condition fails or rate limited.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to verify
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
# Check if resource exists
|
||||
does_resource_exist, status = await gitlab_service.check_resource_exists(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does resource exists',
|
||||
extra={
|
||||
'does_resource_exist': does_resource_exist,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not does_resource_exist and status != WebhookStatus.RATE_LIMITED:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if user has admin access
|
||||
(
|
||||
is_user_admin_of_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Is user admin',
|
||||
extra={
|
||||
'is_user_admin': is_user_admin_of_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if not is_user_admin_of_resource:
|
||||
await webhook_store.delete_webhook(webhook)
|
||||
raise BreakLoopException()
|
||||
|
||||
# Check if webhook already exists
|
||||
(
|
||||
does_webhook_exist_on_resource,
|
||||
status,
|
||||
) = await gitlab_service.check_webhook_exists_on_resource(
|
||||
resource_type, resource_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Does webhook already exist',
|
||||
extra={
|
||||
'does_webhook_exist_on_resource': does_webhook_exist_on_resource,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
if does_webhook_exist_on_resource != webhook.webhook_exists:
|
||||
await webhook_store.update_webhook(
|
||||
webhook, {'webhook_exists': does_webhook_exist_on_resource}
|
||||
)
|
||||
|
||||
if does_webhook_exist_on_resource:
|
||||
raise BreakLoopException()
|
||||
|
||||
|
||||
async def install_webhook_on_resource(
|
||||
gitlab_service: type[GitService],
|
||||
resource_type: GitLabResourceType,
|
||||
resource_id: str,
|
||||
webhook_store: GitlabWebhookStore,
|
||||
webhook: GitlabWebhook,
|
||||
) -> tuple[str | None, WebhookStatus | None]:
|
||||
"""
|
||||
Install webhook on a GitLab resource.
|
||||
|
||||
Args:
|
||||
gitlab_service: GitLab service instance
|
||||
resource_type: Type of resource (PROJECT or GROUP)
|
||||
resource_id: ID of the resource
|
||||
webhook_store: Webhook store instance
|
||||
webhook: Webhook object to install
|
||||
|
||||
Returns:
|
||||
Tuple of (webhook_id, status)
|
||||
"""
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
webhook_secret = f'{webhook.user_id}-{str(uuid4())}'
|
||||
webhook_uuid = f'{str(uuid4())}'
|
||||
|
||||
webhook_id, status = await gitlab_service.install_webhook(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_name=WEBHOOK_NAME,
|
||||
webhook_url=GITLAB_WEBHOOK_URL,
|
||||
webhook_secret=webhook_secret,
|
||||
webhook_uuid=webhook_uuid,
|
||||
scopes=SCOPES,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Creating new webhook',
|
||||
extra={
|
||||
'webhook_id': webhook_id,
|
||||
'status': status,
|
||||
'resource_id': resource_id,
|
||||
'resource_type': resource_type,
|
||||
},
|
||||
)
|
||||
|
||||
if status == WebhookStatus.RATE_LIMITED:
|
||||
raise BreakLoopException()
|
||||
|
||||
if webhook_id:
|
||||
await webhook_store.update_webhook(
|
||||
webhook=webhook,
|
||||
update_fields={
|
||||
'webhook_secret': webhook_secret,
|
||||
'webhook_exists': True, # webhook was created
|
||||
'webhook_url': GITLAB_WEBHOOK_URL,
|
||||
'scopes': SCOPES,
|
||||
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
|
||||
)
|
||||
|
||||
return webhook_id, status
|
||||
@@ -17,7 +17,6 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -31,11 +30,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -385,10 +380,6 @@ class JiraManager(Manager):
|
||||
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -19,7 +19,6 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -33,11 +32,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -402,10 +397,6 @@ class JiraDcManager(Manager):
|
||||
logger.warning(f'[Jira DC] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira DC] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira DC] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -16,7 +16,6 @@ from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -30,11 +29,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -392,10 +387,6 @@ class LinearManager(Manager):
|
||||
logger.warning(f'[Linear] LLM authentication error: {str(e)}')
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Linear] Session expired: {str(e)}')
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Linear] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
|
||||
@@ -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()
|
||||
@@ -14,10 +14,10 @@ from integrations.slack.slack_view import (
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.constants import SLACK_CLIENT_ID
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from slack_sdk.oauth import AuthorizeUrlGenerator
|
||||
@@ -29,11 +29,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import config, server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
authorize_url_generator = AuthorizeUrlGenerator(
|
||||
@@ -58,6 +54,17 @@ class SlackManager(Manager):
|
||||
if message.source != SourceType.SLACK:
|
||||
raise ValueError(f'Unexpected message source {message.source}')
|
||||
|
||||
async def _get_user_auth(self, keycloak_user_id: str) -> UserAuth:
|
||||
offline_token = await self.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
|
||||
|
||||
async def authenticate_user(
|
||||
self, slack_user_id: str
|
||||
) -> tuple[SlackUser | None, UserAuth | None]:
|
||||
@@ -74,9 +81,7 @@ class SlackManager(Manager):
|
||||
|
||||
saas_user_auth = None
|
||||
if slack_user:
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
slack_user.keycloak_user_id, self.token_manager
|
||||
)
|
||||
saas_user_auth = await self._get_user_auth(slack_user.keycloak_user_id)
|
||||
# slack_view.saas_user_auth = await self._get_user_auth(slack_view.slack_to_openhands_user.keycloak_user_id)
|
||||
|
||||
return slack_user, saas_user_auth
|
||||
@@ -239,11 +244,13 @@ class SlackManager(Manager):
|
||||
async def is_job_requested(
|
||||
self, message: Message, slack_view: SlackViewInterface
|
||||
) -> bool:
|
||||
"""A job is always request we only receive webhooks for events associated with the slack bot
|
||||
"""
|
||||
A job is always request we only receive webhooks for events associated with the slack bot
|
||||
This method really just checks
|
||||
1. Is the user is authenticated
|
||||
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
|
||||
"""
|
||||
|
||||
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
|
||||
if isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
return True
|
||||
@@ -310,15 +317,10 @@ class SlackManager(Manager):
|
||||
f'[Slack] Created conversation {conversation_id} for user {user_info.slack_display_name}'
|
||||
)
|
||||
|
||||
# Only add SlackCallbackProcessor for new conversations (not updates) and non-v1 conversations
|
||||
if (
|
||||
not isinstance(slack_view, SlackUpdateExistingConversationView)
|
||||
and not slack_view.v1_enabled
|
||||
):
|
||||
if not isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
# We don't re-subscribe for follow up messages from slack.
|
||||
# Summaries are generated for every messages anyways, we only need to do
|
||||
# this subscription once for the event which kicked off the job.
|
||||
|
||||
processor = SlackCallbackProcessor(
|
||||
slack_user_id=slack_view.slack_user_id,
|
||||
channel_id=slack_view.channel_id,
|
||||
@@ -333,14 +335,6 @@ class SlackManager(Manager):
|
||||
logger.info(
|
||||
f'[Slack] Created callback processor for conversation {conversation_id}'
|
||||
)
|
||||
elif isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
logger.info(
|
||||
f'[Slack] Skipping callback processor for existing conversation update {conversation_id}'
|
||||
)
|
||||
elif slack_view.v1_enabled:
|
||||
logger.info(
|
||||
f'[Slack] Skipping callback processor for v1 conversation {conversation_id}'
|
||||
)
|
||||
|
||||
msg_info = slack_view.get_response_msg()
|
||||
|
||||
@@ -358,13 +352,6 @@ class SlackManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.slack_display_name} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(
|
||||
f'[Slack] Session expired for user {user_info.slack_display_name}: {str(e)}'
|
||||
)
|
||||
|
||||
msg_info = get_session_expired_message(user_info.slack_display_name)
|
||||
|
||||
except StartingConvoException as e:
|
||||
msg_info = str(e)
|
||||
|
||||
|
||||
@@ -21,16 +21,20 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
@abstractmethod
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
"Instructions passed when conversation is first initialized"
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_or_update_conversation(self, jinja_env: Environment):
|
||||
"""Create a new conversation"""
|
||||
"Create a new conversation"
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_callback_id(self) -> str:
|
||||
"Unique callback id for subscribription made to EventStream for fetching agent summary"
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -39,4 +43,6 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""Raised when trying to send message to a conversation that's is still starting up"""
|
||||
"""
|
||||
Raised when trying to send message to a conversation that's is still starting up
|
||||
"""
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from integrations.utils import CONVERSATION_URL, get_summary_instruction
|
||||
from pydantic import Field
|
||||
from slack_sdk import WebClient
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
|
||||
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
EventCallback,
|
||||
EventCallbackProcessor,
|
||||
)
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResult,
|
||||
EventCallbackResultStatus,
|
||||
)
|
||||
from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
ensure_running_sandbox,
|
||||
get_agent_server_url_from_sandbox,
|
||||
)
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
"""Callback processor for Slack V1 integrations."""
|
||||
|
||||
slack_view_data: dict[str, str | None] = Field(default_factory=dict)
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
callback: EventCallback,
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Slack V1 integration."""
|
||||
|
||||
# Only handle ConversationStateUpdateEvent
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
# Only act when execution has finished
|
||||
if not (event.key == 'execution_status' and event.value == 'finished'):
|
||||
return None
|
||||
|
||||
_logger.info('[Slack V1] Callback agent state was %s', event)
|
||||
|
||||
try:
|
||||
summary = await self._request_summary(conversation_id)
|
||||
await self._post_summary_to_slack(summary)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.SUCCESS,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception('[Slack V1] Error processing callback: %s', e)
|
||||
|
||||
# Only try to post error to Slack if we have basic requirements
|
||||
try:
|
||||
await self._post_summary_to_slack(
|
||||
f'OpenHands encountered an error: **{str(e)}**.\n\n'
|
||||
f'[See the conversation]({CONVERSATION_URL.format(conversation_id)})'
|
||||
'for more information.'
|
||||
)
|
||||
except Exception as post_error:
|
||||
_logger.warning(
|
||||
'[Slack V1] Failed to post error message to Slack: %s', post_error
|
||||
)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Slack helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _get_bot_access_token(self):
|
||||
slack_team_store = SlackTeamStore.get_instance()
|
||||
bot_access_token = slack_team_store.get_team_bot_token(
|
||||
self.slack_view_data['team_id']
|
||||
)
|
||||
|
||||
return bot_access_token
|
||||
|
||||
async def _post_summary_to_slack(self, summary: str) -> None:
|
||||
"""Post a summary message to the configured Slack channel."""
|
||||
bot_access_token = self._get_bot_access_token()
|
||||
if not bot_access_token:
|
||||
raise RuntimeError('Missing Slack bot access token')
|
||||
|
||||
channel_id = self.slack_view_data['channel_id']
|
||||
thread_ts = self.slack_view_data.get('thread_ts') or self.slack_view_data.get(
|
||||
'message_ts'
|
||||
)
|
||||
|
||||
client = WebClient(token=bot_access_token)
|
||||
|
||||
try:
|
||||
# Post the summary as a threaded reply
|
||||
response = client.chat_postMessage(
|
||||
channel=channel_id,
|
||||
text=summary,
|
||||
thread_ts=thread_ts,
|
||||
unfurl_links=False,
|
||||
unfurl_media=False,
|
||||
)
|
||||
|
||||
if not response['ok']:
|
||||
raise RuntimeError(
|
||||
f"Slack API error: {response.get('error', 'Unknown error')}"
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
'[Slack V1] Successfully posted summary to channel %s', channel_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error('[Slack V1] Failed to post message to Slack: %s', e)
|
||||
raise
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Agent / sandbox helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _ask_question(
|
||||
self,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
agent_server_url: str,
|
||||
conversation_id: UUID,
|
||||
session_api_key: str,
|
||||
message_content: str,
|
||||
) -> str:
|
||||
"""Send a message to the agent server via the V1 API and return response text."""
|
||||
send_message_request = AskAgentRequest(question=message_content)
|
||||
|
||||
url = (
|
||||
f'{agent_server_url.rstrip("/")}'
|
||||
f'/api/conversations/{conversation_id}/ask_agent'
|
||||
)
|
||||
headers = {'X-Session-API-Key': session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
agent_response = AskAgentResponse.model_validate(response.json())
|
||||
return agent_response.response
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = f'HTTP {e.response.status_code} error'
|
||||
try:
|
||||
error_body = e.response.text
|
||||
if error_body:
|
||||
error_detail += f': {error_body}'
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
_logger.error(
|
||||
'[Slack V1] HTTP error sending message to %s: %s. '
|
||||
'Request payload: %s. Response headers: %s',
|
||||
url,
|
||||
error_detail,
|
||||
payload,
|
||||
dict(e.response.headers),
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_detail = f'Request timeout after 30 seconds to {url}'
|
||||
_logger.error(
|
||||
'[Slack V1] %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(error_detail)
|
||||
|
||||
except httpx.RequestError as e:
|
||||
error_detail = f'Request error to {url}: {str(e)}'
|
||||
_logger.error(
|
||||
'[Slack V1] %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(error_detail)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Summary orchestration
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""
|
||||
Ask the agent to produce a summary of its work and return the agent response.
|
||||
|
||||
NOTE: This method now returns a string (the agent server's response text)
|
||||
and raises exceptions on errors. The wrapping into EventCallbackResult
|
||||
is handled by __call__.
|
||||
"""
|
||||
# Import services within the method to avoid circular imports
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
get_httpx_client,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
)
|
||||
|
||||
# Create injector state for dependency injection
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
async with (
|
||||
get_app_conversation_info_service(state) as app_conversation_info_service,
|
||||
get_sandbox_service(state) as sandbox_service,
|
||||
get_httpx_client(state) as httpx_client,
|
||||
):
|
||||
# 1. Conversation lookup
|
||||
app_conversation_info = ensure_conversation_found(
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_id
|
||||
),
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
# 2. Sandbox lookup + validation
|
||||
sandbox = ensure_running_sandbox(
|
||||
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
|
||||
app_conversation_info.sandbox_id,
|
||||
)
|
||||
|
||||
assert (
|
||||
sandbox.session_api_key is not None
|
||||
), f'No session API key for sandbox: {sandbox.id}'
|
||||
|
||||
# 3. URL + instruction
|
||||
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
|
||||
|
||||
# Prepare message based on agent state
|
||||
message_content = get_summary_instruction()
|
||||
|
||||
# Ask the agent and return the response text
|
||||
return await self._ask_question(
|
||||
httpx_client=httpx_client,
|
||||
agent_server_url=agent_server_url,
|
||||
conversation_id=conversation_id,
|
||||
session_api_key=sandbox.session_api_key,
|
||||
message_content=message_content,
|
||||
)
|
||||
@@ -1,16 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.slack.slack_types import SlackViewInterface, StartingConvoException
|
||||
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_V1_SLACK_RESOLVER,
|
||||
get_final_agent_observation,
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from jinja2 import Environment
|
||||
from slack_sdk import WebClient
|
||||
from storage.slack_conversation import SlackConversation
|
||||
@@ -18,34 +10,22 @@ from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
SendMessageRequest,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
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.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, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
# =================================================
|
||||
# SECTION: Slack view types
|
||||
# SECTION: Github view types
|
||||
# =================================================
|
||||
|
||||
|
||||
@@ -54,10 +34,6 @@ slack_conversation_store = SlackConversationStore.get_instance()
|
||||
slack_team_store = SlackTeamStore.get_instance()
|
||||
|
||||
|
||||
async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackUnkownUserView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
@@ -73,7 +49,6 @@ class SlackUnkownUserView(SlackViewInterface):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
raise NotImplementedError
|
||||
@@ -81,6 +56,9 @@ class SlackUnkownUserView(SlackViewInterface):
|
||||
async def create_or_update_conversation(self, jinja_env: Environment):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_callback_id(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -100,7 +78,6 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_initial_prompt(self, text: str, blocks: list[dict]):
|
||||
bot_id = self._get_bot_id(blocks)
|
||||
@@ -119,7 +96,8 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
return ''
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
"Instructions passed when conversation is first initialized"
|
||||
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
messages = []
|
||||
@@ -179,7 +157,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'Attempting to start conversation without confirming selected repo from user'
|
||||
)
|
||||
|
||||
async def save_slack_convo(self, v1_enabled: bool = False):
|
||||
async def save_slack_convo(self):
|
||||
if self.slack_to_openhands_user:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
@@ -190,7 +168,6 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'conversation_id': self.conversation_id,
|
||||
'keycloak_user_id': user_info.keycloak_user_id,
|
||||
'parent_id': self.thread_ts or self.message_ts,
|
||||
'v1_enabled': v1_enabled,
|
||||
},
|
||||
)
|
||||
slack_conversation = SlackConversation(
|
||||
@@ -199,47 +176,17 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
keycloak_user_id=user_info.keycloak_user_id,
|
||||
parent_id=self.thread_ts
|
||||
or self.message_ts, # conversations can start in a thread reply as well; we should always references the parent's (root level msg's) message ID
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
def _create_slack_v1_callback_processor(self) -> SlackV1CallbackProcessor:
|
||||
"""Create a SlackV1CallbackProcessor for V1 conversation handling."""
|
||||
return SlackV1CallbackProcessor(
|
||||
slack_view_data={
|
||||
'channel_id': self.channel_id,
|
||||
'message_ts': self.message_ts,
|
||||
'thread_ts': self.thread_ts,
|
||||
'team_id': self.team_id,
|
||||
'slack_user_id': self.slack_user_id,
|
||||
}
|
||||
)
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""Only creates a new conversation"""
|
||||
"""
|
||||
Only creates a new conversation
|
||||
"""
|
||||
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()
|
||||
|
||||
# Check if V1 conversations are enabled for this user
|
||||
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
|
||||
self.slack_to_openhands_user.keycloak_user_id
|
||||
)
|
||||
|
||||
if self.v1_enabled:
|
||||
# Use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja)
|
||||
return self.conversation_id
|
||||
else:
|
||||
# Use existing V0 conversation service
|
||||
await self._create_v0_conversation(jinja, provider_tokens, user_secrets)
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v0_conversation(
|
||||
self, jinja: Environment, provider_tokens, user_secrets
|
||||
) -> None:
|
||||
"""Create conversation using the legacy V0 system."""
|
||||
user_instructions, conversation_instructions = self._get_instructions(jinja)
|
||||
|
||||
# Determine git provider from repository
|
||||
@@ -266,65 +213,11 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=False)
|
||||
await self.save_slack_convo()
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v1_conversation(self, jinja: Environment) -> None:
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
user_instructions, conversation_instructions = self._get_instructions(jinja)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_instructions)]
|
||||
)
|
||||
|
||||
# Create the Slack V1 callback processor
|
||||
slack_callback_processor = self._create_slack_v1_callback_processor()
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = ProviderType(repository.git_provider.value)
|
||||
|
||||
# Get the app conversation service and start the conversation
|
||||
injector_state = InjectorState()
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
self.conversation_id = uuid4().hex
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(self.conversation_id),
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.selected_repo,
|
||||
git_provider=git_provider,
|
||||
title=f'Slack conversation from {self.slack_to_openhands_user.slack_display_name}',
|
||||
trigger=ConversationTrigger.SLACK,
|
||||
processors=[
|
||||
slack_callback_processor
|
||||
], # Pass the callback processor directly
|
||||
)
|
||||
|
||||
# Set up the Slack user context for the V1 system
|
||||
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, slack_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}'
|
||||
)
|
||||
|
||||
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=True)
|
||||
def get_callback_id(self) -> str:
|
||||
return f'slack_{self.channel_id}_{self.message_ts}'
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
@@ -361,20 +254,32 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
|
||||
return user_message, ''
|
||||
|
||||
async def send_message_to_v0_conversation(self, jinja: Environment):
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""
|
||||
Send new user message to converation
|
||||
"""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
user_id = user_info.keycloak_user_id
|
||||
saas_user_auth: UserAuth = self.saas_user_auth
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
user_id = user_info.keycloak_user_id
|
||||
|
||||
# Org management in the future will get rid of this
|
||||
# For now, only user that created the conversation can send follow up messages to it
|
||||
if user_id != self.slack_conversation.keycloak_user_id:
|
||||
raise StartingConvoException(
|
||||
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
|
||||
)
|
||||
|
||||
# Check if conversation has been deleted
|
||||
# Update logic when soft delete is implemented
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
|
||||
try:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
|
||||
# Should we raise here if there are no provider tokens?
|
||||
providers_set = list(provider_tokens.keys()) if provider_tokens else []
|
||||
|
||||
@@ -405,117 +310,6 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
self.conversation_id, event_to_dict(user_msg_action)
|
||||
)
|
||||
|
||||
async def send_message_to_v1_conversation(self, jinja: Environment):
|
||||
"""Send a message to a v1 conversation using the agent server API."""
|
||||
# Import services within the method to avoid circular imports
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
get_httpx_client,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
get_agent_server_url_from_sandbox,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
)
|
||||
|
||||
# Create injector state for dependency injection
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
async with (
|
||||
get_app_conversation_info_service(state) as app_conversation_info_service,
|
||||
get_sandbox_service(state) as sandbox_service,
|
||||
get_httpx_client(state) as httpx_client,
|
||||
):
|
||||
# 1. Conversation lookup
|
||||
app_conversation_info = ensure_conversation_found(
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
UUID(self.conversation_id)
|
||||
),
|
||||
UUID(self.conversation_id),
|
||||
)
|
||||
|
||||
# 2. Sandbox lookup + validation
|
||||
sandbox = await sandbox_service.get_sandbox(
|
||||
app_conversation_info.sandbox_id
|
||||
)
|
||||
|
||||
if sandbox and sandbox.status == SandboxStatus.PAUSED:
|
||||
# Resume paused sandbox and wait for it to be running
|
||||
logger.info('[Slack V1]: Attempting to resume paused sandbox')
|
||||
await sandbox_service.resume_sandbox(app_conversation_info.sandbox_id)
|
||||
|
||||
# Wait for sandbox to be running (handles both fresh start and resume)
|
||||
running_sandbox = await sandbox_service.wait_for_sandbox_running(
|
||||
app_conversation_info.sandbox_id,
|
||||
timeout=120,
|
||||
poll_interval=2,
|
||||
httpx_client=httpx_client,
|
||||
)
|
||||
|
||||
assert (
|
||||
running_sandbox.session_api_key is not None
|
||||
), f'No session API key for sandbox: {running_sandbox.id}'
|
||||
|
||||
# 3. Get the agent server URL
|
||||
agent_server_url = get_agent_server_url_from_sandbox(running_sandbox)
|
||||
|
||||
# 4. Prepare the message content
|
||||
user_msg, _ = self._get_instructions(jinja)
|
||||
|
||||
# 5. Create the message request
|
||||
send_message_request = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=user_msg)], run=True
|
||||
)
|
||||
|
||||
# 6. Send the message to the agent server
|
||||
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
|
||||
|
||||
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'[Slack V1] Failed to send message to conversation %s: %s',
|
||||
self.conversation_id,
|
||||
str(e),
|
||||
exc_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to v1 conversation: {str(e)}')
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""Send new user message to converation"""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
user_id = user_info.keycloak_user_id
|
||||
|
||||
# Org management in the future will get rid of this
|
||||
# For now, only user that created the conversation can send follow up messages to it
|
||||
if user_id != self.slack_conversation.keycloak_user_id:
|
||||
raise StartingConvoException(
|
||||
f'{user_info.slack_display_name} is not authorized to send messages to this conversation.'
|
||||
)
|
||||
|
||||
if self.slack_conversation.v1_enabled:
|
||||
await self.send_message_to_v1_conversation(jinja)
|
||||
else:
|
||||
await self.send_message_to_v0_conversation(jinja)
|
||||
|
||||
return self.conversation_id
|
||||
|
||||
def get_response_msg(self):
|
||||
@@ -567,7 +361,7 @@ class SlackFactory:
|
||||
'channel_id': channel_id,
|
||||
},
|
||||
)
|
||||
raise Exception('Did not find slack team')
|
||||
raise Exception('Did not slack team')
|
||||
|
||||
# Determine if this is a known slack user by openhands
|
||||
if not slack_user or not saas_user_auth or not channel_id:
|
||||
@@ -585,7 +379,6 @@ class SlackFactory:
|
||||
send_summary_instruction=False,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
conversation: SlackConversation | None = call_async_from_sync(
|
||||
@@ -616,7 +409,6 @@ class SlackFactory:
|
||||
conversation_id=conversation.conversation_id,
|
||||
slack_conversation=conversation,
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif SlackFactory.did_user_select_repo_from_form(message):
|
||||
@@ -634,7 +426,6 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -652,5 +443,4 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -45,3 +45,7 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
async def create_new_conversation(self, jinja_env: Environment, token: str):
|
||||
"Create a new conversation"
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_callback_id(self) -> str:
|
||||
"Unique callback id for subscribription made to EventStream for fetching agent summary"
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -6,9 +6,7 @@ import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.config import get_config
|
||||
from server.constants import WEB_HOST
|
||||
from storage.database import session_maker
|
||||
from storage.repository_store import RepositoryStore
|
||||
from storage.stored_repository import StoredRepository
|
||||
from storage.user_repo_map import UserRepositoryMap
|
||||
@@ -22,12 +20,10 @@ from openhands.events.action import (
|
||||
AgentFinishAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
@@ -39,7 +35,7 @@ if TYPE_CHECKING:
|
||||
HOST = WEB_HOST
|
||||
# ---- DO NOT REMOVE ----
|
||||
|
||||
HOST_URL = f'https://{HOST}' if 'localhost' not in HOST else f'http://{HOST}'
|
||||
HOST_URL = f'https://{HOST}'
|
||||
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
|
||||
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
|
||||
conversation_prefix = 'conversations/{}'
|
||||
@@ -50,45 +46,13 @@ ENABLE_PROACTIVE_CONVERSATION_STARTERS = (
|
||||
os.getenv('ENABLE_PROACTIVE_CONVERSATION_STARTERS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
def get_session_expired_message(username: str | None = None) -> str:
|
||||
"""Get a user-friendly session expired message.
|
||||
|
||||
Used by integrations to notify users when their Keycloak offline session
|
||||
has expired.
|
||||
|
||||
Args:
|
||||
username: Optional username to mention in the message. If provided,
|
||||
the message will include @username prefix (used by Git providers
|
||||
like GitHub, GitLab, Slack). If None, returns a generic message
|
||||
(used by Jira, Jira DC, Linear).
|
||||
|
||||
Returns:
|
||||
A formatted session expired message
|
||||
"""
|
||||
if username:
|
||||
return f'@{username} your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
return f'Your session has expired. Please login again at [OpenHands Cloud]({HOST_URL}) and try again.'
|
||||
|
||||
|
||||
# Toggle for solvability report feature
|
||||
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'
|
||||
)
|
||||
|
||||
ENABLE_V1_SLACK_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
|
||||
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
|
||||
or 'openhands/integrations/templates/resolver/'
|
||||
)
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = 'openhands/integrations/templates/resolver/'
|
||||
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
|
||||
|
||||
@@ -116,37 +80,6 @@ def get_summary_instruction():
|
||||
return summary_instruction
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str | None) -> 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
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
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
|
||||
|
||||
|
||||
def has_exact_mention(text: str, mention: str) -> bool:
|
||||
"""Check if the text contains an exact mention (not part of a larger word).
|
||||
|
||||
@@ -265,35 +198,18 @@ def get_summary_for_agent_state(
|
||||
def get_final_agent_observation(
|
||||
event_store: EventStoreABC,
|
||||
) -> list[AgentStateChangedObservation]:
|
||||
events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.ENVIRONMENT,
|
||||
include_types=(AgentStateChangedObservation,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
return event_store.get_matching_events(
|
||||
source=EventSource.ENVIRONMENT,
|
||||
event_types=(AgentStateChangedObservation,),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
result = [e for e in events if isinstance(e, AgentStateChangedObservation)]
|
||||
assert len(result) == len(events)
|
||||
return result
|
||||
|
||||
|
||||
def get_last_user_msg(event_store: EventStoreABC) -> list[MessageAction]:
|
||||
events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.USER,
|
||||
include_types=(MessageAction,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
return event_store.get_matching_events(
|
||||
source=EventSource.USER, event_types=(MessageAction,), limit=1, reverse='true'
|
||||
)
|
||||
result = [e for e in events if isinstance(e, MessageAction)]
|
||||
assert len(result) == len(events)
|
||||
return result
|
||||
|
||||
|
||||
def extract_summary_from_event_store(
|
||||
@@ -305,22 +221,18 @@ def extract_summary_from_event_store(
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
summary_instruction = get_summary_instruction()
|
||||
|
||||
instruction_events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
query=json.dumps(summary_instruction),
|
||||
source=EventSource.USER,
|
||||
include_types=(MessageAction,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
instruction_event: list[MessageAction] = event_store.get_matching_events(
|
||||
query=json.dumps(summary_instruction),
|
||||
source=EventSource.USER,
|
||||
event_types=(MessageAction,),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
final_agent_observation = get_final_agent_observation(event_store)
|
||||
|
||||
# Find summary instruction event ID
|
||||
if not instruction_events:
|
||||
if len(instruction_event) == 0:
|
||||
logger.warning(
|
||||
'no_instruction_event_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
@@ -328,19 +240,19 @@ def extract_summary_from_event_store(
|
||||
final_agent_observation, conversation_link
|
||||
) # Agent did not receive summary instruction
|
||||
|
||||
summary_events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.AGENT,
|
||||
include_types=(MessageAction, AgentFinishAction),
|
||||
),
|
||||
limit=1,
|
||||
event_id: int = instruction_event[0].id
|
||||
|
||||
agent_messages: list[MessageAction | AgentFinishAction] = (
|
||||
event_store.get_matching_events(
|
||||
start_id=event_id,
|
||||
source=EventSource.AGENT,
|
||||
event_types=(MessageAction, AgentFinishAction),
|
||||
reverse=True,
|
||||
start_id=instruction_events[0].id,
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
|
||||
if not summary_events:
|
||||
if len(agent_messages) == 0:
|
||||
logger.warning(
|
||||
'no_agent_messages_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
@@ -348,11 +260,10 @@ def extract_summary_from_event_store(
|
||||
final_agent_observation, conversation_link
|
||||
) # Agent failed to generate summary
|
||||
|
||||
summary_event = summary_events[0]
|
||||
summary_event: MessageAction | AgentFinishAction = agent_messages[0]
|
||||
if isinstance(summary_event, MessageAction):
|
||||
return summary_event.content
|
||||
|
||||
assert isinstance(summary_event, AgentFinishAction)
|
||||
return summary_event.final_thought
|
||||
|
||||
|
||||
@@ -405,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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1,76 +0,0 @@
|
||||
# LiteLLM Configuration for Local Development
|
||||
# Documentation: https://docs.litellm.ai/docs/proxy/configs
|
||||
|
||||
general_settings:
|
||||
master_key: sk-local-dev-master-key
|
||||
database_url: postgresql://postgres:postgres@postgres:5432/litellm
|
||||
|
||||
model_list:
|
||||
# OpenAI Models
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-4o
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
- model_name: gpt-4o-mini
|
||||
litellm_params:
|
||||
model: openai/gpt-4o-mini
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
- model_name: gpt-4-turbo
|
||||
litellm_params:
|
||||
model: openai/gpt-4-turbo
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
# Anthropic Claude Models
|
||||
- model_name: claude-3-5-sonnet-20241022
|
||||
litellm_params:
|
||||
model: anthropic/claude-3-5-sonnet-20241022
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-3-7-sonnet-20250219
|
||||
litellm_params:
|
||||
model: anthropic/claude-3-7-sonnet-20250219
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-sonnet-4-20250514
|
||||
litellm_params:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-opus-4-5-20251101
|
||||
litellm_params:
|
||||
model: anthropic/claude-opus-4-5-20251101
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-3-opus-20240229
|
||||
litellm_params:
|
||||
model: anthropic/claude-3-opus-20240229
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
# Google Gemini Models (optional)
|
||||
- model_name: gemini-1.5-pro
|
||||
litellm_params:
|
||||
model: gemini/gemini-1.5-pro
|
||||
api_key: os.environ/GOOGLE_API_KEY
|
||||
|
||||
- model_name: gemini-2.0-flash
|
||||
litellm_params:
|
||||
model: gemini/gemini-2.0-flash
|
||||
api_key: os.environ/GOOGLE_API_KEY
|
||||
|
||||
litellm_settings:
|
||||
# Enable request/response logging
|
||||
set_verbose: true
|
||||
# Fallback behavior
|
||||
drop_params: true
|
||||
# Default max tokens if not specified
|
||||
max_tokens: 4096
|
||||
|
||||
router_settings:
|
||||
# Enable retries on failure
|
||||
num_retries: 2
|
||||
# Timeout for each request
|
||||
timeout: 600
|
||||
# Routing strategy
|
||||
routing_strategy: simple-shuffle
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -1,30 +0,0 @@
|
||||
"""add v1 column to slack conversation table
|
||||
|
||||
Revision ID: 086
|
||||
Revises: 085
|
||||
Create Date: 2025-12-02 15:30:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '086'
|
||||
down_revision: Union[str, None] = '085'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add v1 column
|
||||
op.add_column(
|
||||
'slack_conversation', sa.Column('v1_enabled', sa.Boolean(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop v1 column
|
||||
op.drop_column('slack_conversation', 'v1_enabled')
|
||||
@@ -1,61 +0,0 @@
|
||||
"""bump condenser defaults: max_size 120->240
|
||||
|
||||
Revision ID: 087
|
||||
Revises: 086
|
||||
Create Date: 2026-01-05
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.sql import column, table
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '087'
|
||||
down_revision: Union[str, None] = '086'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema.
|
||||
|
||||
Update existing users with condenser_max_size=120 or NULL to 240.
|
||||
This covers both users who had the old default (120) explicitly set
|
||||
and users who had NULL (which defaulted to 120 in the application code).
|
||||
The SDK default for keep_first will be used automatically.
|
||||
"""
|
||||
user_settings_table = table(
|
||||
'user_settings',
|
||||
column('condenser_max_size', sa.Integer),
|
||||
)
|
||||
# Update users with explicit 120 value
|
||||
op.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.condenser_max_size == 120)
|
||||
.values(condenser_max_size=240)
|
||||
)
|
||||
# Update users with NULL value (which defaulted to 120 in application code)
|
||||
op.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.condenser_max_size.is_(None))
|
||||
.values(condenser_max_size=240)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema.
|
||||
|
||||
Note: This sets all 240 values back to NULL (not 120) since we can't
|
||||
distinguish between users who had 120 vs NULL before the upgrade.
|
||||
"""
|
||||
user_settings_table = table(
|
||||
'user_settings', column('condenser_max_size', sa.Integer)
|
||||
)
|
||||
op.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.condenser_max_size == 240)
|
||||
.values(condenser_max_size=None)
|
||||
)
|
||||
@@ -1,54 +0,0 @@
|
||||
"""create blocked_email_domains table
|
||||
|
||||
Revision ID: 088
|
||||
Revises: 087
|
||||
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 = '088'
|
||||
down_revision: Union[str, None] = '087'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create blocked_email_domains table for storing blocked email domain patterns."""
|
||||
op.create_table(
|
||||
'blocked_email_domains',
|
||||
sa.Column('id', sa.Integer(), sa.Identity(), nullable=False, primary_key=True),
|
||||
sa.Column('domain', sa.String(), nullable=False),
|
||||
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'),
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# Create unique index on domain column
|
||||
op.create_index(
|
||||
'ix_blocked_email_domains_domain',
|
||||
'blocked_email_domains',
|
||||
['domain'],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop blocked_email_domains table."""
|
||||
op.drop_index('ix_blocked_email_domains_domain', table_name='blocked_email_domains')
|
||||
op.drop_table('blocked_email_domains')
|
||||
606
enterprise/poetry.lock
generated
606
enterprise/poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -201,14 +201,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.75.0"
|
||||
version = "0.72.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.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
|
||||
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -682,37 +682,37 @@ crt = ["awscrt (==0.27.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "browser-use"
|
||||
version = "0.10.1"
|
||||
version = "0.9.5"
|
||||
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.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
|
||||
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = "3.12.15"
|
||||
anthropic = ">=0.72.1,<1.0.0"
|
||||
anthropic = ">=0.68.1,<1.0.0"
|
||||
anyio = ">=4.9.0"
|
||||
authlib = ">=1.6.0"
|
||||
bubus = ">=1.5.6"
|
||||
cdp-use = ">=1.4.4"
|
||||
cdp-use = ">=1.4.0"
|
||||
click = ">=8.1.8"
|
||||
cloudpickle = ">=3.1.1"
|
||||
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"
|
||||
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,7 +721,6 @@ 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"
|
||||
@@ -851,14 +850,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]
|
||||
@@ -2884,28 +2883,6 @@ google-auth = ">=1.25.0,<3.0dev"
|
||||
[package.extras]
|
||||
grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-recaptcha-enterprise"
|
||||
version = "1.29.0"
|
||||
description = "Google Cloud Recaptcha Enterprise API client library"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_cloud_recaptcha_enterprise-1.29.0-py3-none-any.whl", hash = "sha256:d3332f3ab9c586404c187d111326670bc745b4cbb64cad0a1c16259356c43c6d"},
|
||||
{file = "google_cloud_recaptcha_enterprise-1.29.0.tar.gz", hash = "sha256:60cb3e8fb5860733c3c2c0b69d3a0aca6cf1ece2738ad7b5952183f1fb77e3e7"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
|
||||
grpcio = ">=1.33.2,<2.0.0"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-resource-manager"
|
||||
version = "1.14.2"
|
||||
@@ -3001,29 +2978,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"
|
||||
@@ -3079,8 +3055,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"},
|
||||
@@ -3090,8 +3064,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"},
|
||||
@@ -3101,8 +3073,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"},
|
||||
@@ -3112,8 +3082,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"},
|
||||
@@ -3121,8 +3089,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"},
|
||||
@@ -3132,8 +3098,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"},
|
||||
@@ -3202,87 +3166,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]]
|
||||
@@ -4539,14 +4499,14 @@ dev = ["Sphinx (>=5.1.1)", "black (==24.8.0)", "build (>=0.10.0)", "coverage[tom
|
||||
|
||||
[[package]]
|
||||
name = "libtmux"
|
||||
version = "0.53.0"
|
||||
version = "0.46.2"
|
||||
description = "Typed library that provides an ORM wrapper for tmux, a terminal multiplexer."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.10"
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "libtmux-0.53.0-py3-none-any.whl", hash = "sha256:024b7ae6a12aae55358e8feb914c8632b3ab9bd61c0987c53559643c6a58ee4f"},
|
||||
{file = "libtmux-0.53.0.tar.gz", hash = "sha256:1d19af4cea0c19543954d7e7317c7025c0739b029cccbe3b843212fae238f1bd"},
|
||||
{file = "libtmux-0.46.2-py3-none-any.whl", hash = "sha256:6c32dbf22bde8e5e33b2714a4295f6e838dc640f337cd4c085a044f6828c7793"},
|
||||
{file = "libtmux-0.46.2.tar.gz", hash = "sha256:9a398fec5d714129c8344555d466e1a903dfc0f741ba07aabe75a8ceb25c5dda"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4580,39 +4540,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"
|
||||
@@ -4646,14 +4609,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "lmnr"
|
||||
version = "0.7.24"
|
||||
version = "0.7.20"
|
||||
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"},
|
||||
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
|
||||
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4676,15 +4639,14 @@ 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)"]
|
||||
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)", "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)"]
|
||||
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
|
||||
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
|
||||
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
|
||||
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
|
||||
@@ -5682,28 +5644,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)"]
|
||||
@@ -5858,14 +5820,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.8.1"
|
||||
version = "1.3.0"
|
||||
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.8.1-py3-none-any.whl", hash = "sha256:c0dfe620184633a173094ffaa77b0d13124ea7bf84e7b534b1641e5fc5fd0256"},
|
||||
{file = "openhands_agent_server-1.8.1.tar.gz", hash = "sha256:08adfe26d867ff0cb0c1e87bb0ad6e058c9a97374964ba6a9860ea35d32764a0"},
|
||||
{file = "openhands_agent_server-1.3.0-py3-none-any.whl", hash = "sha256:2f87f790c740dc3fb81821c5f9fa375af875fbb937ebca3baa6dc5c035035b3c"},
|
||||
{file = "openhands_agent_server-1.3.0.tar.gz", hash = "sha256:0a83ae77373f5c41d0ba0e22d8f0f6144d54d55784183a50b7c098c96cd5135c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -5873,7 +5835,6 @@ aiosqlite = ">=0.19"
|
||||
alembic = ">=1.13"
|
||||
docker = ">=7.1,<8"
|
||||
fastapi = ">=0.104"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2"
|
||||
sqlalchemy = ">=2"
|
||||
uvicorn = ">=0.31.1"
|
||||
@@ -5882,7 +5843,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "1.1.0"
|
||||
version = "0.62.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5912,21 +5873,21 @@ google-genai = "*"
|
||||
html2text = "*"
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
ipywidgets = "^8.1.5"
|
||||
jinja2 = "^3.1.6"
|
||||
jinja2 = "^3.1.3"
|
||||
joblib = "*"
|
||||
json-repair = "*"
|
||||
jupyter_kernel_gateway = "*"
|
||||
kubernetes = "^33.1.0"
|
||||
libtmux = ">=0.46.2"
|
||||
litellm = ">=1.74.3, !=1.64.4, !=1.67.*"
|
||||
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
|
||||
lmnr = "^0.7.20"
|
||||
memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "2.8.0"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = "1.8.1"
|
||||
openhands-sdk = "1.8.1"
|
||||
openhands-tools = "1.8.1"
|
||||
openhands-agent-server = "1.3.0"
|
||||
openhands-sdk = "1.3.0"
|
||||
openhands-tools = "1.3.0"
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5943,6 +5904,7 @@ pygithub = "^2.5.0"
|
||||
pyjwt = "^2.9.0"
|
||||
pylatexenc = "*"
|
||||
pypdf = "^6.0.0"
|
||||
PyPDF2 = "*"
|
||||
python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = "^1.1.0"
|
||||
@@ -5965,9 +5927,9 @@ starlette = "^0.48.0"
|
||||
tenacity = ">=8.5,<10.0"
|
||||
termcolor = "*"
|
||||
toml = "*"
|
||||
tornado = ">=6.5"
|
||||
tornado = "*"
|
||||
types-toml = "*"
|
||||
urllib3 = "^2.6.3"
|
||||
urllib3 = "^2.5.0"
|
||||
uvicorn = "*"
|
||||
whatthepatch = "^1.0.6"
|
||||
zope-interface = "7.2"
|
||||
@@ -5981,23 +5943,23 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.8.1"
|
||||
version = "1.3.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.8.1-py3-none-any.whl", hash = "sha256:133275f56321585c016b4718d56c8fc7bb834f4ef7cab1ef66b0c71c49d47d1d"},
|
||||
{file = "openhands_sdk-1.8.1.tar.gz", hash = "sha256:9e2baa6c512ac4c2bc1c2c0bf8b1dbdb0267d794a8b86b7306a4656fc0cb8b0b"},
|
||||
{file = "openhands_sdk-1.3.0-py3-none-any.whl", hash = "sha256:feee838346f8e60ea3e4d3391de7cb854314eb8b3c9e3dbbb56f98a784aadc56"},
|
||||
{file = "openhands_sdk-1.3.0.tar.gz", hash = "sha256:2d060803a78de462121b56dea717a66356922deb02276f37b29fae8af66343fb"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
deprecation = ">=2.1.0"
|
||||
fastmcp = ">=2.11.3"
|
||||
httpx = ">=0.27.0"
|
||||
litellm = ">=1.80.10"
|
||||
lmnr = ">=0.7.24"
|
||||
pydantic = ">=2.12.5"
|
||||
litellm = ">=1.77.7.dev9"
|
||||
lmnr = ">=0.7.20"
|
||||
pydantic = ">=2.11.7"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
python-json-logger = ">=3.3.0"
|
||||
tenacity = ">=9.1.2"
|
||||
@@ -6008,14 +5970,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.8.1"
|
||||
version = "1.3.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.8.1-py3-none-any.whl", hash = "sha256:9404b17edb8960d4af3a4439e6f68e37c92c59d0705f13096e4a8ff9b6ffc472"},
|
||||
{file = "openhands_tools-1.8.1.tar.gz", hash = "sha256:e59fcd9ca3baa6266e92020646c4c5f5266f57761f434770cf0cd458b1a33cb0"},
|
||||
{file = "openhands_tools-1.3.0-py3-none-any.whl", hash = "sha256:f31056d87c3058ac92709f9161c7c602daeee3ed0cb4439097b43cda105ed03e"},
|
||||
{file = "openhands_tools-1.3.0.tar.gz", hash = "sha256:3da46f09e28593677d3e17252ce18584fcc13caab1a73213e66bd7edca2cebe0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6024,10 +5986,9 @@ binaryornot = ">=0.4.4"
|
||||
browser-use = ">=0.8.0"
|
||||
cachetools = "*"
|
||||
func-timeout = ">=4.3.5"
|
||||
libtmux = ">=0.53.0"
|
||||
libtmux = ">=0.46.2"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2.11.7"
|
||||
tom-swe = ">=1.0.3"
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
@@ -6880,6 +6841,22 @@ files = [
|
||||
[package.extras]
|
||||
twisted = ["twisted"]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-fastapi-instrumentator"
|
||||
version = "7.1.0"
|
||||
description = "Instrument your FastAPI app with Prometheus metrics"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "prometheus_fastapi_instrumentator-7.1.0-py3-none-any.whl", hash = "sha256:978130f3c0bb7b8ebcc90d35516a6fe13e02d2eb358c8f83887cdef7020c31e9"},
|
||||
{file = "prometheus_fastapi_instrumentator-7.1.0.tar.gz", hash = "sha256:be7cd61eeea4e5912aeccb4261c6631b3f227d8924542d79eaf5af3f439cbe5e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
prometheus-client = ">=0.8.0,<1.0.0"
|
||||
starlette = ">=0.30.0,<1.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
@@ -7260,22 +7237,22 @@ markers = {test = "platform_python_implementation == \"CPython\" and sys_platfor
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
version = "2.11.7"
|
||||
description = "Data validation using Python type hints"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
|
||||
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
|
||||
{file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"},
|
||||
{file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
annotated-types = ">=0.6.0"
|
||||
email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
|
||||
pydantic-core = "2.41.5"
|
||||
typing-extensions = ">=4.14.1"
|
||||
typing-inspection = ">=0.4.2"
|
||||
pydantic-core = "2.33.2"
|
||||
typing-extensions = ">=4.12.2"
|
||||
typing-inspection = ">=0.4.0"
|
||||
|
||||
[package.extras]
|
||||
email = ["email-validator (>=2.0.0)"]
|
||||
@@ -7283,137 +7260,115 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
version = "2.33.2"
|
||||
description = "Core functionality for Pydantic validation and serialization"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
|
||||
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
|
||||
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
|
||||
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
|
||||
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
|
||||
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
|
||||
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
|
||||
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
|
||||
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
|
||||
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
|
||||
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
|
||||
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"},
|
||||
{file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"},
|
||||
{file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"},
|
||||
{file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"},
|
||||
{file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"},
|
||||
{file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"},
|
||||
{file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"},
|
||||
{file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"},
|
||||
{file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"},
|
||||
{file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.14.1"
|
||||
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
@@ -13350,31 +13305,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"
|
||||
@@ -13652,14 +13582,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
version = "0.4.1"
|
||||
description = "Runtime typing introspection tools"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "test"]
|
||||
files = [
|
||||
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
|
||||
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
|
||||
{file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"},
|
||||
{file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -13706,21 +13636,21 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.5.0"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
||||
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
||||
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
|
||||
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
|
||||
brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
|
||||
h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uuid7"
|
||||
@@ -14514,4 +14444,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "b5cbb1e25176845ac9f95650a802667e2f8be1a536e3e55a9269b5af5a42e3fc"
|
||||
content-hash = "fac67a8991a3e2c840a23702dc90f99e98d381f3537ad50b4c4739cdbde941ca"
|
||||
|
||||
@@ -29,6 +29,7 @@ cloud-sql-python-connector = "^1.16.0"
|
||||
psycopg2-binary = "^2.9.10"
|
||||
pg8000 = "^1.31.2"
|
||||
stripe = "^11.5.0"
|
||||
prometheus-fastapi-instrumentator = "^7.0.2"
|
||||
python-json-logger = "^3.2.1"
|
||||
python-keycloak = "^5.3.1"
|
||||
asyncpg = "^0.30.0"
|
||||
@@ -43,7 +44,6 @@ coredis = "^4.22.0"
|
||||
httpx = "*"
|
||||
scikit-learn = "^1.7.0"
|
||||
shap = "^0.48.0"
|
||||
google-cloud-recaptcha-enterprise = "^1.24.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "0.8.3"
|
||||
|
||||
@@ -18,6 +18,7 @@ from server.auth.constants import ( # noqa: E402
|
||||
)
|
||||
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
|
||||
from server.logger import logger # noqa: E402
|
||||
from server.metrics import metrics_app # noqa: E402
|
||||
from server.middleware import SetAuthCookieMiddleware # noqa: E402
|
||||
from server.rate_limit import setup_rate_limit_handler # noqa: E402
|
||||
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
@@ -33,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
|
||||
@@ -60,16 +54,16 @@ def is_saas():
|
||||
return {'saas': True}
|
||||
|
||||
|
||||
# This requires a trailing slash to access, like /api/metrics/
|
||||
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:
|
||||
@@ -103,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,
|
||||
|
||||
@@ -38,16 +38,3 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
|
||||
'y',
|
||||
'on',
|
||||
)
|
||||
|
||||
# reCAPTCHA Enterprise
|
||||
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
|
||||
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
|
||||
RECAPTCHA_HMAC_SECRET = os.getenv('RECAPTCHA_HMAC_SECRET', '').strip()
|
||||
RECAPTCHA_BLOCK_THRESHOLD = float(os.getenv('RECAPTCHA_BLOCK_THRESHOLD', '0.3'))
|
||||
|
||||
# Account Defender labels that indicate suspicious activity
|
||||
SUSPICIOUS_LABELS = {
|
||||
'SUSPICIOUS_LOGIN_ACTIVITY',
|
||||
'SUSPICIOUS_ACCOUNT_CREATION',
|
||||
'RELATED_ACCOUNTS_NUMBER_HIGH',
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
from storage.blocked_email_domain_store import BlockedEmailDomainStore
|
||||
from storage.database import session_maker
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class DomainBlocker:
|
||||
def __init__(self, store: BlockedEmailDomainStore) -> None:
|
||||
logger.debug('Initializing DomainBlocker')
|
||||
self.store = store
|
||||
|
||||
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 by querying the database directly via SQL.
|
||||
|
||||
Supports blocking:
|
||||
- Exact domains: 'example.com' blocks 'user@example.com'
|
||||
- Subdomains: 'example.com' blocks 'user@subdomain.example.com'
|
||||
- TLDs: '.us' blocks 'user@company.us' and 'user@subdomain.company.us'
|
||||
|
||||
The blocking logic is handled efficiently in SQL, avoiding the need to load
|
||||
all blocked domains into memory.
|
||||
"""
|
||||
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
|
||||
|
||||
try:
|
||||
# Query database directly via SQL to check if domain is blocked
|
||||
is_blocked = self.store.is_domain_blocked(domain)
|
||||
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error checking if domain is blocked for email {email}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
# Fail-safe: if database query fails, don't block (allow auth to proceed)
|
||||
return False
|
||||
|
||||
|
||||
# Initialize store and domain blocker
|
||||
_store = BlockedEmailDomainStore(session_maker=session_maker)
|
||||
domain_blocker = DomainBlocker(store=_store)
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -18,12 +19,6 @@ def schedule_gitlab_repo_sync(
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
# Lazy import to avoid circular dependency:
|
||||
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
|
||||
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
|
||||
# -> integrations.gitlab.gitlab_service (circular)
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
service = SaaSGitLabService(
|
||||
external_auth_id=user_id, external_auth_token=keycloak_access_token
|
||||
)
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
|
||||
from google.cloud import recaptchaenterprise_v1
|
||||
from server.auth.constants import (
|
||||
RECAPTCHA_BLOCK_THRESHOLD,
|
||||
RECAPTCHA_HMAC_SECRET,
|
||||
RECAPTCHA_PROJECT_ID,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
SUSPICIOUS_LABELS,
|
||||
)
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssessmentResult:
|
||||
"""Result of a reCAPTCHA Enterprise assessment."""
|
||||
|
||||
score: float
|
||||
valid: bool
|
||||
action_valid: bool
|
||||
reason_codes: list[str]
|
||||
account_defender_labels: list[str]
|
||||
allowed: bool
|
||||
|
||||
|
||||
class RecaptchaService:
|
||||
"""Service for creating reCAPTCHA Enterprise assessments."""
|
||||
|
||||
def __init__(self):
|
||||
self._client = None
|
||||
self.project_id = RECAPTCHA_PROJECT_ID
|
||||
self.site_key = RECAPTCHA_SITE_KEY
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
"""Lazily initialize the reCAPTCHA client to avoid credential errors at import time."""
|
||||
if self._client is None:
|
||||
self._client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
|
||||
return self._client
|
||||
|
||||
def hash_account_id(self, email: str) -> str:
|
||||
"""Hash email using SHA256-HMAC for Account Defender.
|
||||
|
||||
Args:
|
||||
email: The user's email address.
|
||||
|
||||
Returns:
|
||||
Hex-encoded HMAC-SHA256 hash of the lowercase email.
|
||||
"""
|
||||
return hmac.new(
|
||||
RECAPTCHA_HMAC_SECRET.encode(),
|
||||
email.lower().encode(),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
def create_assessment(
|
||||
self,
|
||||
token: str,
|
||||
action: str,
|
||||
user_ip: str,
|
||||
user_agent: str,
|
||||
email: str | None = None,
|
||||
) -> AssessmentResult:
|
||||
"""Create a reCAPTCHA Enterprise assessment.
|
||||
|
||||
Args:
|
||||
token: The reCAPTCHA token from the frontend.
|
||||
action: The expected action name (e.g., 'LOGIN').
|
||||
user_ip: The user's IP address.
|
||||
user_agent: The user's browser user agent.
|
||||
email: Optional email for Account Defender hashing.
|
||||
|
||||
Returns:
|
||||
AssessmentResult with score, validity, and allowed status.
|
||||
"""
|
||||
event = recaptchaenterprise_v1.Event()
|
||||
event.site_key = self.site_key
|
||||
event.token = token
|
||||
event.user_ip_address = user_ip
|
||||
event.user_agent = user_agent
|
||||
event.expected_action = action
|
||||
|
||||
# Account Defender: use user_info.account_id (hashed_account_id is deprecated)
|
||||
if email:
|
||||
user_info = recaptchaenterprise_v1.UserInfo()
|
||||
user_info.account_id = self.hash_account_id(email)
|
||||
# Also include email as a user identifier for better fraud detection
|
||||
user_info.user_ids.append(recaptchaenterprise_v1.UserId(email=email))
|
||||
event.user_info = user_info
|
||||
|
||||
assessment = recaptchaenterprise_v1.Assessment()
|
||||
assessment.event = event
|
||||
|
||||
request = recaptchaenterprise_v1.CreateAssessmentRequest()
|
||||
request.assessment = assessment
|
||||
request.parent = f'projects/{self.project_id}'
|
||||
|
||||
response = self.client.create_assessment(request)
|
||||
|
||||
token_properties = response.token_properties
|
||||
risk_analysis = response.risk_analysis
|
||||
|
||||
score = risk_analysis.score
|
||||
valid = token_properties.valid
|
||||
action_valid = token_properties.action == action
|
||||
reason_codes = [str(r) for r in risk_analysis.reasons]
|
||||
|
||||
# Extract Account Defender labels
|
||||
account_defender_labels = []
|
||||
if response.account_defender_assessment:
|
||||
account_defender_labels = [
|
||||
str(label) for label in response.account_defender_assessment.labels
|
||||
]
|
||||
|
||||
# Check if any suspicious labels are present
|
||||
has_suspicious_labels = bool(set(account_defender_labels) & SUSPICIOUS_LABELS)
|
||||
|
||||
# Block if: invalid token, wrong action, low score, OR suspicious Account Defender labels
|
||||
allowed = (
|
||||
valid
|
||||
and action_valid
|
||||
and score >= RECAPTCHA_BLOCK_THRESHOLD
|
||||
and not has_suspicious_labels
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'recaptcha_assessment',
|
||||
extra={
|
||||
'score': score,
|
||||
'valid': valid,
|
||||
'action_valid': action_valid,
|
||||
'reasons': reason_codes,
|
||||
'account_defender_labels': account_defender_labels,
|
||||
'has_suspicious_labels': has_suspicious_labels,
|
||||
'allowed': allowed,
|
||||
'user_ip': user_ip,
|
||||
},
|
||||
)
|
||||
|
||||
return AssessmentResult(
|
||||
score=score,
|
||||
valid=valid,
|
||||
action_valid=action_valid,
|
||||
reason_codes=reason_codes,
|
||||
account_defender_labels=account_defender_labels,
|
||||
allowed=allowed,
|
||||
)
|
||||
|
||||
|
||||
recaptcha_service = RecaptchaService()
|
||||
@@ -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
|
||||
@@ -154,10 +153,8 @@ class SaasUserAuth(UserAuth):
|
||||
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:
|
||||
@@ -315,16 +312,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_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(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
@@ -14,7 +13,6 @@ from keycloak.exceptions import (
|
||||
KeycloakAuthenticationError,
|
||||
KeycloakConnectionError,
|
||||
KeycloakError,
|
||||
KeycloakPostError,
|
||||
)
|
||||
from server.auth.constants import (
|
||||
BITBUCKET_APP_CLIENT_ID,
|
||||
@@ -27,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
|
||||
@@ -44,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.server.types import SessionExpiredError
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
@@ -467,14 +459,6 @@ class TokenManager:
|
||||
except KeycloakConnectionError:
|
||||
logger.exception('KeycloakConnectionError when refreshing token')
|
||||
raise
|
||||
except KeycloakPostError as e:
|
||||
error_message = str(e)
|
||||
if 'invalid_grant' in error_message or 'session not found' in error_message:
|
||||
logger.warning(f'User session expired or invalid: {error_message}')
|
||||
raise SessionExpiredError(
|
||||
'Your session has expired. Please login again.'
|
||||
) from e
|
||||
raise
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(2),
|
||||
@@ -525,183 +509,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)
|
||||
@@ -720,49 +527,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.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import socketio
|
||||
from server.logger import logger
|
||||
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
@@ -742,8 +743,6 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
return
|
||||
|
||||
# Restart the agent loop
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
config = load_openhands_config()
|
||||
settings_store = await SaasSettingsStore.get_instance(config, user_id)
|
||||
settings = await settings_store.load()
|
||||
|
||||
@@ -17,7 +17,6 @@ from server.auth.constants import (
|
||||
GITHUB_APP_PRIVATE_KEY,
|
||||
GITHUB_APP_WEBHOOK_SECRET,
|
||||
GITLAB_APP_CLIENT_ID,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
)
|
||||
|
||||
from openhands.core.config.utils import load_openhands_config
|
||||
@@ -188,7 +187,4 @@ class SaaSServerConfig(ServerConfig):
|
||||
if self.auth_url:
|
||||
config['AUTH_URL'] = self.auth_url
|
||||
|
||||
if RECAPTCHA_SITE_KEY:
|
||||
config['RECAPTCHA_SITE_KEY'] = RECAPTCHA_SITE_KEY
|
||||
|
||||
return config
|
||||
|
||||
@@ -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')
|
||||
@@ -38,8 +37,6 @@ LITE_LLM_API_URL = os.environ.get(
|
||||
)
|
||||
LITE_LLM_TEAM_ID = os.environ.get('LITE_LLM_TEAM_ID', None)
|
||||
LITE_LLM_API_KEY = os.environ.get('LITE_LLM_API_KEY', None)
|
||||
# Timeout in seconds for BYOR key verification requests to LiteLLM
|
||||
BYOR_KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
SUBSCRIPTION_PRICE_DATA = {
|
||||
'MONTHLY_SUBSCRIPTION': {
|
||||
'unit_amount': 2000,
|
||||
@@ -73,22 +70,36 @@ PERMITTED_CORS_ORIGINS = [
|
||||
|
||||
|
||||
def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
"""Build the LiteLLM proxy model path based on model name.
|
||||
"""
|
||||
Build the LiteLLM proxy model path based on environment and model name.
|
||||
|
||||
This utility constructs the full model path for LiteLLM proxy based on:
|
||||
- Environment type (staging vs prod)
|
||||
- The provided model name
|
||||
|
||||
Args:
|
||||
model_name: The base model name (e.g., 'claude-3-7-sonnet-20250219')
|
||||
|
||||
Returns:
|
||||
The full LiteLLM proxy model path (e.g., 'litellm_proxy/claude-3-7-sonnet-20250219')
|
||||
The full LiteLLM proxy model path (e.g., 'litellm_proxy/prod/claude-3-7-sonnet-20250219')
|
||||
"""
|
||||
if 'litellm' in model_name:
|
||||
|
||||
if 'prod' in model_name or 'litellm' in model_name or 'proxy' in model_name:
|
||||
raise ValueError("Only include model name, don't include prefix")
|
||||
|
||||
return 'litellm_proxy/' + model_name
|
||||
prefix = 'litellm_proxy/'
|
||||
|
||||
if not IS_STAGING_ENV and not IS_LOCAL_ENV:
|
||||
prefix += 'prod/'
|
||||
|
||||
return prefix + model_name
|
||||
|
||||
|
||||
def get_default_litellm_model():
|
||||
"""Construct proxy for litellm model based on user settings if not set explicitly."""
|
||||
"""
|
||||
Construct proxy for litellm model based on user settings and environment type (staging vs prod)
|
||||
if not set explicitly
|
||||
"""
|
||||
if LITELLM_DEFAULT_MODEL:
|
||||
return LITELLM_DEFAULT_MODEL
|
||||
model = USER_SETTINGS_VERSION_TO_MODEL[CURRENT_USER_SETTINGS_VERSION]
|
||||
|
||||
331
enterprise/server/legacy_conversation_manager.py
Normal file
331
enterprise/server/legacy_conversation_manager.py
Normal 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
|
||||
),
|
||||
)
|
||||
43
enterprise/server/metrics.py
Normal file
43
enterprise/server/metrics.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from typing import Callable
|
||||
|
||||
from prometheus_client import Gauge, make_asgi_app
|
||||
from server.clustered_conversation_manager import ClusteredConversationManager
|
||||
|
||||
from openhands.server.shared import (
|
||||
conversation_manager,
|
||||
)
|
||||
|
||||
RUNNING_AGENT_LOOPS_GAUGE = Gauge(
|
||||
'saas_running_agent_loops',
|
||||
'Count of running agent loops, aggregate by session_id to dedupe',
|
||||
['session_id'],
|
||||
)
|
||||
|
||||
|
||||
async def _update_metrics():
|
||||
"""Update any prometheus metrics that are not updated during normal operation."""
|
||||
if isinstance(conversation_manager, ClusteredConversationManager):
|
||||
running_agent_loops = (
|
||||
await conversation_manager.get_running_agent_loops_locally()
|
||||
)
|
||||
# Clear so we don't keep counting old sessions.
|
||||
# This is theoretically a race condition but this is scraped on a regular interval.
|
||||
RUNNING_AGENT_LOOPS_GAUGE.clear()
|
||||
# running_agent_loops shouldn't be None, but can be.
|
||||
if running_agent_loops is not None:
|
||||
for sid in running_agent_loops:
|
||||
RUNNING_AGENT_LOOPS_GAUGE.labels(session_id=sid).set(1)
|
||||
|
||||
|
||||
def metrics_app() -> Callable:
|
||||
metrics_callable = make_asgi_app()
|
||||
|
||||
async def wrapped_handler(scope, receive, send):
|
||||
"""
|
||||
Call _update_metrics before serving Prometheus metrics endpoint.
|
||||
Not wrapped in a `try`, failing would make metrics endpoint unavailable.
|
||||
"""
|
||||
await _update_metrics()
|
||||
await metrics_callable(scope, receive, send)
|
||||
|
||||
return wrapped_handler
|
||||
@@ -152,29 +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
|
||||
|
||||
# Allow public access to shared conversations and events
|
||||
if path.startswith('/api/shared-conversations') or path.startswith(
|
||||
'/api/shared-events'
|
||||
):
|
||||
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
|
||||
|
||||
@@ -4,11 +4,7 @@ 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 (
|
||||
BYOR_KEY_VERIFICATION_TIMEOUT,
|
||||
LITE_LLM_API_KEY,
|
||||
LITE_LLM_API_URL,
|
||||
)
|
||||
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
|
||||
@@ -116,70 +112,6 @@ async def generate_byor_key(user_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def verify_byor_key_in_litellm(byor_key: str, user_id: str) -> bool:
|
||||
"""Verify that a BYOR key is valid in LiteLLM by making a lightweight API call.
|
||||
|
||||
Args:
|
||||
byor_key: The BYOR key to verify
|
||||
user_id: The user ID for logging purposes
|
||||
|
||||
Returns:
|
||||
True if the key is verified as valid, False if verification fails or key is invalid.
|
||||
Returns False on network errors/timeouts to ensure we don't return potentially invalid keys.
|
||||
"""
|
||||
if not (LITE_LLM_API_URL and byor_key):
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
timeout=BYOR_KEY_VERIFICATION_TIMEOUT,
|
||||
) as client:
|
||||
# Make a lightweight request to verify the key
|
||||
# Using /v1/models endpoint as it's lightweight and requires authentication
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/v1/models',
|
||||
headers={
|
||||
'Authorization': f'Bearer {byor_key}',
|
||||
},
|
||||
)
|
||||
|
||||
# Only 200 status code indicates valid key
|
||||
if response.status_code == 200:
|
||||
logger.debug(
|
||||
'BYOR key verification successful',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return True
|
||||
|
||||
# All other status codes (401, 403, 500, etc.) are treated as invalid
|
||||
# This includes authentication errors and server errors
|
||||
logger.warning(
|
||||
'BYOR key verification failed - treating as invalid',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'status_code': response.status_code,
|
||||
'key_prefix': byor_key[:10] + '...'
|
||||
if len(byor_key) > 10
|
||||
else byor_key,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
except (httpx.TimeoutException, Exception) as e:
|
||||
# Any exception (timeout, network error, etc.) means we can't verify
|
||||
# Return False to trigger regeneration rather than returning potentially invalid key
|
||||
logger.warning(
|
||||
'BYOR key verification error - treating as invalid to ensure key validity',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
'error_type': type(e).__name__,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool:
|
||||
"""Delete the BYOR key from LiteLLM using the key directly."""
|
||||
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
|
||||
@@ -346,44 +278,18 @@ async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)):
|
||||
|
||||
@api_router.get('/llm/byor', response_model=LlmApiKeyResponse)
|
||||
async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
"""Get the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.
|
||||
|
||||
This endpoint validates that the key exists in LiteLLM before returning it.
|
||||
If validation fails, it automatically generates a new key to ensure users
|
||||
always receive a working key.
|
||||
"""
|
||||
"""Get the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user."""
|
||||
try:
|
||||
# Check if the BYOR key exists in the database
|
||||
byor_key = await get_byor_key_from_db(user_id)
|
||||
if byor_key:
|
||||
# Validate that the key is actually registered in LiteLLM
|
||||
is_valid = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
if is_valid:
|
||||
return {'key': byor_key}
|
||||
else:
|
||||
# Key exists in DB but is invalid in LiteLLM - regenerate it
|
||||
logger.warning(
|
||||
'BYOR key found in database but invalid in LiteLLM - regenerating',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'key_prefix': byor_key[:10] + '...'
|
||||
if len(byor_key) > 10
|
||||
else byor_key,
|
||||
},
|
||||
)
|
||||
# Delete the invalid key from LiteLLM (best effort, don't fail if it doesn't exist)
|
||||
await delete_byor_key_from_litellm(user_id, byor_key)
|
||||
# Fall through to generate a new key
|
||||
return {'key': byor_key}
|
||||
|
||||
# Generate a new key for BYOR (either no key exists or validation failed)
|
||||
# If not, generate a new key for BYOR
|
||||
key = await generate_byor_key(user_id)
|
||||
if key:
|
||||
# Store the key in the database
|
||||
await store_byor_key_in_db(user_id, key)
|
||||
logger.info(
|
||||
'Successfully generated and stored new BYOR key',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return {'key': key}
|
||||
else:
|
||||
logger.error(
|
||||
@@ -395,9 +301,6 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
detail='Failed to generate new BYOR LLM API key',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions as-is
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception('Error retrieving BYOR LLM API key', extra={'error': str(e)})
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import base64
|
||||
import json
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal, Optional
|
||||
@@ -14,12 +12,9 @@ from server.auth.constants import (
|
||||
KEYCLOAK_CLIENT_ID,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
ROLE_CHECK_ENABLED,
|
||||
)
|
||||
from server.auth.domain_blocker import domain_blocker
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.recaptcha_service import recaptcha_service
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config, sign_token
|
||||
@@ -102,24 +97,6 @@ def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
|
||||
)
|
||||
|
||||
|
||||
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
|
||||
"""Extract redirect URL and reCAPTCHA token from OAuth state.
|
||||
|
||||
Returns:
|
||||
Tuple of (redirect_url, recaptcha_token). Token may be None.
|
||||
"""
|
||||
if not state:
|
||||
return '', None
|
||||
|
||||
try:
|
||||
# Try to decode as JSON (new format with reCAPTCHA)
|
||||
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
|
||||
return state_data.get('redirect_url', ''), state_data.get('recaptcha_token')
|
||||
except Exception:
|
||||
# Old format - state is just the redirect URL
|
||||
return state, None
|
||||
|
||||
|
||||
@oauth_router.get('/keycloak/callback')
|
||||
async def keycloak_callback(
|
||||
request: Request,
|
||||
@@ -128,11 +105,7 @@ async def keycloak_callback(
|
||||
error: Optional[str] = None,
|
||||
error_description: Optional[str] = None,
|
||||
):
|
||||
# Extract redirect URL and reCAPTCHA token from state
|
||||
redirect_url, recaptcha_token = _extract_recaptcha_state(state)
|
||||
if not redirect_url:
|
||||
redirect_url = str(request.base_url)
|
||||
|
||||
redirect_url: str = state if state else str(request.base_url)
|
||||
if not code:
|
||||
# check if this is a forward from the account linking page
|
||||
if (
|
||||
@@ -172,120 +145,7 @@ async def keycloak_callback(
|
||||
content={'error': 'Missing user ID or username in response'},
|
||||
)
|
||||
|
||||
email = user_info.get('email')
|
||||
user_id = user_info['sub']
|
||||
|
||||
# reCAPTCHA verification with Account Defender
|
||||
if RECAPTCHA_SITE_KEY:
|
||||
if not recaptcha_token:
|
||||
logger.warning(
|
||||
'recaptcha_token_missing',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'email': email,
|
||||
},
|
||||
)
|
||||
error_url = f'{request.base_url}login?recaptcha_blocked=true'
|
||||
return RedirectResponse(error_url, status_code=302)
|
||||
|
||||
user_ip = request.client.host if request.client else 'unknown'
|
||||
user_agent = request.headers.get('User-Agent', '')
|
||||
|
||||
# Handle X-Forwarded-For for proxied requests
|
||||
forwarded_for = request.headers.get('X-Forwarded-For')
|
||||
if forwarded_for:
|
||||
user_ip = forwarded_for.split(',')[0].strip()
|
||||
|
||||
try:
|
||||
result = recaptcha_service.create_assessment(
|
||||
token=recaptcha_token,
|
||||
action='LOGIN',
|
||||
user_ip=user_ip,
|
||||
user_agent=user_agent,
|
||||
email=email,
|
||||
)
|
||||
|
||||
if not result.allowed:
|
||||
logger.warning(
|
||||
'recaptcha_blocked_at_callback',
|
||||
extra={
|
||||
'user_ip': user_ip,
|
||||
'score': result.score,
|
||||
'user_id': user_id,
|
||||
},
|
||||
)
|
||||
# Redirect to home with error parameter
|
||||
error_url = f'{request.base_url}login?recaptcha_blocked=true'
|
||||
return RedirectResponse(error_url, status_code=302)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f'reCAPTCHA verification error at callback: {e}')
|
||||
# Fail open - continue with login if reCAPTCHA service unavailable
|
||||
|
||||
# Check if email domain is blocked
|
||||
if email 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}/login?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}login?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)
|
||||
|
||||
@@ -111,26 +111,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(), timeout=15.0
|
||||
) 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(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)))
|
||||
|
||||
|
||||
# Endpoint to retrieve user's current subscription access
|
||||
|
||||
@@ -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}/login?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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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.'})
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
from integrations.gitlab.webhook_installation import (
|
||||
BreakLoopException,
|
||||
install_webhook_on_resource,
|
||||
verify_webhook_conditions,
|
||||
)
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL
|
||||
from pydantic import BaseModel
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.server.shared import sio
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
gitlab_integration_router = APIRouter(prefix='/integration')
|
||||
webhook_store = GitlabWebhookStore()
|
||||
@@ -31,37 +18,6 @@ token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class ResourceIdentifier(BaseModel):
|
||||
type: GitLabResourceType
|
||||
id: str
|
||||
|
||||
|
||||
class ReinstallWebhookRequest(BaseModel):
|
||||
resource: ResourceIdentifier
|
||||
|
||||
|
||||
class ResourceWithWebhookStatus(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
full_path: str
|
||||
type: str
|
||||
webhook_installed: bool
|
||||
webhook_uuid: str | None
|
||||
last_synced: str | None
|
||||
|
||||
|
||||
class GitLabResourcesResponse(BaseModel):
|
||||
resources: list[ResourceWithWebhookStatus]
|
||||
|
||||
|
||||
class ResourceInstallationResult(BaseModel):
|
||||
resource_id: str
|
||||
resource_type: str
|
||||
success: bool
|
||||
error: str | None
|
||||
|
||||
|
||||
async def verify_gitlab_signature(
|
||||
header_webhook_secret: str, webhook_uuid: str, user_id: str
|
||||
):
|
||||
@@ -127,260 +83,3 @@ async def gitlab_events(
|
||||
except Exception as e:
|
||||
logger.exception(f'Error processing GitLab event: {e}')
|
||||
return JSONResponse(status_code=400, content={'error': 'Invalid payload.'})
|
||||
|
||||
|
||||
@gitlab_integration_router.get('/gitlab/resources')
|
||||
async def get_gitlab_resources(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> GitLabResourcesResponse:
|
||||
"""Get all GitLab projects and groups where the user has admin access.
|
||||
|
||||
Returns a list of resources with their webhook installation status.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
# Fetch projects and groups with admin access
|
||||
projects, groups = await gitlab_service.get_user_resources_with_admin_access()
|
||||
|
||||
# Filter out projects that belong to a group (nested projects)
|
||||
# We only want top-level personal projects since group webhooks cover nested projects
|
||||
filtered_projects = [
|
||||
project
|
||||
for project in projects
|
||||
if project.get('namespace', {}).get('kind') != 'group'
|
||||
]
|
||||
|
||||
# Extract IDs for bulk fetching
|
||||
project_ids = [str(project['id']) for project in filtered_projects]
|
||||
group_ids = [str(group['id']) for group in groups]
|
||||
|
||||
# Bulk fetch webhook records from database (organization-wide)
|
||||
(
|
||||
project_webhook_map,
|
||||
group_webhook_map,
|
||||
) = await webhook_store.get_webhooks_by_resources(project_ids, group_ids)
|
||||
|
||||
# Parallelize GitLab API calls to check webhook status for all resources
|
||||
async def check_project_webhook(project):
|
||||
project_id = str(project['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.PROJECT, project_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return project_id, webhook_exists
|
||||
|
||||
async def check_group_webhook(group):
|
||||
group_id = str(group['id'])
|
||||
webhook_exists, _ = await gitlab_service.check_webhook_exists_on_resource(
|
||||
GitLabResourceType.GROUP, group_id, GITLAB_WEBHOOK_URL
|
||||
)
|
||||
return group_id, webhook_exists
|
||||
|
||||
# Gather all API calls in parallel
|
||||
project_checks = [
|
||||
check_project_webhook(project) for project in filtered_projects
|
||||
]
|
||||
group_checks = [check_group_webhook(group) for group in groups]
|
||||
|
||||
# Execute all checks concurrently
|
||||
all_results = await asyncio.gather(*(project_checks + group_checks))
|
||||
|
||||
# Split results back into projects and groups
|
||||
num_projects = len(filtered_projects)
|
||||
project_results = all_results[:num_projects]
|
||||
group_results = all_results[num_projects:]
|
||||
|
||||
# Build response
|
||||
resources = []
|
||||
|
||||
# Add projects with their webhook status
|
||||
for project, (project_id, webhook_exists) in zip(
|
||||
filtered_projects, project_results
|
||||
):
|
||||
webhook = project_webhook_map.get(project_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=project_id,
|
||||
name=project.get('name', ''),
|
||||
full_path=project.get('path_with_namespace', ''),
|
||||
type='project',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Add groups with their webhook status
|
||||
for group, (group_id, webhook_exists) in zip(groups, group_results):
|
||||
webhook = group_webhook_map.get(group_id)
|
||||
|
||||
resources.append(
|
||||
ResourceWithWebhookStatus(
|
||||
id=group_id,
|
||||
name=group.get('name', ''),
|
||||
full_path=group.get('full_path', ''),
|
||||
type='group',
|
||||
webhook_installed=webhook_exists,
|
||||
webhook_uuid=webhook.webhook_uuid if webhook else None,
|
||||
last_synced=(
|
||||
webhook.last_synced.isoformat()
|
||||
if webhook and webhook.last_synced
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Retrieved GitLab resources',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'project_count': len(projects),
|
||||
'group_count': len(groups),
|
||||
},
|
||||
)
|
||||
|
||||
return GitLabResourcesResponse(resources=resources)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error retrieving GitLab resources: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve GitLab resources',
|
||||
)
|
||||
|
||||
|
||||
@gitlab_integration_router.post('/gitlab/reinstall-webhook')
|
||||
async def reinstall_gitlab_webhook(
|
||||
body: ReinstallWebhookRequest,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> ResourceInstallationResult:
|
||||
"""Reinstall GitLab webhook for a specific resource immediately.
|
||||
|
||||
This endpoint validates permissions, resets webhook status in the database,
|
||||
and immediately installs the webhook on the specified resource.
|
||||
"""
|
||||
try:
|
||||
# Get GitLab service for the user
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Only SaaS GitLab service is supported',
|
||||
)
|
||||
|
||||
resource_id = body.resource.id
|
||||
resource_type = body.resource.type
|
||||
|
||||
# Check if user has admin access to this resource
|
||||
(
|
||||
has_admin_access,
|
||||
check_status,
|
||||
) = await gitlab_service.check_user_has_admin_access_to_resource(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not has_admin_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='User does not have admin access to this resource',
|
||||
)
|
||||
|
||||
# Reset webhook in database (organization-wide, not user-specific)
|
||||
# This allows any admin user to reinstall webhooks
|
||||
await webhook_store.reset_webhook_for_reinstallation_by_resource(
|
||||
resource_type, resource_id, user_id
|
||||
)
|
||||
|
||||
# Get or create webhook record (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
if not webhook:
|
||||
# Create new webhook record
|
||||
webhook = GitlabWebhook(
|
||||
user_id=user_id, # Track who created it
|
||||
project_id=resource_id
|
||||
if resource_type == GitLabResourceType.PROJECT
|
||||
else None,
|
||||
group_id=resource_id
|
||||
if resource_type == GitLabResourceType.GROUP
|
||||
else None,
|
||||
webhook_exists=False,
|
||||
)
|
||||
await webhook_store.store_webhooks([webhook])
|
||||
# Fetch it again to get the ID (without user_id filter)
|
||||
webhook = await webhook_store.get_webhook_by_resource_only(
|
||||
resource_type, resource_id
|
||||
)
|
||||
|
||||
# Verify conditions and install webhook
|
||||
try:
|
||||
await verify_webhook_conditions(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
# Install the webhook
|
||||
webhook_id, install_status = await install_webhook_on_resource(
|
||||
gitlab_service=gitlab_service,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
webhook_store=webhook_store,
|
||||
webhook=webhook,
|
||||
)
|
||||
|
||||
if webhook_id:
|
||||
logger.info(
|
||||
'GitLab webhook reinstalled successfully',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'resource_type': resource_type.value,
|
||||
'resource_id': resource_id,
|
||||
},
|
||||
)
|
||||
return ResourceInstallationResult(
|
||||
resource_id=resource_id,
|
||||
resource_type=resource_type.value,
|
||||
success=True,
|
||||
error=None,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to install webhook',
|
||||
)
|
||||
|
||||
except BreakLoopException:
|
||||
# Conditions not met or webhook already exists
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Webhook installation conditions not met or webhook already exists',
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f'Error reinstalling GitLab webhook: {e}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to reinstall webhook',
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ async def install_callback(
|
||||
|
||||
# Redirect into keycloak
|
||||
scope = quote('openid email profile offline_access')
|
||||
redirect_uri = f'{HOST_URL}/slack/keycloak-callback'
|
||||
redirect_uri = quote(f'{HOST_URL}/slack/keycloak-callback')
|
||||
auth_url = (
|
||||
f'{KEYCLOAK_SERVER_URL_EXT}/realms/{KEYCLOAK_REALM_NAME}/protocol/openid-connect/auth'
|
||||
f'?client_id={KEYCLOAK_CLIENT_ID}&response_type=code'
|
||||
@@ -158,7 +158,7 @@ async def keycloak_callback(
|
||||
team_id = payload['team_id']
|
||||
|
||||
# Retrieve the keycloak_user_id
|
||||
redirect_uri = f'{HOST_URL}{request.url.path}'
|
||||
redirect_uri = f'https://{request.url.netloc}{request.url.path}'
|
||||
(
|
||||
keycloak_access_token,
|
||||
keycloak_refresh_token,
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
from prometheus_client import Counter, Histogram
|
||||
from server.logger import logger
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
@@ -8,27 +9,45 @@ from openhands.events.observation import (
|
||||
)
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
|
||||
AGENT_STATUS_ERROR_COUNT = Counter(
|
||||
'saas_agent_status_errors', 'Agent Status change events to status error'
|
||||
)
|
||||
CREATE_CONVERSATION_COUNT = Counter(
|
||||
'saas_create_conversation', 'Create conversation attempts'
|
||||
)
|
||||
AGENT_SESSION_START_HISTOGRAM = Histogram(
|
||||
'saas_agent_session_start',
|
||||
'AgentSession starts with success and duration',
|
||||
labelnames=['success'],
|
||||
)
|
||||
|
||||
|
||||
class SaaSMonitoringListener(MonitoringListener):
|
||||
"""Forward app signals to structured logging for GCP native monitoring."""
|
||||
"""
|
||||
Forward app signals to Prometheus.
|
||||
"""
|
||||
|
||||
def on_session_event(self, event: Event) -> None:
|
||||
"""Track metrics about events being added to a Session's EventStream."""
|
||||
"""
|
||||
Track metrics about events being added to a Session's EventStream.
|
||||
"""
|
||||
if (
|
||||
isinstance(event, AgentStateChangedObservation)
|
||||
and event.agent_state == AgentState.ERROR
|
||||
):
|
||||
AGENT_STATUS_ERROR_COUNT.inc()
|
||||
logger.info(
|
||||
'Tracking agent status error',
|
||||
extra={'signal': 'saas_agent_status_errors'},
|
||||
)
|
||||
|
||||
def on_agent_session_start(self, success: bool, duration: float) -> None:
|
||||
"""Track an agent session start.
|
||||
|
||||
"""
|
||||
Track an agent session start.
|
||||
Success is true if startup completed without error.
|
||||
Duration is start time in seconds observed by AgentSession.
|
||||
"""
|
||||
AGENT_SESSION_START_HISTOGRAM.labels(success=success).observe(duration)
|
||||
logger.info(
|
||||
'Tracking agent session start',
|
||||
extra={
|
||||
@@ -39,10 +58,11 @@ class SaaSMonitoringListener(MonitoringListener):
|
||||
)
|
||||
|
||||
def on_create_conversation(self) -> None:
|
||||
"""Track the beginning of conversation creation.
|
||||
|
||||
"""
|
||||
Track the beginning of conversation creation.
|
||||
Does not currently capture whether it succeed.
|
||||
"""
|
||||
CREATE_CONVERSATION_COUNT.inc()
|
||||
logger.info(
|
||||
'Tracking create conversation', extra={'signal': 'saas_create_conversation'}
|
||||
)
|
||||
|
||||
@@ -12,8 +12,6 @@ from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import socketio
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import PERMITTED_CORS_ORIGINS, WEB_HOST
|
||||
from server.utils.conversation_callback_utils import (
|
||||
process_event,
|
||||
@@ -31,13 +29,8 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import MessageAction
|
||||
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,
|
||||
ProviderToken,
|
||||
)
|
||||
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
|
||||
@@ -77,14 +70,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
|
||||
|
||||
@@ -234,102 +219,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def _refresh_provider_tokens_after_runtime_init(
|
||||
self, settings: Settings, sid: str, user_id: str | None = None
|
||||
) -> Settings:
|
||||
"""Refresh provider tokens after runtime initialization.
|
||||
|
||||
During runtime initialization, tokens may be refreshed by Runtime.__init__().
|
||||
This method retrieves the fresh tokens from the database and creates a new
|
||||
settings object with updated tokens to avoid sending stale tokens to the
|
||||
nested runtime.
|
||||
|
||||
The method handles two scenarios:
|
||||
1. ProviderToken has user_id (IDP user ID, e.g., GitLab user ID)
|
||||
→ Uses get_idp_token_from_idp_user_id()
|
||||
2. ProviderToken has no user_id but Keycloak user_id is available
|
||||
→ Uses load_offline_token() + get_idp_token_from_offline_token()
|
||||
|
||||
Args:
|
||||
settings: The conversation settings that may contain provider tokens
|
||||
sid: The session ID for logging purposes
|
||||
user_id: The Keycloak user ID (optional, used as fallback when
|
||||
ProviderToken.user_id is not available)
|
||||
|
||||
Returns:
|
||||
Updated settings with fresh provider tokens, or original settings
|
||||
if no update is needed
|
||||
"""
|
||||
if not isinstance(settings, ConversationInitData):
|
||||
return settings
|
||||
|
||||
if not settings.git_provider_tokens:
|
||||
return settings
|
||||
|
||||
token_manager = TokenManager()
|
||||
updated_tokens = {}
|
||||
tokens_refreshed = 0
|
||||
tokens_failed = 0
|
||||
|
||||
for provider_type, provider_token in settings.git_provider_tokens.items():
|
||||
fresh_token = None
|
||||
|
||||
try:
|
||||
if provider_token.user_id:
|
||||
# Case 1: We have IDP user ID (e.g., GitLab user ID '32546706')
|
||||
# Get the token that was just refreshed during runtime initialization
|
||||
fresh_token = await token_manager.get_idp_token_from_idp_user_id(
|
||||
provider_token.user_id, provider_type
|
||||
)
|
||||
elif user_id:
|
||||
# Case 2: We have Keycloak user ID but no IDP user ID
|
||||
# This happens in web UI flow where ProviderToken.user_id is None
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
if offline_token:
|
||||
fresh_token = (
|
||||
await token_manager.get_idp_token_from_offline_token(
|
||||
offline_token, provider_type
|
||||
)
|
||||
)
|
||||
|
||||
if fresh_token:
|
||||
updated_tokens[provider_type] = ProviderToken(
|
||||
token=SecretStr(fresh_token),
|
||||
user_id=provider_token.user_id,
|
||||
host=provider_token.host,
|
||||
)
|
||||
tokens_refreshed += 1
|
||||
else:
|
||||
# Keep original token if we couldn't get a fresh one
|
||||
updated_tokens[provider_type] = provider_token
|
||||
|
||||
except Exception as e:
|
||||
# If refresh fails, use original token to prevent conversation startup failure
|
||||
logger.warning(
|
||||
f'Failed to refresh {provider_type.value} token: {e}',
|
||||
extra={'session_id': sid, 'provider': provider_type.value},
|
||||
exc_info=True,
|
||||
)
|
||||
updated_tokens[provider_type] = provider_token
|
||||
tokens_failed += 1
|
||||
|
||||
# Create new ConversationInitData with updated tokens
|
||||
# We cannot modify the frozen field directly, so we create a new object
|
||||
updated_settings = settings.model_copy(
|
||||
update={'git_provider_tokens': MappingProxyType(updated_tokens)}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Updated provider tokens after runtime creation',
|
||||
extra={
|
||||
'session_id': sid,
|
||||
'providers': [p.value for p in updated_tokens.keys()],
|
||||
'refreshed': tokens_refreshed,
|
||||
'failed': tokens_failed,
|
||||
},
|
||||
)
|
||||
return updated_settings
|
||||
|
||||
async def _start_agent_loop(
|
||||
self, sid, settings, user_id, initial_user_msg=None, replay_json=None
|
||||
):
|
||||
@@ -351,11 +240,6 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
|
||||
session_api_key = runtime.session.headers['X-Session-API-Key']
|
||||
|
||||
# Update provider tokens with fresh ones after runtime creation
|
||||
settings = await self._refresh_provider_tokens_after_runtime_init(
|
||||
settings, sid, user_id
|
||||
)
|
||||
|
||||
await self._start_conversation(
|
||||
sid,
|
||||
user_id,
|
||||
@@ -440,12 +324,7 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
async def _setup_provider_tokens(
|
||||
self, client: httpx.AsyncClient, api_url: str, settings: Settings
|
||||
):
|
||||
"""Setup provider tokens for the nested conversation.
|
||||
|
||||
Note: Token validation happens in the nested runtime. If tokens are revoked,
|
||||
the nested runtime will return 401. The caller should handle token refresh
|
||||
and retry if needed.
|
||||
"""
|
||||
"""Setup provider tokens for the nested conversation."""
|
||||
provider_handler = self._get_provider_handler(settings)
|
||||
provider_tokens = provider_handler.provider_tokens
|
||||
if provider_tokens:
|
||||
@@ -893,11 +772,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
|
||||
@@ -914,10 +789,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
|
||||
@@ -933,18 +804,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,
|
||||
|
||||
@@ -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
|
||||
@@ -1,158 +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
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from google.cloud import storage
|
||||
from google.cloud.storage.bucket import Bucket
|
||||
from google.cloud.storage.client import Client
|
||||
from pydantic import Field
|
||||
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.google_cloud_event_service import (
|
||||
GoogleCloudEventService,
|
||||
)
|
||||
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 GoogleCloudSharedEventService(SharedEventService):
|
||||
"""Implementation of SharedEventService that validates shared access."""
|
||||
|
||||
shared_conversation_info_service: SharedConversationInfoService
|
||||
bucket: Bucket
|
||||
|
||||
async def get_event_service(self, conversation_id: UUID) -> EventService | None:
|
||||
shared_conversation_info = (
|
||||
await self.shared_conversation_info_service.get_shared_conversation_info(
|
||||
conversation_id
|
||||
)
|
||||
)
|
||||
if shared_conversation_info is None:
|
||||
return None
|
||||
|
||||
return GoogleCloudEventService(
|
||||
bucket=self.bucket,
|
||||
prefix=Path('users'),
|
||||
user_id=shared_conversation_info.created_by_user_id,
|
||||
app_conversation_info_service=None,
|
||||
app_conversation_info_load_tasks={},
|
||||
)
|
||||
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
return None
|
||||
|
||||
# If conversation is shared, get the event
|
||||
return await event_service.get_event(conversation_id, 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
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service 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 event_service.search_events(
|
||||
conversation_id=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,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
# First check if the conversation is shared
|
||||
event_service = await self.get_event_service(conversation_id)
|
||||
if event_service is None:
|
||||
# Return empty page if conversation is not shared
|
||||
return 0
|
||||
|
||||
# If conversation is shared, count events for this conversation
|
||||
return await event_service.count_events(
|
||||
conversation_id=conversation_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
)
|
||||
|
||||
|
||||
class GoogleCloudSharedEventServiceInjector(SharedEventServiceInjector):
|
||||
bucket_name: str | None = Field(
|
||||
default_factory=lambda: os.environ.get('FILE_STORE_PATH')
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
async with get_db_session(state, request) as db_session:
|
||||
shared_conversation_info_service = SQLSharedConversationInfoService(
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
bucket_name = self.bucket_name
|
||||
storage_client: Client = storage.Client()
|
||||
bucket: Bucket = storage_client.bucket(bucket_name)
|
||||
|
||||
service = GoogleCloudSharedEventService(
|
||||
shared_conversation_info_service=shared_conversation_info_service,
|
||||
bucket=bucket,
|
||||
)
|
||||
yield service
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,128 +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.google_cloud_shared_event_service import (
|
||||
GoogleCloudSharedEventServiceInjector,
|
||||
)
|
||||
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(
|
||||
GoogleCloudSharedEventServiceInjector().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,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@router.get('')
|
||||
async def batch_get_shared_events(
|
||||
conversation_id: Annotated[
|
||||
str,
|
||||
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
|
||||
event_ids = [UUID(id_) for id_ in id]
|
||||
events = await shared_event_service.batch_get_shared_events(
|
||||
UUID(conversation_id), event_ids
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/{event_id}')
|
||||
async def get_shared_event(
|
||||
conversation_id: str,
|
||||
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(
|
||||
UUID(conversation_id), UUID(event_id)
|
||||
)
|
||||
@@ -1,63 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.services.injector import Injector
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SharedEventService(ABC):
|
||||
"""Event Service for getting events from shared conversations only."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_shared_event(
|
||||
self, conversation_id: UUID, event_id: UUID
|
||||
) -> Event | None:
|
||||
"""Given a conversation_id and event_id, retrieve an event if the conversation is shared."""
|
||||
|
||||
@abstractmethod
|
||||
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."""
|
||||
|
||||
@abstractmethod
|
||||
async def count_shared_events(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
kind__eq: EventKind | None = None,
|
||||
timestamp__gte: datetime | None = None,
|
||||
timestamp__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count events for a specific shared conversation."""
|
||||
|
||||
async def batch_get_shared_events(
|
||||
self, conversation_id: UUID, event_ids: list[UUID]
|
||||
) -> list[Event | None]:
|
||||
"""Given a conversation_id and list of event_ids, get events if the conversation is shared."""
|
||||
return await asyncio.gather(
|
||||
*[
|
||||
self.get_shared_event(conversation_id, event_id)
|
||||
for event_id in event_ids
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class SharedEventServiceInjector(
|
||||
DiscriminatedUnionMixin, Injector[SharedEventService], ABC
|
||||
):
|
||||
pass
|
||||
@@ -1,282 +0,0 @@
|
||||
"""SQL implementation of SharedConversationInfoService.
|
||||
|
||||
This implementation provides read-only access to shared conversations:
|
||||
- Direct database access without user permission checks
|
||||
- Filters only conversations marked as shared (currently public)
|
||||
- Full async/await support using SQL async db_sessions
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from server.sharing.shared_conversation_info_service import (
|
||||
SharedConversationInfoService,
|
||||
SharedConversationInfoServiceInjector,
|
||||
)
|
||||
from server.sharing.shared_conversation_models import (
|
||||
SharedConversation,
|
||||
SharedConversationPage,
|
||||
SharedConversationSortOrder,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
StoredConversationMetadata,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
"""SQL implementation of SharedConversationInfoService for shared conversations only."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
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."""
|
||||
query = self._public_select()
|
||||
|
||||
# Conditionally exclude sub-conversations based on the parameter
|
||||
if not include_sub_conversations:
|
||||
# Exclude sub-conversations (only include top-level conversations)
|
||||
query = query.where(
|
||||
StoredConversationMetadata.parent_conversation_id.is_(None)
|
||||
)
|
||||
|
||||
query = self._apply_filters(
|
||||
query=query,
|
||||
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,
|
||||
)
|
||||
|
||||
# Add sort order
|
||||
if sort_order == SharedConversationSortOrder.CREATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.created_at)
|
||||
elif sort_order == SharedConversationSortOrder.CREATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.created_at.desc())
|
||||
elif sort_order == SharedConversationSortOrder.UPDATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at)
|
||||
elif sort_order == SharedConversationSortOrder.UPDATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at.desc())
|
||||
elif sort_order == SharedConversationSortOrder.TITLE:
|
||||
query = query.order_by(StoredConversationMetadata.title)
|
||||
elif sort_order == SharedConversationSortOrder.TITLE_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.title.desc())
|
||||
|
||||
# Apply pagination
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
query = query.offset(offset)
|
||||
except ValueError:
|
||||
# If page_id is not a valid integer, start from beginning
|
||||
offset = 0
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
# Apply limit and get one extra to check if there are more results
|
||||
query = query.limit(limit + 1)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
items = [self._to_shared_conversation(row) for row in rows]
|
||||
|
||||
# Calculate next page ID
|
||||
next_page_id = None
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
|
||||
return SharedConversationPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
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 matching the given filters."""
|
||||
from sqlalchemy import func
|
||||
|
||||
query = select(func.count(StoredConversationMetadata.conversation_id))
|
||||
# Only include shared conversations
|
||||
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
query = query.where(StoredConversationMetadata.conversation_version == 'V1')
|
||||
|
||||
query = self._apply_filters(
|
||||
query=query,
|
||||
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,
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_shared_conversation_info(
|
||||
self, conversation_id: UUID
|
||||
) -> SharedConversation | None:
|
||||
"""Get a single public conversation info, returning None if missing or not shared."""
|
||||
query = self._public_select().where(
|
||||
StoredConversationMetadata.conversation_id == str(conversation_id)
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
stored = result.scalar_one_or_none()
|
||||
|
||||
if stored is None:
|
||||
return None
|
||||
|
||||
return self._to_shared_conversation(stored)
|
||||
|
||||
def _public_select(self):
|
||||
"""Create a select query that only returns public conversations."""
|
||||
query = select(StoredConversationMetadata).where(
|
||||
StoredConversationMetadata.conversation_version == 'V1'
|
||||
)
|
||||
# Only include conversations marked as public
|
||||
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
return query
|
||||
|
||||
def _apply_filters(
|
||||
self,
|
||||
query,
|
||||
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,
|
||||
):
|
||||
"""Apply common filters to a query."""
|
||||
if title__contains is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.title.contains(title__contains)
|
||||
)
|
||||
|
||||
if created_at__gte is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.created_at >= created_at__gte
|
||||
)
|
||||
|
||||
if created_at__lt is not None:
|
||||
query = query.where(StoredConversationMetadata.created_at < created_at__lt)
|
||||
|
||||
if updated_at__gte is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.last_updated_at >= updated_at__gte
|
||||
)
|
||||
|
||||
if updated_at__lt is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.last_updated_at < updated_at__lt
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def _to_shared_conversation(
|
||||
self,
|
||||
stored: StoredConversationMetadata,
|
||||
sub_conversation_ids: list[UUID] | None = None,
|
||||
) -> SharedConversation:
|
||||
"""Convert StoredConversationMetadata to SharedConversation."""
|
||||
# V1 conversations should always have a sandbox_id
|
||||
sandbox_id = stored.sandbox_id
|
||||
assert sandbox_id is not None
|
||||
|
||||
# Rebuild token usage
|
||||
token_usage = TokenUsage(
|
||||
prompt_tokens=stored.prompt_tokens,
|
||||
completion_tokens=stored.completion_tokens,
|
||||
cache_read_tokens=stored.cache_read_tokens,
|
||||
cache_write_tokens=stored.cache_write_tokens,
|
||||
context_window=stored.context_window,
|
||||
per_turn_token=stored.per_turn_token,
|
||||
)
|
||||
|
||||
# Rebuild metrics object
|
||||
metrics = MetricsSnapshot(
|
||||
accumulated_cost=stored.accumulated_cost,
|
||||
max_budget_per_task=stored.max_budget_per_task,
|
||||
accumulated_token_usage=token_usage,
|
||||
)
|
||||
|
||||
# Get timestamps
|
||||
created_at = self._fix_timezone(stored.created_at)
|
||||
updated_at = self._fix_timezone(stored.last_updated_at)
|
||||
|
||||
return SharedConversation(
|
||||
id=UUID(stored.conversation_id),
|
||||
created_by_user_id=stored.user_id if stored.user_id else None,
|
||||
sandbox_id=stored.sandbox_id,
|
||||
selected_repository=stored.selected_repository,
|
||||
selected_branch=stored.selected_branch,
|
||||
git_provider=(
|
||||
ProviderType(stored.git_provider) if stored.git_provider else None
|
||||
),
|
||||
title=stored.title,
|
||||
pr_number=stored.pr_number,
|
||||
llm_model=stored.llm_model,
|
||||
metrics=metrics,
|
||||
parent_conversation_id=(
|
||||
UUID(stored.parent_conversation_id)
|
||||
if stored.parent_conversation_id
|
||||
else None
|
||||
),
|
||||
sub_conversation_ids=sub_conversation_ids or [],
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def _fix_timezone(self, value: datetime) -> datetime:
|
||||
"""Sqlite does not store timezones - and since we can't update the existing models
|
||||
we assume UTC if the timezone is missing."""
|
||||
if not value.tzinfo:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
class SQLSharedConversationInfoServiceInjector(SharedConversationInfoServiceInjector):
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[SharedConversationInfoService, None]:
|
||||
# Define inline to prevent circular lookup
|
||||
from openhands.app_server.config import get_db_session
|
||||
|
||||
async with get_db_session(state, request) as db_session:
|
||||
service = SQLSharedConversationInfoService(db_session=db_session)
|
||||
yield service
|
||||
@@ -1,83 +0,0 @@
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import sio
|
||||
|
||||
# Rate limiting constants
|
||||
RATE_LIMIT_USER_SECONDS = 120 # 2 minutes per user_id
|
||||
RATE_LIMIT_IP_SECONDS = 300 # 5 minutes per IP address
|
||||
|
||||
|
||||
async def check_rate_limit_by_user_id(
|
||||
request: Request,
|
||||
key_prefix: str,
|
||||
user_id: str | None,
|
||||
user_rate_limit_seconds: int = RATE_LIMIT_USER_SECONDS,
|
||||
ip_rate_limit_seconds: int = RATE_LIMIT_IP_SECONDS,
|
||||
) -> None:
|
||||
"""
|
||||
Check rate limit for requests, using user_id when available, falling back to IP address.
|
||||
|
||||
Uses Redis to store rate limit keys with expiration. If a key already exists,
|
||||
it means the rate limit is active and the request will be rejected.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
key_prefix: Prefix for the Redis key (e.g., "email_resend")
|
||||
user_id: User ID if available, None otherwise
|
||||
user_rate_limit_seconds: Rate limit window in seconds for user_id-based limiting (default: 120)
|
||||
ip_rate_limit_seconds: Rate limit window in seconds for IP-based limiting (default: 300)
|
||||
|
||||
Raises:
|
||||
HTTPException: If rate limit is exceeded (429 status code)
|
||||
"""
|
||||
try:
|
||||
redis = sio.manager.redis
|
||||
if not redis:
|
||||
# If Redis is unavailable, log warning and allow request (fail open)
|
||||
logger.warning('Redis unavailable for rate limiting, allowing request')
|
||||
return
|
||||
|
||||
if user_id:
|
||||
# Rate limit by user_id (primary method)
|
||||
rate_limit_key = f'{key_prefix}:{user_id}'
|
||||
rate_limit_seconds = user_rate_limit_seconds
|
||||
else:
|
||||
# Fallback to IP address rate limiting
|
||||
client_ip = request.client.host if request.client else 'unknown'
|
||||
rate_limit_key = f'{key_prefix}:ip:{client_ip}'
|
||||
rate_limit_seconds = ip_rate_limit_seconds
|
||||
|
||||
# Try to set the key with expiration. If it already exists (nx=True fails),
|
||||
# it means the rate limit is active
|
||||
created = await redis.set(rate_limit_key, 1, nx=True, ex=rate_limit_seconds)
|
||||
|
||||
if not created:
|
||||
logger.info(
|
||||
f'Rate limit exceeded for {rate_limit_key}',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'ip': request.client.host if request.client else 'unknown',
|
||||
},
|
||||
)
|
||||
# Format error message based on duration
|
||||
if rate_limit_seconds < 60:
|
||||
wait_message = f'{rate_limit_seconds} seconds'
|
||||
elif rate_limit_seconds % 60 == 0:
|
||||
wait_message = f'{rate_limit_seconds // 60} minute{"s" if rate_limit_seconds // 60 != 1 else ""}'
|
||||
else:
|
||||
minutes = rate_limit_seconds // 60
|
||||
seconds = rate_limit_seconds % 60
|
||||
wait_message = f'{minutes} minute{"s" if minutes != 1 else ""} and {seconds} second{"s" if seconds != 1 else ""}'
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f'Too many requests. Please wait {wait_message} before trying again.',
|
||||
)
|
||||
except HTTPException:
|
||||
# Re-raise HTTPException (rate limit exceeded)
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log error but allow request (fail open) to avoid blocking legitimate users
|
||||
logger.warning(f'Error checking rate limit: {e}', exc_info=True)
|
||||
return
|
||||
@@ -1,270 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# OpenHands Enterprise Local Development Setup Script
|
||||
# This script helps set up the local development environment
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENTERPRISE_DIR="$SCRIPT_DIR"
|
||||
OPENHANDS_DIR="$(dirname "$ENTERPRISE_DIR")"
|
||||
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ OpenHands Enterprise Local Development Setup ║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Function to check if a Docker container is running
|
||||
container_running() {
|
||||
docker ps --format '{{.Names}}' | grep -q "^$1$"
|
||||
}
|
||||
|
||||
# Function to wait for a service
|
||||
wait_for_service() {
|
||||
local host=$1
|
||||
local port=$2
|
||||
local name=$3
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
echo -ne " Waiting for $name to be ready..."
|
||||
while ! nc -z "$host" "$port" 2>/dev/null; do
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo -e " ${RED}FAILED${NC}"
|
||||
echo -e " ${RED}$name did not become ready in time${NC}"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
echo -ne "."
|
||||
done
|
||||
echo -e " ${GREEN}READY${NC}"
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
echo -e "${YELLOW}Checking prerequisites...${NC}"
|
||||
|
||||
if ! command_exists docker; then
|
||||
echo -e " ${RED}✗ Docker is not installed${NC}"
|
||||
echo " Please install Docker: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓ Docker${NC}"
|
||||
|
||||
if ! command_exists docker-compose && ! docker compose version >/dev/null 2>&1; then
|
||||
echo -e " ${RED}✗ Docker Compose is not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓ Docker Compose${NC}"
|
||||
|
||||
if ! command_exists poetry; then
|
||||
echo -e " ${RED}✗ Poetry is not installed${NC}"
|
||||
echo " Install with: curl -sSL https://install.python-poetry.org | python3 -"
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓ Poetry${NC}"
|
||||
|
||||
echo ""
|
||||
|
||||
# Parse arguments
|
||||
MINIMAL=false
|
||||
FULL=false
|
||||
START_ONLY=false
|
||||
STOP=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--minimal)
|
||||
MINIMAL=true
|
||||
shift
|
||||
;;
|
||||
--full)
|
||||
FULL=true
|
||||
shift
|
||||
;;
|
||||
--start)
|
||||
START_ONLY=true
|
||||
shift
|
||||
;;
|
||||
--stop)
|
||||
STOP=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --minimal Start only PostgreSQL and Redis (no Keycloak/LiteLLM)"
|
||||
echo " --full Start all services including Keycloak and LiteLLM"
|
||||
echo " --start Only start services, skip installation"
|
||||
echo " --stop Stop all services"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Stop services
|
||||
if [ "$STOP" = true ]; then
|
||||
echo -e "${YELLOW}Stopping services...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
if [ -f docker-compose.local.yml ]; then
|
||||
docker compose -f docker-compose.local.yml down 2>/dev/null || true
|
||||
fi
|
||||
docker rm -f openhands-postgres openhands-redis openhands-keycloak openhands-litellm 2>/dev/null || true
|
||||
echo -e "${GREEN}Services stopped${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Determine mode
|
||||
if [ "$MINIMAL" = false ] && [ "$FULL" = false ]; then
|
||||
echo -e "${YELLOW}Select setup mode:${NC}"
|
||||
echo " 1) Minimal (PostgreSQL + Redis only)"
|
||||
echo " 2) Full (PostgreSQL + Redis + Keycloak + LiteLLM)"
|
||||
read -p "Enter choice [1]: " choice
|
||||
choice=${choice:-1}
|
||||
|
||||
if [ "$choice" = "1" ]; then
|
||||
MINIMAL=true
|
||||
else
|
||||
FULL=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Start services
|
||||
if [ "$MINIMAL" = true ]; then
|
||||
echo -e "${YELLOW}Starting minimal services (PostgreSQL + Redis)...${NC}"
|
||||
|
||||
# PostgreSQL
|
||||
if ! container_running openhands-postgres; then
|
||||
echo -e " Starting PostgreSQL..."
|
||||
docker run -d \
|
||||
--name openhands-postgres \
|
||||
-p 5432:5432 \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=openhands \
|
||||
postgres:15 >/dev/null
|
||||
else
|
||||
echo -e " ${GREEN}PostgreSQL already running${NC}"
|
||||
fi
|
||||
|
||||
# Redis
|
||||
if ! container_running openhands-redis; then
|
||||
echo -e " Starting Redis..."
|
||||
docker run -d \
|
||||
--name openhands-redis \
|
||||
-p 6379:6379 \
|
||||
redis:7-alpine >/dev/null
|
||||
else
|
||||
echo -e " ${GREEN}Redis already running${NC}"
|
||||
fi
|
||||
|
||||
wait_for_service localhost 5432 "PostgreSQL"
|
||||
wait_for_service localhost 6379 "Redis"
|
||||
|
||||
else
|
||||
echo -e "${YELLOW}Starting full services (PostgreSQL + Redis + Keycloak + LiteLLM)...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
|
||||
# Use docker compose
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
else
|
||||
docker-compose -f docker-compose.local.yml up -d
|
||||
fi
|
||||
|
||||
wait_for_service localhost 5432 "PostgreSQL"
|
||||
wait_for_service localhost 6379 "Redis"
|
||||
wait_for_service localhost 8080 "Keycloak"
|
||||
wait_for_service localhost 4000 "LiteLLM"
|
||||
fi
|
||||
|
||||
if [ "$START_ONLY" = true ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}Services started!${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
echo -e "${YELLOW}Configuring environment...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
echo -e " ${GREEN}Created .env file from template${NC}"
|
||||
echo -e " ${YELLOW}Please edit .env to add your LLM API keys!${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}.env file already exists${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Install Python dependencies
|
||||
echo -e "${YELLOW}Installing Python dependencies...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
poetry install
|
||||
|
||||
echo ""
|
||||
|
||||
# Run database migrations
|
||||
echo -e "${YELLOW}Running database migrations...${NC}"
|
||||
poetry run alembic upgrade head
|
||||
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Setup Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Services running:"
|
||||
echo -e " • PostgreSQL: ${BLUE}localhost:5432${NC}"
|
||||
echo -e " • Redis: ${BLUE}localhost:6379${NC}"
|
||||
if [ "$FULL" = true ]; then
|
||||
echo -e " • Keycloak: ${BLUE}http://localhost:8080${NC} (admin/admin)"
|
||||
echo -e " • LiteLLM: ${BLUE}http://localhost:4000${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. Edit .env and add your LLM API keys (OPENAI_API_KEY or ANTHROPIC_API_KEY)"
|
||||
echo ""
|
||||
if [ "$FULL" = true ]; then
|
||||
echo "2. Configure LiteLLM with your API keys:"
|
||||
echo " docker exec -it openhands-litellm /bin/sh"
|
||||
echo " # Then set environment variables"
|
||||
echo ""
|
||||
echo "3. (Optional) Configure Keycloak realm for OAuth"
|
||||
echo ""
|
||||
fi
|
||||
echo "3. Build the frontend (from repo root):"
|
||||
echo " cd .. && make build-frontend"
|
||||
echo ""
|
||||
echo "4. Start the backend server:"
|
||||
echo " cd enterprise"
|
||||
echo " OPENHANDS_PATH=../ make start-backend"
|
||||
echo ""
|
||||
echo "5. Access the application at: ${BLUE}http://localhost:3000${NC}"
|
||||
echo ""
|
||||
echo "To stop all services: $0 --stop"
|
||||
@@ -17,13 +17,10 @@ from openhands.core.logger import openhands_logger as logger
|
||||
class ApiKeyStore:
|
||||
session_maker: sessionmaker
|
||||
|
||||
API_KEY_PREFIX = 'sk-oh-'
|
||||
|
||||
def generate_api_key(self, length: int = 32) -> str:
|
||||
"""Generate a random API key with the sk-oh- prefix."""
|
||||
"""Generate a random API key."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return f'{self.API_KEY_PREFIX}{random_part}'
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
def create_api_key(
|
||||
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
|
||||
@@ -60,15 +57,9 @@ class ApiKeyStore:
|
||||
return None
|
||||
|
||||
# Check if the key has expired
|
||||
if key_record.expires_at:
|
||||
# Handle timezone-naive datetime from database by assuming it's UTC
|
||||
expires_at = key_record.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
|
||||
if expires_at < now:
|
||||
logger.info(f'API key has expired: {key_record.id}')
|
||||
return None
|
||||
if key_record.expires_at and key_record.expires_at < now:
|
||||
logger.info(f'API key has expired: {key_record.id}')
|
||||
return None
|
||||
|
||||
# Update last_used_at timestamp
|
||||
session.execute(
|
||||
@@ -134,33 +125,6 @@ class ApiKeyStore:
|
||||
|
||||
return None
|
||||
|
||||
def retrieve_api_key_by_name(self, user_id: str, name: str) -> str | None:
|
||||
"""Retrieve an API key by name for a specific user."""
|
||||
with self.session_maker() as session:
|
||||
key_record = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
.first()
|
||||
)
|
||||
return key_record.key if key_record else None
|
||||
|
||||
def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
|
||||
"""Delete an API key by name for a specific user."""
|
||||
with self.session_maker() as session:
|
||||
key_record = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id, ApiKey.name == name)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not key_record:
|
||||
return False
|
||||
|
||||
session.delete(key_record)
|
||||
session.commit()
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> ApiKeyStore:
|
||||
"""Get an instance of the ApiKeyStore."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user