mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
69 Commits
fix/sqlalc
...
fix-llm-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
982db9c6d8 | ||
|
|
25262a3a3f | ||
|
|
862c363ded | ||
|
|
cf156b0073 | ||
|
|
703a1eeca2 | ||
|
|
a6573de584 | ||
|
|
23b3b188c4 | ||
|
|
2ff094b363 | ||
|
|
b0169342f7 | ||
|
|
d5036c2813 | ||
|
|
8f0f3e49c8 | ||
|
|
03f49a40a0 | ||
|
|
3a85dbce78 | ||
|
|
4e63531fa6 | ||
|
|
db48a7af26 | ||
|
|
4c8179cd08 | ||
|
|
9e3aed7f53 | ||
|
|
3a40ecb931 | ||
|
|
f8b4f9369f | ||
|
|
5bb6522f2f | ||
|
|
273c38f0b6 | ||
|
|
02b999c166 | ||
|
|
28d26f8178 | ||
|
|
2468708293 | ||
|
|
a89811f952 | ||
|
|
aef5f9cc89 | ||
|
|
aea611602f | ||
|
|
fc4c62a73d | ||
|
|
b41dd2ba8b | ||
|
|
731183e069 | ||
|
|
c22c03eeb6 | ||
|
|
1093afdced | ||
|
|
93355fd770 | ||
|
|
6464eaed3c | ||
|
|
237948978b | ||
|
|
baa3a7e5b7 | ||
|
|
dd7234d712 | ||
|
|
2a6f5c8976 | ||
|
|
e86067c15b | ||
|
|
137bede1f5 | ||
|
|
8a1d80ac8f | ||
|
|
77043da280 | ||
|
|
180a35f013 | ||
|
|
18365e0323 | ||
|
|
9a743ff51a | ||
|
|
29577935b4 | ||
|
|
7498353ed5 | ||
|
|
b62bdfd143 | ||
|
|
fb98faf4ac | ||
|
|
a8f62aa30c | ||
|
|
1a7449b03a | ||
|
|
1091901be2 | ||
|
|
15160f6733 | ||
|
|
13dba59bb8 | ||
|
|
478c998f04 | ||
|
|
a9fc93ffbf | ||
|
|
cc100c0d10 | ||
|
|
7bc3300981 | ||
|
|
3e0283796e | ||
|
|
cd0175d83e | ||
|
|
f313cfceb9 | ||
|
|
fb0108f946 | ||
|
|
6b29a82de3 | ||
|
|
033c6202b7 | ||
|
|
d64d0d6bf6 | ||
|
|
b357c0c3bb | ||
|
|
16374dc9c0 | ||
|
|
a8926068ff | ||
|
|
f318792a17 |
@@ -46,34 +46,12 @@ These files contain image tags that **must** be updated whenever the SDK version
|
||||
### `openhands/version.py`
|
||||
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
|
||||
|
||||
### `openhands/resolver/issue_resolver.py`
|
||||
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
|
||||
|
||||
### `openhands/runtime/utils/runtime_build.py`
|
||||
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
|
||||
|
||||
### `.github/scripts/update_pr_description.sh`
|
||||
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
|
||||
|
||||
### `enterprise/Dockerfile`
|
||||
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
|
||||
|
||||
## V0 Legacy Files (separate update cadence)
|
||||
|
||||
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
|
||||
|
||||
### `Development.md`
|
||||
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
|
||||
|
||||
### `openhands/runtime/impl/kubernetes/README.md`
|
||||
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
|
||||
|
||||
### `enterprise/enterprise_local/README.md`
|
||||
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
|
||||
|
||||
### `third_party/runtime/impl/daytona/README.md`
|
||||
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
|
||||
|
||||
## Image Registries
|
||||
|
||||
| Registry | Usage |
|
||||
|
||||
228
.github/workflows/e2e-tests.yml
vendored
228
.github/workflows/e2e-tests.yml
vendored
@@ -1,228 +0,0 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
GITHUB_REPO_NAME: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
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
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend/package-lock.json'
|
||||
|
||||
- name: Setup environment for end-to-end tests
|
||||
run: |
|
||||
# Create test results directory
|
||||
mkdir -p test-results
|
||||
|
||||
# Create downloads directory for OpenHands (use a directory in the home folder)
|
||||
mkdir -p $HOME/downloads
|
||||
sudo chown -R $USER:$USER $HOME/downloads
|
||||
sudo chmod -R 755 $HOME/downloads
|
||||
|
||||
- name: Build OpenHands
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
INSTALL_DOCKER: 1
|
||||
RUNTIME: docker
|
||||
FRONTEND_PORT: 12000
|
||||
FRONTEND_HOST: 0.0.0.0
|
||||
BACKEND_HOST: 0.0.0.0
|
||||
BACKEND_PORT: 3000
|
||||
ENABLE_BROWSER: true
|
||||
INSTALL_PLAYWRIGHT: 1
|
||||
run: |
|
||||
# Fix poetry.lock file if needed
|
||||
echo "Fixing poetry.lock file if needed..."
|
||||
poetry lock
|
||||
|
||||
# Build OpenHands using make build
|
||||
echo "Running make build..."
|
||||
make build
|
||||
|
||||
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
|
||||
echo "Installing Chromium Headless Shell for Playwright..."
|
||||
poetry run playwright install chromium-headless-shell
|
||||
|
||||
# Verify Playwright browsers are installed (for e2e tests only)
|
||||
echo "Verifying Playwright browsers installation for e2e tests..."
|
||||
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
|
||||
|
||||
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
|
||||
echo "ERROR: Chromium browser not found or not working for e2e tests"
|
||||
echo "$BROWSER_CHECK"
|
||||
exit 1
|
||||
else
|
||||
echo "Playwright browsers are properly installed for e2e tests."
|
||||
fi
|
||||
|
||||
# Docker runtime will handle workspace directory creation
|
||||
|
||||
# Start the application using make run with custom parameters and reduced logging
|
||||
echo "Starting OpenHands using make run..."
|
||||
# Set environment variables to reduce logging verbosity
|
||||
export PYTHONUNBUFFERED=1
|
||||
export LOG_LEVEL=WARNING
|
||||
export UVICORN_LOG_LEVEL=warning
|
||||
export OPENHANDS_LOG_LEVEL=WARNING
|
||||
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
|
||||
|
||||
# Store the PID of the make run process
|
||||
MAKE_PID=$!
|
||||
echo "OpenHands started with PID: $MAKE_PID"
|
||||
|
||||
# Wait for the application to start
|
||||
echo "Waiting for OpenHands to start..."
|
||||
max_attempts=15
|
||||
attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
|
||||
|
||||
# Check if the process is still running
|
||||
if ! ps -p $MAKE_PID > /dev/null; then
|
||||
echo "ERROR: OpenHands process has terminated unexpectedly"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if frontend port is open
|
||||
if nc -z localhost 12000; then
|
||||
# Verify we can get HTML content
|
||||
if curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
|
||||
break
|
||||
else
|
||||
echo "Port 12000 is open but not serving HTML content yet"
|
||||
fi
|
||||
else
|
||||
echo "Frontend port 12000 is not open yet"
|
||||
fi
|
||||
|
||||
# Show log output on each attempt
|
||||
echo "Recent log output:"
|
||||
tail -n 20 /tmp/openhands-e2e-test.log
|
||||
|
||||
# Wait before next attempt
|
||||
echo "Waiting 10 seconds before next check..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# Exit if we've reached the maximum number of attempts
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Final verification that the app is running
|
||||
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "ERROR: OpenHands is not running properly on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print success message
|
||||
echo "OpenHands is running successfully on port 12000"
|
||||
|
||||
- name: Run end-to-end tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
run: |
|
||||
# Check if the application is running
|
||||
if ! nc -z localhost 12000; then
|
||||
echo "ERROR: OpenHands is not running on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the tests with detailed output
|
||||
cd tests/e2e
|
||||
poetry run python -m pytest \
|
||||
test_settings.py::test_github_token_configuration \
|
||||
test_conversation.py::test_conversation_start \
|
||||
test_browsing_catchphrase.py::test_browsing_catchphrase \
|
||||
test_multi_conversation_resume.py::test_multi_conversation_resume \
|
||||
-v --no-header --capture=no --timeout=900
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
/tmp/openhands-e2e-test.log
|
||||
/tmp/openhands-e2e-build.log
|
||||
/tmp/openhands-backend.log
|
||||
/tmp/openhands-frontend.log
|
||||
/tmp/backend-health-check.log
|
||||
/tmp/frontend-check.log
|
||||
/tmp/vite-config.log
|
||||
/tmp/makefile-contents.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
# Stop OpenHands processes
|
||||
echo "Stopping OpenHands processes..."
|
||||
pkill -f "python -m openhands.server" || true
|
||||
pkill -f "npm run dev" || true
|
||||
pkill -f "make run" || true
|
||||
|
||||
# Print process status for debugging
|
||||
echo "Checking if any OpenHands processes are still running:"
|
||||
ps aux | grep -E "openhands|npm run dev" || true
|
||||
433
.github/workflows/openhands-resolver.yml
vendored
433
.github/workflows/openhands-resolver.yml
vendored
@@ -1,433 +0,0 @@
|
||||
name: Auto-Fix Tagged Issue with OpenHands
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
max_iterations:
|
||||
required: false
|
||||
type: number
|
||||
default: 50
|
||||
macro:
|
||||
required: false
|
||||
type: string
|
||||
default: "@openhands-agent"
|
||||
target_branch:
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
description: "Target branch to pull and create PR against"
|
||||
pr_type:
|
||||
required: false
|
||||
type: string
|
||||
default: "draft"
|
||||
description: "The PR type that is going to be created (draft, ready)"
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
type: string
|
||||
default: "anthropic/claude-sonnet-4-20250514"
|
||||
LLM_API_VERSION:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
base_container_image:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
description: "Custom sandbox env"
|
||||
runner:
|
||||
required: false
|
||||
type: string
|
||||
default: "ubuntu-latest"
|
||||
secrets:
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
LLM_API_KEY:
|
||||
required: true
|
||||
LLM_BASE_URL:
|
||||
required: false
|
||||
PAT_TOKEN:
|
||||
required: false
|
||||
PAT_USERNAME:
|
||||
required: false
|
||||
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
github.event.label.name == 'fix-me' ||
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
contains(github.event.comment.body, inputs.macro || '@openhands-agent') &&
|
||||
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
|
||||
) ||
|
||||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, inputs.macro || '@openhands-agent') &&
|
||||
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
|
||||
)
|
||||
)
|
||||
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Upgrade pip
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
- name: Get latest versions and create requirements.txt
|
||||
run: |
|
||||
python -m pip index versions openhands-ai > openhands_versions.txt
|
||||
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
|
||||
|
||||
# Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file
|
||||
echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt
|
||||
cat /tmp/requirements.txt
|
||||
|
||||
- name: Cache pip dependencies
|
||||
if: |
|
||||
!(
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
contains(github.event.comment.body, '@openhands-agent-exp')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@openhands-agent-exp')
|
||||
)
|
||||
)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
|
||||
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
|
||||
|
||||
- name: Check required environment variables
|
||||
env:
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PAT_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
||||
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
required_vars=("LLM_API_KEY")
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo "Error: Required environment variable $var is not set."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check optional variables and warn about fallbacks
|
||||
if [ -z "$LLM_BASE_URL" ]; then
|
||||
echo "Warning: LLM_BASE_URL is not set, will use default API endpoint"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_TOKEN" ]; then
|
||||
echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_USERNAME" ]; then
|
||||
echo "Warning: PAT_USERNAME is not set, will use openhands-agent"
|
||||
fi
|
||||
|
||||
- name: Set environment variables
|
||||
env:
|
||||
REVIEW_BODY: ${{ github.event.review.body || '' }}
|
||||
run: |
|
||||
# Handle pull request events first
|
||||
if [ -n "${{ github.event.pull_request.number }}" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle pull request review events
|
||||
elif [ -n "$REVIEW_BODY" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle issue comment events that reference a PR
|
||||
elif [ -n "${{ github.event.issue.pull_request }}" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle regular issue events
|
||||
else
|
||||
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
if [ -n "$REVIEW_BODY" ]; then
|
||||
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
|
||||
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}" >> $GITHUB_ENV
|
||||
echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
|
||||
|
||||
# Set branch variables
|
||||
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Comment on issue with start message
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const issueType = process.env.ISSUE_TYPE;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: ${{ env.ISSUE_NUMBER }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `[OpenHands](https://github.com/OpenHands/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
|
||||
});
|
||||
|
||||
- name: Install OpenHands
|
||||
id: install_openhands
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body || '' }}
|
||||
REVIEW_BODY: ${{ github.event.review.body || '' }}
|
||||
LABEL_NAME: ${{ github.event.label.name || '' }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
with:
|
||||
script: |
|
||||
const commentBody = process.env.COMMENT_BODY.trim();
|
||||
const reviewBody = process.env.REVIEW_BODY.trim();
|
||||
const labelName = process.env.LABEL_NAME.trim();
|
||||
const eventName = process.env.EVENT_NAME.trim();
|
||||
// Check conditions
|
||||
const isExperimentalLabel = labelName === "fix-me-experimental";
|
||||
const isIssueCommentExperimental =
|
||||
(eventName === "issue_comment" || eventName === "pull_request_review_comment") &&
|
||||
commentBody.includes("@openhands-agent-exp");
|
||||
const isReviewCommentExperimental =
|
||||
eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
|
||||
|
||||
// Set output variable
|
||||
core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
|
||||
|
||||
// Perform package installation
|
||||
if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
|
||||
console.log("Installing experimental OpenHands...");
|
||||
|
||||
await exec.exec("pip install git+https://github.com/openhands/openhands.git");
|
||||
} else {
|
||||
console.log("Installing from requirements.txt...");
|
||||
|
||||
await exec.exec("pip install -r /tmp/requirements.txt");
|
||||
}
|
||||
|
||||
- name: Attempt to resolve issue
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PYTHONPATH: ""
|
||||
run: |
|
||||
cd /tmp && python -m openhands.resolver.resolve_issue \
|
||||
--selected-repo ${{ github.repository }} \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--issue-type ${{ env.ISSUE_TYPE }} \
|
||||
--max-iterations ${{ env.MAX_ITERATIONS }} \
|
||||
--comment-id ${{ env.COMMENT_ID }} \
|
||||
--is-experimental ${{ steps.install_openhands.outputs.isExperimental }}
|
||||
|
||||
- name: Check resolution result
|
||||
id: check_result
|
||||
run: |
|
||||
if cd /tmp && grep -q '"success":true' output/output.jsonl; then
|
||||
echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Upload output.jsonl as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() # Upload even if the previous steps fail
|
||||
with:
|
||||
name: resolver-output
|
||||
path: /tmp/output/output.jsonl
|
||||
retention-days: 30 # Keep the artifact for 30 days
|
||||
|
||||
- name: Create draft PR or push branch
|
||||
if: always() # Create PR or branch even if the previous steps fail
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PYTHONPATH: ""
|
||||
run: |
|
||||
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--target-branch ${{ env.TARGET_BRANCH }} \
|
||||
--pr-type ${{ inputs.pr_type || 'draft' }} \
|
||||
--reviewer ${{ github.actor }} | tee pr_result.txt && \
|
||||
grep "PR created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
|
||||
else
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--pr-type branch \
|
||||
--send-on-failure | tee branch_result.txt && \
|
||||
grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
|
||||
fi
|
||||
|
||||
# Step leaves comment for when agent is invoked on PR
|
||||
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
|
||||
uses: actions/github-script@v9
|
||||
if: always()
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
let logContent = '';
|
||||
|
||||
try {
|
||||
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
|
||||
} catch (error) {
|
||||
console.error('Error reading pr_result.txt file:', error);
|
||||
}
|
||||
|
||||
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
|
||||
|
||||
// Check logs from send_pull_request.py (pushes code to GitHub)
|
||||
if (logContent.includes("Updated pull request")) {
|
||||
console.log("Updated pull request found. Skipping comment.");
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (logContent.includes(noChangesMessage)) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Step leaves comment for when agent is invoked on issue
|
||||
- name: Comment on issue # Comment link to either PR or branch created by agent
|
||||
uses: actions/github-script@v9
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
const success = process.env.RESOLUTION_SUCCESS === 'true';
|
||||
|
||||
let prNumber = '';
|
||||
let branchName = '';
|
||||
let resultExplanation = '';
|
||||
|
||||
try {
|
||||
if (success) {
|
||||
prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim();
|
||||
} else {
|
||||
branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (!success){
|
||||
// Read result_explanation from JSON file for failed resolution
|
||||
const outputFilePath = path.resolve('/tmp/output/output.jsonl');
|
||||
if (fs.existsSync(outputFilePath)) {
|
||||
const outputContent = fs.readFileSync(outputFilePath, 'utf8');
|
||||
const jsonLines = outputContent.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
if (jsonLines.length > 0) {
|
||||
// First entry in JSON lines has the key 'result_explanation'
|
||||
const firstEntry = JSON.parse(jsonLines[0]);
|
||||
resultExplanation = firstEntry.result_explanation || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error){
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
// Check "success" log from resolver output
|
||||
if (success && prNumber) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (!success && branchName) {
|
||||
let commentBody = `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`;
|
||||
|
||||
if (resultExplanation) {
|
||||
commentBody += `\n\nAdditional details about the failure:\n${resultExplanation}`;
|
||||
}
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: commentBody
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Leave error comment when both PR/Issue comment handling fail
|
||||
- name: Fallback Error Comment
|
||||
uses: actions/github-script@v9
|
||||
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
|
||||
});
|
||||
40
.github/workflows/pr-review-by-openhands.yml
vendored
40
.github/workflows/pr-review-by-openhands.yml
vendored
@@ -2,12 +2,14 @@
|
||||
name: PR Review by OpenHands
|
||||
|
||||
on:
|
||||
# TEMPORARY MITIGATION (Clinejection hardening)
|
||||
#
|
||||
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
|
||||
# workflow is fully hardened for untrusted execution.
|
||||
# Use pull_request for same-repo PRs so workflow changes can self-verify in PRs.
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
# Use pull_request_target for fork PRs.
|
||||
# The bot token used here is intentionally scoped to PR review operations,
|
||||
# so the remaining blast radius is bounded even though PR content is untrusted.
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,13 +18,33 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-review:
|
||||
# Note: fork PRs will not have access to repository secrets under `pull_request`.
|
||||
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
|
||||
# Run on same-repo PRs via pull_request and on fork PRs via pull_request_target.
|
||||
# Trigger when one of the following conditions is met:
|
||||
# 1. A new non-draft PR is opened by a non-first-time contributor, OR
|
||||
# 2. A draft PR is converted to ready for review by a non-first-time contributor, OR
|
||||
# 3. The 'review-this' label is added, OR
|
||||
# 4. openhands-agent or all-hands-bot is requested as a reviewer
|
||||
# Note: FIRST_TIME_CONTRIBUTOR and NONE PRs require manual trigger via label/reviewer request.
|
||||
# Trigger logic:
|
||||
# 1. Route same-repo PRs through `pull_request` and fork PRs through `pull_request_target`
|
||||
# 2. Auto-trigger on `opened` / `ready_for_review` for non-first-time contributors
|
||||
# 3. Always allow manual triggers via `review-this` or reviewer request
|
||||
# The author association check is duplicated intentionally for both
|
||||
# auto-triggered actions (`opened` and `ready_for_review`).
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
|
||||
github.event.action == 'ready_for_review' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
)
|
||||
) &&
|
||||
(
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
|
||||
(
|
||||
github.event.action == 'review_requested' &&
|
||||
|
||||
4
.github/workflows/py-tests.yml
vendored
4
.github/workflows/py-tests.yml
vendored
@@ -60,10 +60,6 @@ jobs:
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -18,6 +18,8 @@ Local run troubleshooting notes:
|
||||
- If local runtime startup fails with `duplicate session: test-session`, clear the stale tmux session on the default socket: `tmux -S /tmp/tmux-$(id -u)/default kill-session -t test-session`.
|
||||
- Local runtime browser startup expects Playwright browsers under `~/.cache/playwright`; if needed run `PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/playwright poetry run playwright install chromium`.
|
||||
- In this sandbox environment, an inherited `SESSION_API_KEY` can make `/api/v1/settings` return 401 in the browser. Unset it before `make run` when you want to use the local web UI directly.
|
||||
- In this sandbox, `frontend`'s `npm run dev:mock` / `dev:mock:saas` can start but still be awkward to browse through the work-host proxy. For PR QA screenshots, a reliable fallback is to `npm run build` with the desired `VITE_MOCK_*` env, then serve `build/` with a tiny custom HTTP server that returns the minimal mock JSON endpoints needed by the settings page.
|
||||
|
||||
|
||||
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
|
||||
|
||||
@@ -144,6 +146,8 @@ Frontend:
|
||||
- Query hooks should follow the pattern use[Resource] (e.g., `useConversationSkills`)
|
||||
- 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
|
||||
- For SaaS organization management screens, prefer deriving the selected organization from `useOrganizations()` plus the selected org ID store instead of adding a dedicated single-org fetch when only list-level fields (for example `name`) are needed.
|
||||
|
||||
|
||||
VSCode Extension:
|
||||
- Located in the `openhands/integrations/vscode` directory
|
||||
@@ -280,6 +284,32 @@ If you are starting a pull request (PR), please follow the template in `.github/
|
||||
|
||||
These details may or may not be useful for your current task.
|
||||
|
||||
### Conversation State Management
|
||||
|
||||
#### Agent State and Sandbox Status:
|
||||
The frontend uses `useAgentState` hook (`frontend/src/hooks/use-agent-state.ts`) to determine the current conversation state. This hook:
|
||||
- Returns `curAgentState` (AgentState enum) for UI state determination
|
||||
- Returns `isArchived` flag when `sandbox_status === "MISSING"` (archived conversations)
|
||||
- Prioritizes live WebSocket execution status over cached API data
|
||||
|
||||
#### Archived Conversations (sandbox_status === "MISSING"):
|
||||
When a conversation's sandbox is no longer available (archived):
|
||||
- `useAgentState` returns `AgentState.STOPPED` and `isArchived: true`
|
||||
- Chat input is replaced with an archived banner (`ArchivedBanner` component)
|
||||
- VS Code tab, Terminal, and Planner show read-only messages instead of loading states
|
||||
- All interactive elements that require a running sandbox are disabled
|
||||
|
||||
#### Testing useAgentState:
|
||||
When mocking `useAgentState` in tests, always include the `isArchived` property:
|
||||
```typescript
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: () => ({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
isArchived: false,
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### Microagents
|
||||
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They are Markdown files that can include frontmatter for configuration.
|
||||
@@ -359,6 +389,7 @@ There are two main patterns for saving settings in the OpenHands frontend:
|
||||
**When to use each pattern:**
|
||||
- Use Pattern 1 (Immediate Save) for entity management where each item is independent
|
||||
- Use Pattern 2 (Manual Save) for configuration forms where settings are interdependent or need validation
|
||||
- Git provider tokens in the local/OSS integrations settings are managed through the V1 secrets endpoints (`POST`/`DELETE /api/v1/secrets/git-providers`). Do not reuse the logout flow for disconnecting tokens; `useLogout` is for actual app logout and still targets legacy OSS logout behavior.
|
||||
|
||||
### Adding New LLM Models
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ Full details in our [Development Guide](./Development.md).
|
||||
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
## What Can You Build?
|
||||
|
||||
@@ -16,7 +16,7 @@ open source community:
|
||||
|
||||
#### [Aider](https://github.com/paul-gauthier/aider)
|
||||
- License: Apache License 2.0
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
|
||||
|
||||
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
|
||||
- License: Apache License 2.0
|
||||
|
||||
@@ -309,16 +309,6 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
|
||||
---
|
||||
|
||||
## Using Existing Docker Images
|
||||
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Help
|
||||
|
||||
```bash
|
||||
@@ -339,4 +329,3 @@ make help
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -88,7 +88,6 @@ USER openhands
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
|
||||
@@ -23,18 +23,6 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
|
||||
unset WORKSPACE_BASE
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
|
||||
echo "Downloading and installing third_party_runtimes..."
|
||||
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
|
||||
|
||||
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
|
||||
echo "third_party_runtimes installed successfully."
|
||||
else
|
||||
echo "Failed to install third_party_runtimes." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
|
||||
echo "Running OpenHands as root"
|
||||
export RUN_AS_OPENHANDS=false
|
||||
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -37,12 +37,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
@@ -60,6 +60,7 @@ repos:
|
||||
lxml,
|
||||
"openhands-sdk==1.17.0",
|
||||
"openhands-tools==1.17.0",
|
||||
"sqlalchemy>=2.0",
|
||||
]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
|
||||
@@ -10,10 +10,7 @@ strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openhands.memory.condenser.impl.*]
|
||||
disable_error_code = override
|
||||
exclude = (enterprise/)
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
exclude = ["enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -50,6 +50,7 @@ repos:
|
||||
- ./
|
||||
- stripe==11.5.0
|
||||
- pygithub==2.6.1
|
||||
- sqlalchemy>=2.0
|
||||
# Use -p (package) to avoid dual module name conflict when using MYPYPATH
|
||||
# MYPYPATH=enterprise allows resolving bare imports like "from integrations.xxx"
|
||||
# Note: tests package excluded to avoid conflict with core openhands tests
|
||||
|
||||
@@ -61,13 +61,6 @@ export LITE_LLM_API_KEY=<your LLM API key>
|
||||
python enterprise_local/convert_to_env.py
|
||||
```
|
||||
|
||||
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
|
||||
|
||||
```
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
|
||||
```
|
||||
|
||||
By default the application will log in json, you can override.
|
||||
|
||||
```
|
||||
@@ -203,7 +196,6 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
@@ -237,7 +229,6 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
|
||||
@@ -112,9 +112,6 @@ lines.append(
|
||||
lines.append(
|
||||
'OPENHANDS_BITBUCKET_DATA_CENTER_SERVICE_CLS=integrations.bitbucket_data_center.bitbucket_dc_service.SaaSBitbucketDCService'
|
||||
)
|
||||
lines.append(
|
||||
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
|
||||
)
|
||||
lines.append('POSTHOG_CLIENT_KEY=test')
|
||||
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
|
||||
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
|
||||
|
||||
@@ -429,6 +429,11 @@ class GitHubDataCollector:
|
||||
- Num openhands review comments
|
||||
"""
|
||||
pr_number = openhands_pr.pr_number
|
||||
if openhands_pr.installation_id is None:
|
||||
logger.warning(
|
||||
f'Skipping PR {openhands_pr.repo_name}#{pr_number}: missing installation_id'
|
||||
)
|
||||
return
|
||||
installation_id = int(openhands_pr.installation_id)
|
||||
repo_id = openhands_pr.repo_id
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ from types import MappingProxyType
|
||||
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from integrations.github.data_collector import GitHubDataCollector
|
||||
from integrations.github.github_solvability import summarize_issue_solvability
|
||||
from integrations.github.github_view import (
|
||||
GithubFactory,
|
||||
GithubFailingAction,
|
||||
@@ -20,7 +19,6 @@ from integrations.models import (
|
||||
from integrations.types import ResolverViewInterface
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_SOLVABILITY_ANALYSIS,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
@@ -33,6 +31,7 @@ from server.auth.auth_error import ExpiredError
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import AuthenticationError
|
||||
@@ -41,7 +40,6 @@ from openhands.server.types import (
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class GithubManager(Manager[GithubViewType]):
|
||||
@@ -358,26 +356,7 @@ class GithubManager(Manager[GithubViewType]):
|
||||
)
|
||||
)
|
||||
|
||||
# We first initialize a conversation and generate the solvability report BEFORE starting the conversation runtime
|
||||
# This helps us accumulate llm spend without requiring a running runtime. This setups us up for
|
||||
# 1. If there is a problem starting the runtime we still have accumulated total conversation cost
|
||||
# 2. In the future, based on the report confidence we can conditionally start the conversation
|
||||
# 3. Once the conversation is started, its base cost will include the report's spend as well which allows us to control max budget per resolver task
|
||||
convo_metadata = await github_view.initialize_new_conversation()
|
||||
solvability_summary = None
|
||||
if not ENABLE_SOLVABILITY_ANALYSIS:
|
||||
logger.info(
|
||||
'[Github]: Solvability report feature is disabled, skipping'
|
||||
)
|
||||
else:
|
||||
try:
|
||||
solvability_summary = await summarize_issue_solvability(
|
||||
github_view, user_token
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Github]: Error summarizing issue solvability: {str(e)}'
|
||||
)
|
||||
conversation_id = await github_view.initialize_new_conversation()
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
github_view.user_info.keycloak_user_id, self.token_manager
|
||||
@@ -386,26 +365,21 @@ class GithubManager(Manager[GithubViewType]):
|
||||
await github_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
convo_metadata,
|
||||
conversation_id,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id = github_view.conversation_id
|
||||
conversation_id_hex = github_view.conversation_id
|
||||
|
||||
logger.info(
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
f'[GitHub] Created conversation {conversation_id_hex} for user {user_info.username}'
|
||||
)
|
||||
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
|
||||
# Send message with conversation link
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
base_msg = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
# Combine messages: include solvability report with "I'm on it!" if successful
|
||||
if solvability_summary:
|
||||
msg_info = f'{base_msg}\n\n{solvability_summary}'
|
||||
else:
|
||||
msg_info = base_msg
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id_hex)
|
||||
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
|
||||
except MissingSettingsError as e:
|
||||
logger.warning(
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from github import Auth, Github
|
||||
from integrations.github.github_view import (
|
||||
GithubInlinePRComment,
|
||||
GithubIssueComment,
|
||||
GithubPRComment,
|
||||
GithubViewType,
|
||||
)
|
||||
from integrations.solvability.data import load_classifier
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from integrations.solvability.models.summary import SolvabilitySummary
|
||||
from integrations.utils import ENABLE_SOLVABILITY_ANALYSIS
|
||||
from pydantic import ValidationError
|
||||
from server.config import get_config
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
|
||||
|
||||
def fetch_github_issue_context(
|
||||
github_view: GithubViewType,
|
||||
user_token: str,
|
||||
) -> str:
|
||||
"""Fetch full GitHub issue/PR context including title, body, and comments.
|
||||
|
||||
Args:
|
||||
full_repo_name: Full repository name in the format 'owner/repo'
|
||||
issue_number: The issue or PR number
|
||||
user_token: GitHub user access token
|
||||
max_comments: Maximum number of comments to fetch (default: 10)
|
||||
max_comment_length: Maximum length of each comment to include in the context (default: 500)
|
||||
|
||||
Returns:
|
||||
A comprehensive string containing the issue/PR context
|
||||
"""
|
||||
|
||||
# Build context string
|
||||
context_parts = []
|
||||
|
||||
# Add title and body
|
||||
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:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(github_view.issue_number)
|
||||
if issue.labels:
|
||||
labels = [label.name for label in issue.labels]
|
||||
context_parts.append(f"Labels: {', '.join(labels)}")
|
||||
|
||||
for comment in github_view.previous_comments:
|
||||
context_parts.append(f'- {comment.author}: {comment.body}')
|
||||
|
||||
return '\n\n'.join(context_parts)
|
||||
|
||||
|
||||
async def summarize_issue_solvability(
|
||||
github_view: GithubViewType,
|
||||
user_token: str,
|
||||
timeout: float = 60.0 * 5,
|
||||
) -> str:
|
||||
"""Generate a solvability summary for an issue using the resolver view interface.
|
||||
|
||||
Args:
|
||||
resolver_view: A resolver view interface instance (e.g., GithubIssue, GithubPRComment)
|
||||
user_token: GitHub user access token for API access
|
||||
timeout: Maximum time in seconds to wait for the result (default: 60.0)
|
||||
|
||||
Returns:
|
||||
The solvability summary as a string
|
||||
|
||||
Raises:
|
||||
ValueError: If LLM settings cannot be found for the user
|
||||
asyncio.TimeoutError: If the operation exceeds the specified timeout
|
||||
"""
|
||||
if not ENABLE_SOLVABILITY_ANALYSIS:
|
||||
raise ValueError('Solvability report feature is disabled')
|
||||
|
||||
if github_view.user_info.keycloak_user_id is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No user ID found for user {github_view.user_info.username}'
|
||||
)
|
||||
|
||||
# Grab the user's information so we can load their LLM configuration
|
||||
store = SaasSettingsStore(
|
||||
user_id=github_view.user_info.keycloak_user_id,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
user_settings = await store.load()
|
||||
|
||||
if user_settings is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No user settings found for user ID {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
# Check if solvability analysis is enabled for this user, exit early if
|
||||
# needed
|
||||
if not getattr(user_settings, 'enable_solvability_analysis', False):
|
||||
raise ValueError(
|
||||
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
agent_settings = user_settings.agent_settings
|
||||
llm_settings = agent_settings.llm
|
||||
if llm_settings.api_key is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
try:
|
||||
llm_config = LLMConfig(
|
||||
model=llm_settings.model,
|
||||
api_key=llm_settings.api_key.get_secret_value(),
|
||||
base_url=llm_settings.base_url,
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
f'[Solvability] Invalid LLM configuration for user {github_view.user_info.user_id}: {str(e)}'
|
||||
)
|
||||
|
||||
# Fetch the full GitHub issue/PR context using the GitHub API
|
||||
start_time = time.time()
|
||||
issue_context = fetch_github_issue_context(github_view, user_token)
|
||||
logger.info(
|
||||
f'[Solvability] Grabbed issue context for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'response_latency': time.time() - start_time,
|
||||
'full_repo_name': github_view.full_repo_name,
|
||||
'issue_number': github_view.issue_number,
|
||||
},
|
||||
)
|
||||
|
||||
# For comment-based triggers, also include the specific comment that triggered the action
|
||||
if isinstance(
|
||||
github_view, (GithubIssueComment, GithubPRComment, GithubInlinePRComment)
|
||||
):
|
||||
issue_context += f'\n\nTriggering Comment:\n{github_view.comment_body}'
|
||||
|
||||
solvability_classifier = load_classifier('default-classifier')
|
||||
|
||||
async with asyncio.timeout(timeout):
|
||||
solvability_report: SolvabilityReport = await call_sync_from_async(
|
||||
lambda: solvability_classifier.solvability_report(
|
||||
issue_context, llm_config=llm_config
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Solvability] Generated report for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'report': solvability_report.model_dump(exclude=['issue']),
|
||||
},
|
||||
)
|
||||
|
||||
llm_registry, conversation_stats, _ = create_registry_and_conversation_stats(
|
||||
get_config(),
|
||||
github_view.conversation_id,
|
||||
github_view.user_info.keycloak_user_id,
|
||||
None,
|
||||
)
|
||||
|
||||
solvability_summary = await call_sync_from_async(
|
||||
lambda: SolvabilitySummary.from_report(
|
||||
solvability_report,
|
||||
llm=llm_registry.get_llm(
|
||||
service_id='solvability_analysis', config=llm_config
|
||||
),
|
||||
)
|
||||
)
|
||||
conversation_stats.save_metrics()
|
||||
|
||||
logger.info(
|
||||
f'[Solvability] Generated summary for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'summary': solvability_summary.model_dump(exclude=['content']),
|
||||
},
|
||||
)
|
||||
|
||||
return solvability_summary.format_as_markdown()
|
||||
@@ -14,11 +14,9 @@ from integrations.resolver_org_router import resolve_org_for_repo
|
||||
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
|
||||
@@ -27,13 +25,13 @@ from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.org_store import OrgStore
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
@@ -44,20 +42,11 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's proactive conversation setting.
|
||||
|
||||
@@ -105,7 +94,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)
|
||||
@@ -152,11 +140,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='github',
|
||||
@@ -164,54 +148,20 @@ class GithubIssue(ResolverViewInterface):
|
||||
keycloak_user_id=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,
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
# All conversations use V1 app conversation service
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
return conversation_id
|
||||
|
||||
async def create_new_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
conversation_id: UUID,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
await self._create_v1_conversation(jinja_env, saas_user_auth, conversation_id)
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
"""Build the initial user message for V1 resolver conversations.
|
||||
@@ -239,7 +189,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
conversation_id: UUID,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
@@ -259,7 +209,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
conversation_id=conversation_id,
|
||||
# NOTE: Resolver instructions are intended to be lower priority than the
|
||||
# system prompt, so we inject them into the initial user message.
|
||||
system_message_suffix=None,
|
||||
@@ -813,7 +763,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@@ -839,7 +788,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@@ -881,7 +829,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@@ -915,7 +862,6 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -25,6 +25,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
@@ -33,7 +34,6 @@ from openhands.server.types import (
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class GitlabManager(Manager[GitlabViewType]):
|
||||
@@ -208,8 +208,8 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize conversation and get metadata (following GitHub pattern)
|
||||
convo_metadata = await gitlab_view.initialize_new_conversation()
|
||||
# Initialize conversation and get UUID
|
||||
conversation_id = await gitlab_view.initialize_new_conversation()
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
gitlab_view.user_info.keycloak_user_id, self.token_manager
|
||||
@@ -218,19 +218,19 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
await gitlab_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
convo_metadata,
|
||||
conversation_id,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id = gitlab_view.conversation_id
|
||||
conversation_id_hex = gitlab_view.conversation_id
|
||||
|
||||
logger.info(
|
||||
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
|
||||
f'[GitLab] Created conversation {conversation_id_hex} for user {user_info.username}'
|
||||
)
|
||||
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id_hex)
|
||||
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
|
||||
except MissingSettingsError as e:
|
||||
|
||||
@@ -6,22 +6,20 @@ from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_V1_GITLAB_RESOLVER,
|
||||
HOST,
|
||||
get_oh_labels,
|
||||
get_user_v1_enabled_setting,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
@@ -32,21 +30,12 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
CONFIDENTIAL_NOTE = 'confidential_note'
|
||||
NOTE_TYPES = ['note', CONFIDENTIAL_NOTE]
|
||||
|
||||
|
||||
async def is_v1_enabled_for_gitlab_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITLAB_RESOLVER
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Factory to create appriorate Gitlab view
|
||||
# =================================================
|
||||
@@ -68,7 +57,6 @@ class GitlabIssue(ResolverViewInterface):
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
is_mr: bool
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return getattr(self, 'branch_name', None)
|
||||
@@ -114,10 +102,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# v1_enabled is already set at construction time in the factory method
|
||||
# This is the source of truth for the conversation type
|
||||
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='gitlab',
|
||||
@@ -125,57 +110,26 @@ class GitlabIssue(ResolverViewInterface):
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversation 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,
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
# All conversations use V1 app conversation service
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
return conversation_id
|
||||
|
||||
async def create_new_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
conversation_id: UUID,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
await self._create_v1_conversation(jinja_env, saas_user_auth, conversation_id)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
conversation_id: UUID,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitLab V1]: Creating V1 conversation')
|
||||
@@ -201,7 +155,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
conversation_id=conversation_id,
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
@@ -450,16 +404,6 @@ class GitlabFactory:
|
||||
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
|
||||
)
|
||||
|
||||
# Check v1_enabled at construction time - this is the source of truth
|
||||
v1_enabled = (
|
||||
await is_v1_enabled_for_gitlab_resolver(keycloak_user_id)
|
||||
if keycloak_user_id
|
||||
else False
|
||||
)
|
||||
logger.info(
|
||||
f'[GitLab V1]: User flag found for {keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
|
||||
if GitlabFactory.is_labeled_issue(message):
|
||||
issue_iid = payload['object_attributes']['iid']
|
||||
|
||||
@@ -481,7 +425,6 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_issue_comment(message):
|
||||
@@ -512,7 +455,6 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message):
|
||||
@@ -545,7 +487,6 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message, inline=True):
|
||||
@@ -586,7 +527,6 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
raise ValueError(f'Unhandled GitLab webhook event: {message}')
|
||||
|
||||
@@ -35,6 +35,7 @@ from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
@@ -43,10 +44,6 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
@@ -192,32 +189,30 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
)
|
||||
await integration_store.create_conversation(jira_conversation)
|
||||
|
||||
conversation_metadata = await self._create_v1_metadata()
|
||||
await self._create_v1_conversation(jinja_env, conversation_metadata)
|
||||
conversation_id = await self._initialize_conversation()
|
||||
await self._create_v1_conversation(jinja_env, conversation_id)
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v1_metadata(self) -> ConversationMetadata:
|
||||
"""Create conversation metadata for V1 conversations.
|
||||
async def _initialize_conversation(self) -> UUID:
|
||||
"""Initialize conversation and return the conversation ID.
|
||||
|
||||
The JiraConversation mapping is saved to the integration store (above), but
|
||||
V1 conversation metadata is managed by the app conversation system, not
|
||||
the legacy conversation store.
|
||||
"""
|
||||
logger.info('[Jira]: Creating V1 metadata')
|
||||
logger.info('[Jira]: Initializing V1 conversation')
|
||||
|
||||
# Generate a dummy conversation for V1 (not saved to store)
|
||||
self.conversation_id = uuid4().hex
|
||||
# Generate a conversation ID for V1
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
self.resolved_org_id = await self._get_resolved_org_id()
|
||||
|
||||
return ConversationMetadata(
|
||||
conversation_id=self.conversation_id,
|
||||
selected_repository=self.selected_repo,
|
||||
)
|
||||
return conversation_id
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
conversation_id: UUID,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[Jira]: Creating V1 conversation')
|
||||
@@ -236,7 +231,7 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
|
||||
# Create the V1 conversation start request
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
conversation_id=conversation_id,
|
||||
system_message_suffix=None,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.selected_repo,
|
||||
|
||||
@@ -27,6 +27,7 @@ from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
@@ -35,9 +36,6 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
|
||||
integration_store = JiraDcIntegrationStore.get_instance()
|
||||
|
||||
|
||||
@@ -112,7 +112,6 @@ class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
should_extract: bool
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
@abstractmethod
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
|
||||
@@ -13,8 +13,6 @@ from integrations.slack.slack_types import (
|
||||
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_V1_SLACK_RESOLVER,
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from slack_sdk import WebClient
|
||||
@@ -26,6 +24,7 @@ from storage.slack_user import SlackUser
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
SendMessageRequest,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
@@ -36,9 +35,6 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
|
||||
# =================================================
|
||||
@@ -51,10 +47,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 SlackNewConversationView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
@@ -70,7 +62,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)
|
||||
@@ -149,7 +140,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
|
||||
|
||||
@@ -161,7 +152,6 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'keycloak_user_id': user_info.keycloak_user_id,
|
||||
'org_id': user_info.org_id,
|
||||
'parent_id': self.thread_ts or self.message_ts,
|
||||
'v1_enabled': v1_enabled,
|
||||
},
|
||||
)
|
||||
slack_conversation = SlackConversation(
|
||||
@@ -171,7 +161,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
org_id=user_info.org_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,
|
||||
v1_enabled=True, # All conversations are V1
|
||||
)
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
@@ -268,7 +258,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=True)
|
||||
await self.save_slack_convo()
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
@@ -516,7 +506,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):
|
||||
@@ -534,7 +523,6 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -552,7 +540,6 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""
|
||||
Utilities for loading and managing pre-trained classifiers.
|
||||
|
||||
Assumes that classifiers are stored adjacent to this file in the `solvability/data` directory, using a simple
|
||||
`name + .json` pattern.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from integrations.solvability.models.classifier import SolvabilityClassifier
|
||||
|
||||
|
||||
def load_classifier(name: str) -> SolvabilityClassifier:
|
||||
"""
|
||||
Load a classifier by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the classifier to load.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: The loaded classifier instance.
|
||||
"""
|
||||
data_dir = Path(__file__).parent
|
||||
classifier_path = data_dir / f'{name}.json'
|
||||
|
||||
if not classifier_path.exists():
|
||||
raise FileNotFoundError(f"Classifier '{name}' not found at {classifier_path}")
|
||||
|
||||
with classifier_path.open('r') as f:
|
||||
return SolvabilityClassifier.model_validate_json(f.read())
|
||||
|
||||
|
||||
def available_classifiers() -> list[str]:
|
||||
"""
|
||||
List all available classifiers in the data directory.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of classifier names (without the .json extension).
|
||||
"""
|
||||
data_dir = Path(__file__).parent
|
||||
return [f.stem for f in data_dir.glob('*.json') if f.is_file()]
|
||||
File diff suppressed because one or more lines are too long
@@ -1,38 +0,0 @@
|
||||
"""
|
||||
Solvability Models Package
|
||||
|
||||
This package contains the core machine learning models and components for predicting
|
||||
the solvability of GitHub issues and similar technical problems.
|
||||
|
||||
The solvability prediction system works by:
|
||||
1. Using a Featurizer to extract semantic features from issue descriptions via LLM calls
|
||||
2. Training a RandomForestClassifier on these features to predict solvability
|
||||
3. Generating detailed reports with feature importance analysis
|
||||
|
||||
Key Components:
|
||||
- Feature: Defines individual features that can be extracted from issues
|
||||
- Featurizer: Orchestrates LLM-based feature extraction with sampling and batching
|
||||
- SolvabilityClassifier: Main ML pipeline combining featurization and classification
|
||||
- SolvabilityReport: Comprehensive output with predictions, feature analysis, and metadata
|
||||
- ImportanceStrategy: Configurable methods for calculating feature importance (SHAP, permutation, impurity)
|
||||
"""
|
||||
|
||||
from integrations.solvability.models.classifier import SolvabilityClassifier
|
||||
from integrations.solvability.models.featurizer import (
|
||||
EmbeddingDimension,
|
||||
Feature,
|
||||
FeatureEmbedding,
|
||||
Featurizer,
|
||||
)
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
|
||||
__all__ = [
|
||||
'Feature',
|
||||
'EmbeddingDimension',
|
||||
'FeatureEmbedding',
|
||||
'Featurizer',
|
||||
'ImportanceStrategy',
|
||||
'SolvabilityClassifier',
|
||||
'SolvabilityReport',
|
||||
]
|
||||
@@ -1,433 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import pickle
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import shap
|
||||
from integrations.solvability.models.featurizer import Feature, Featurizer
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
PrivateAttr,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
from sklearn.exceptions import NotFittedError
|
||||
from sklearn.inspection import permutation_importance
|
||||
from sklearn.utils.validation import check_is_fitted
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
|
||||
class SolvabilityClassifier(BaseModel):
|
||||
"""
|
||||
Machine learning pipeline for predicting the solvability of GitHub issues and similar problems.
|
||||
|
||||
This classifier combines LLM-based feature extraction with traditional ML classification:
|
||||
1. Uses a Featurizer to extract semantic boolean features from issue descriptions via LLM calls
|
||||
2. Trains a RandomForestClassifier on these features to predict solvability scores
|
||||
3. Provides feature importance analysis using configurable strategies (SHAP, permutation, impurity)
|
||||
4. Generates comprehensive reports with predictions, feature analysis, and cost metrics
|
||||
|
||||
The classifier supports both training on labeled data and inference on new issues, with built-in
|
||||
support for batch processing and concurrent feature extraction.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""
|
||||
The identifier for the classifier.
|
||||
"""
|
||||
|
||||
featurizer: Featurizer
|
||||
"""
|
||||
The featurizer to use for transforming the input data.
|
||||
"""
|
||||
|
||||
classifier: RandomForestClassifier
|
||||
"""
|
||||
The RandomForestClassifier used for predicting solvability from extracted features.
|
||||
|
||||
This ensemble model provides robust predictions and built-in feature importance metrics.
|
||||
"""
|
||||
|
||||
importance_strategy: ImportanceStrategy = ImportanceStrategy.IMPURITY
|
||||
"""
|
||||
Strategy to use for calculating feature importance.
|
||||
"""
|
||||
|
||||
samples: int = 10
|
||||
"""
|
||||
Number of samples to use for calculating feature embedding coefficients.
|
||||
"""
|
||||
|
||||
random_state: int | None = None
|
||||
"""
|
||||
Random state for reproducibility.
|
||||
"""
|
||||
|
||||
_classifier_attrs: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||
"""
|
||||
Private dictionary storing cached results from feature extraction and importance calculations.
|
||||
|
||||
Contains keys like 'features_', 'cost_', 'feature_importances_', and 'labels_' that are populated
|
||||
during transform(), fit(), and predict() operations. Access these via the corresponding properties.
|
||||
|
||||
This field is never serialized, so cached values will not persist across model save/load cycles.
|
||||
"""
|
||||
|
||||
model_config = {
|
||||
'arbitrary_types_allowed': True,
|
||||
}
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_random_state(self) -> SolvabilityClassifier:
|
||||
"""
|
||||
Validate the random state configuration between this object and the classifier.
|
||||
"""
|
||||
# If both random states are set, they definitely need to agree.
|
||||
if self.random_state is not None and self.classifier.random_state is not None:
|
||||
if self.random_state != self.classifier.random_state:
|
||||
raise ValueError(
|
||||
'The random state of the classifier and the top-level classifier must agree.'
|
||||
)
|
||||
|
||||
# Otherwise, we'll always set the classifier's random state to the top-level one.
|
||||
self.classifier.random_state = self.random_state
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def features_(self) -> pd.DataFrame:
|
||||
"""
|
||||
Get the features used by the classifier for the most recent inputs.
|
||||
"""
|
||||
if 'features_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'SolvabilityClassifier.transform() has not yet been called.'
|
||||
)
|
||||
return self._classifier_attrs['features_']
|
||||
|
||||
@property
|
||||
def cost_(self) -> pd.DataFrame:
|
||||
"""
|
||||
Get the cost of the classifier for the most recent inputs.
|
||||
"""
|
||||
if 'cost_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'SolvabilityClassifier.transform() has not yet been called.'
|
||||
)
|
||||
return self._classifier_attrs['cost_']
|
||||
|
||||
@property
|
||||
def feature_importances_(self) -> np.ndarray:
|
||||
"""
|
||||
Get the feature importances for the most recent inputs.
|
||||
"""
|
||||
if 'feature_importances_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'No SolvabilityClassifier methods that produce feature importances (.fit(), .predict_proba(), and '
|
||||
'.predict()) have been called.'
|
||||
)
|
||||
return self._classifier_attrs['feature_importances_'] # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def is_fitted(self) -> bool:
|
||||
"""
|
||||
Check if the classifier is fitted.
|
||||
"""
|
||||
try:
|
||||
check_is_fitted(self.classifier)
|
||||
return True
|
||||
except NotFittedError:
|
||||
return False
|
||||
|
||||
def transform(self, issues: pd.Series, llm_config: LLMConfig) -> pd.DataFrame:
|
||||
"""
|
||||
Transform the input issues using the featurizer to extract features.
|
||||
|
||||
This method orchestrates the feature extraction pipeline:
|
||||
1. Uses the featurizer to generate embeddings for all issues
|
||||
2. Converts embeddings to a structured DataFrame
|
||||
3. Separates feature columns from metadata columns
|
||||
4. Stores results for later access via properties
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: A DataFrame containing only the feature columns (no metadata).
|
||||
"""
|
||||
# Generate feature embeddings for all issues using batch processing
|
||||
feature_embeddings = self.featurizer.embed_batch(
|
||||
issues, samples=self.samples, llm_config=llm_config
|
||||
)
|
||||
df = pd.DataFrame(embedding.to_row() for embedding in feature_embeddings)
|
||||
|
||||
# Split into feature columns (used by classifier) and cost columns (metadata)
|
||||
feature_columns = [feature.identifier for feature in self.featurizer.features]
|
||||
cost_columns = [col for col in df.columns if col not in feature_columns]
|
||||
|
||||
# Store both sets for access via properties
|
||||
self._classifier_attrs['features_'] = df[feature_columns]
|
||||
self._classifier_attrs['cost_'] = df[cost_columns]
|
||||
|
||||
return self.features_
|
||||
|
||||
def fit(
|
||||
self, issues: pd.Series, labels: pd.Series, llm_config: LLMConfig
|
||||
) -> SolvabilityClassifier:
|
||||
"""
|
||||
Fit the classifier to the input issues and labels.
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
|
||||
labels: A pandas Series containing the labels (0 or 1) for each issue.
|
||||
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: The fitted classifier.
|
||||
"""
|
||||
features = self.transform(issues, llm_config=llm_config)
|
||||
self.classifier.fit(features, labels)
|
||||
|
||||
# Store labels for permutation importance calculation
|
||||
self._classifier_attrs['labels_'] = labels
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, self.classifier.predict_proba(features), labels
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def predict_proba(self, issues: pd.Series, llm_config: LLMConfig) -> np.ndarray:
|
||||
"""
|
||||
Predict the solvability probabilities for the input issues.
|
||||
|
||||
Returns class probabilities where the second column represents the probability
|
||||
of the issue being solvable (positive class).
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Array of shape (n_samples, 2) with probabilities for each class.
|
||||
Column 0: probability of not solvable, Column 1: probability of solvable.
|
||||
"""
|
||||
features = self.transform(issues, llm_config=llm_config)
|
||||
scores = self.classifier.predict_proba(features)
|
||||
|
||||
# Calculate feature importances based on the configured strategy
|
||||
# For permutation importance, we need ground truth labels if available
|
||||
labels = self._classifier_attrs.get('labels_')
|
||||
if (
|
||||
self.importance_strategy == ImportanceStrategy.PERMUTATION
|
||||
and labels is not None
|
||||
):
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, scores, labels
|
||||
)
|
||||
else:
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, scores
|
||||
)
|
||||
|
||||
return scores # type: ignore[no-any-return]
|
||||
|
||||
def predict(self, issues: pd.Series, llm_config: LLMConfig) -> np.ndarray:
|
||||
"""
|
||||
Predict the solvability of the input issues by returning binary labels.
|
||||
|
||||
Uses a 0.5 probability threshold to convert probabilities to binary predictions.
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Boolean array where True indicates the issue is predicted as solvable.
|
||||
"""
|
||||
probabilities = self.predict_proba(issues, llm_config=llm_config)
|
||||
# Apply 0.5 threshold to convert probabilities to binary predictions
|
||||
labels = probabilities[:, 1] >= 0.5
|
||||
return labels
|
||||
|
||||
def _importance(
|
||||
self,
|
||||
features: pd.DataFrame,
|
||||
scores: np.ndarray,
|
||||
labels: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Calculate feature importance scores using the configured strategy.
|
||||
|
||||
Different strategies provide different interpretations:
|
||||
- SHAP: Shapley values indicating contribution to individual predictions
|
||||
- PERMUTATION: Decrease in model performance when feature is shuffled
|
||||
- IMPURITY: Gini impurity decrease from splits on each feature
|
||||
|
||||
Args:
|
||||
features: Feature matrix used for predictions.
|
||||
scores: Model prediction scores (unused for some strategies).
|
||||
labels: Ground truth labels (required for permutation importance).
|
||||
|
||||
Returns:
|
||||
np.ndarray: Feature importance scores, one per feature.
|
||||
"""
|
||||
match self.importance_strategy:
|
||||
case ImportanceStrategy.SHAP:
|
||||
# Use SHAP TreeExplainer for tree-based models
|
||||
explainer = shap.TreeExplainer(self.classifier)
|
||||
shap_values = explainer.shap_values(features)
|
||||
# Return mean SHAP values for the positive class (solvable)
|
||||
return shap_values.mean(axis=0)[:, 1] # type: ignore[no-any-return]
|
||||
|
||||
case ImportanceStrategy.PERMUTATION:
|
||||
# Permutation importance requires ground truth labels
|
||||
if labels is None:
|
||||
raise ValueError('Labels are required for permutation importance')
|
||||
result = permutation_importance(
|
||||
self.classifier,
|
||||
features,
|
||||
labels,
|
||||
n_repeats=10, # Number of permutation rounds for stability
|
||||
random_state=self.random_state,
|
||||
)
|
||||
return result.importances_mean # type: ignore[no-any-return]
|
||||
|
||||
case ImportanceStrategy.IMPURITY:
|
||||
# Use built-in feature importances from RandomForest
|
||||
return self.classifier.feature_importances_ # type: ignore[no-any-return]
|
||||
|
||||
case _:
|
||||
raise ValueError(
|
||||
f'Unknown importance strategy: {self.importance_strategy}'
|
||||
)
|
||||
|
||||
def add_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""
|
||||
Add new features to the classifier's featurizer.
|
||||
|
||||
Note: Adding features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
|
||||
Args:
|
||||
features: List of Feature objects to add.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: Self for method chaining.
|
||||
"""
|
||||
for feature in features:
|
||||
if feature not in self.featurizer.features:
|
||||
self.featurizer.features.append(feature)
|
||||
return self
|
||||
|
||||
def forget_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""
|
||||
Remove features from the classifier's featurizer.
|
||||
|
||||
Note: Removing features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
|
||||
Args:
|
||||
features: List of Feature objects to remove.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: Self for method chaining.
|
||||
"""
|
||||
for feature in features:
|
||||
try:
|
||||
self.featurizer.features.remove(feature)
|
||||
except ValueError:
|
||||
# Feature not in list, continue with others
|
||||
continue
|
||||
return self
|
||||
|
||||
@field_serializer('classifier')
|
||||
@staticmethod
|
||||
def _rfc_to_json(rfc: RandomForestClassifier) -> str:
|
||||
"""
|
||||
Convert a RandomForestClassifier to a JSON-compatible value (a string).
|
||||
"""
|
||||
return base64.b64encode(pickle.dumps(rfc)).decode('utf-8')
|
||||
|
||||
@field_validator('classifier', mode='before')
|
||||
@staticmethod
|
||||
def _json_to_rfc(value: str | RandomForestClassifier) -> RandomForestClassifier:
|
||||
"""
|
||||
Convert a JSON-compatible value (a string) back to a RandomForestClassifier.
|
||||
"""
|
||||
if isinstance(value, RandomForestClassifier):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
model = pickle.loads(base64.b64decode(value))
|
||||
if isinstance(model, RandomForestClassifier):
|
||||
return model
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to decode the classifier: {e}')
|
||||
|
||||
raise ValueError(
|
||||
'The classifier must be a RandomForestClassifier or a JSON-compatible dictionary.'
|
||||
)
|
||||
|
||||
def solvability_report(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
|
||||
Args:
|
||||
issue: The issue description for which to generate the report.
|
||||
llm_config: Optional LLM configuration to use for feature extraction.
|
||||
kwargs: Additional metadata to include in the report.
|
||||
|
||||
Returns:
|
||||
SolvabilityReport: The generated solvability report.
|
||||
"""
|
||||
if not self.is_fitted:
|
||||
raise ValueError(
|
||||
'The classifier must be fitted before generating a report.'
|
||||
)
|
||||
|
||||
scores = self.predict_proba(pd.Series([issue]), llm_config=llm_config)
|
||||
|
||||
return SolvabilityReport(
|
||||
identifier=self.identifier,
|
||||
issue=issue,
|
||||
score=scores[0, 1],
|
||||
features=self.features_.iloc[0].to_dict(),
|
||||
samples=self.samples,
|
||||
importance_strategy=self.importance_strategy,
|
||||
# Unlike the features, the importances are just a series with no link
|
||||
# to the actual feature names. For that we have to recombine with the
|
||||
# feature identifiers.
|
||||
feature_importances=dict(
|
||||
zip(
|
||||
self.featurizer.feature_identifiers(),
|
||||
self.feature_importances_.tolist(),
|
||||
)
|
||||
),
|
||||
random_state=self.random_state,
|
||||
metadata=dict(kwargs) if kwargs else None,
|
||||
# Both cost and response_latency are columns in the cost_ DataFrame,
|
||||
# so we can get both by just unpacking the first row.
|
||||
**self.cost_.iloc[0].to_dict(),
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
"""
|
||||
return self.solvability_report(issue, llm_config=llm_config, **kwargs)
|
||||
@@ -1,38 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DifficultyLevel(Enum):
|
||||
"""Enum representing the difficulty level based on solvability score."""
|
||||
|
||||
EASY = ('EASY', 0.7, '🟢')
|
||||
MEDIUM = ('MEDIUM', 0.4, '🟡')
|
||||
HARD = ('HARD', 0.0, '🔴')
|
||||
|
||||
def __init__(self, label: str, threshold: float, emoji: str):
|
||||
self.label = label
|
||||
self.threshold = threshold
|
||||
self.emoji = emoji
|
||||
|
||||
@classmethod
|
||||
def from_score(cls, score: float) -> DifficultyLevel:
|
||||
"""Get difficulty level from a solvability score.
|
||||
|
||||
Returns the difficulty level with the highest threshold that is less than or equal to the given score.
|
||||
"""
|
||||
# Sort enum values by threshold in descending order
|
||||
sorted_levels = sorted(cls, key=lambda x: x.threshold, reverse=True)
|
||||
|
||||
# Find the first level where score meets the threshold
|
||||
for level in sorted_levels:
|
||||
if score >= level.threshold:
|
||||
return level
|
||||
|
||||
# This should never happen if thresholds are set correctly,
|
||||
# but return the lowest threshold level as fallback
|
||||
return sorted_levels[-1]
|
||||
|
||||
def format_display(self) -> str:
|
||||
"""Format the difficulty level for display."""
|
||||
return f'{self.emoji} **Solvability: {self.label}**'
|
||||
@@ -1,368 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
class Feature(BaseModel):
|
||||
"""
|
||||
Represents a single boolean feature that can be extracted from issue descriptions.
|
||||
|
||||
Features are semantic properties of issues (e.g., "has_code_example", "requires_debugging")
|
||||
that are evaluated by LLMs and used as input to the solvability classifier.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""Unique identifier for the feature, used as column name in feature matrices."""
|
||||
|
||||
description: str
|
||||
"""Human-readable description of what the feature represents, used in LLM prompts."""
|
||||
|
||||
@property
|
||||
def to_tool_description_field(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert this feature to a JSON schema field for LLM tool calling.
|
||||
|
||||
Returns:
|
||||
dict: JSON schema field definition for this feature.
|
||||
"""
|
||||
return {
|
||||
'type': 'boolean',
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class EmbeddingDimension(BaseModel):
|
||||
"""
|
||||
Represents a single dimension (feature evaluation) within a feature embedding sample.
|
||||
|
||||
Each dimension corresponds to one feature being evaluated as true/false for a given issue.
|
||||
"""
|
||||
|
||||
feature_id: str
|
||||
"""Identifier of the feature being evaluated."""
|
||||
|
||||
result: bool
|
||||
"""Boolean result of the feature evaluation for this sample."""
|
||||
|
||||
|
||||
# Type alias for a single embedding sample - maps feature identifiers to boolean values
|
||||
EmbeddingSample = dict[str, bool]
|
||||
"""
|
||||
A single sample from the LLM evaluation of features for an issue.
|
||||
Maps feature identifiers to their boolean evaluations.
|
||||
"""
|
||||
|
||||
|
||||
class FeatureEmbedding(BaseModel):
|
||||
"""
|
||||
Represents the complete feature embedding for a single issue, including multiple samples
|
||||
and associated metadata about the LLM calls used to generate it.
|
||||
|
||||
Multiple samples are collected to account for LLM variability and provide more robust
|
||||
feature estimates through averaging.
|
||||
"""
|
||||
|
||||
samples: list[EmbeddingSample]
|
||||
"""List of individual feature evaluation samples from the LLM."""
|
||||
|
||||
prompt_tokens: int | None = None
|
||||
"""Total prompt tokens consumed across all LLM calls for this embedding."""
|
||||
|
||||
completion_tokens: int | None = None
|
||||
"""Total completion tokens generated across all LLM calls for this embedding."""
|
||||
|
||||
response_latency: float | None = None
|
||||
"""Total response latency (seconds) across all LLM calls for this embedding."""
|
||||
|
||||
@property
|
||||
def dimensions(self) -> list[str]:
|
||||
"""
|
||||
Get all unique feature identifiers present across all samples.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers that appear in at least one sample.
|
||||
"""
|
||||
dims: set[str] = set()
|
||||
for sample in self.samples:
|
||||
dims.update(sample.keys())
|
||||
return list(dims)
|
||||
|
||||
def coefficient(self, dimension: str) -> float | None:
|
||||
"""
|
||||
Calculate the average coefficient (0-1) for a specific feature dimension.
|
||||
|
||||
This computes the proportion of samples where the feature was evaluated as True,
|
||||
providing a continuous feature value for the classifier.
|
||||
|
||||
Args:
|
||||
dimension: Feature identifier to calculate coefficient for.
|
||||
|
||||
Returns:
|
||||
float | None: Average coefficient (0.0-1.0), or None if dimension not found.
|
||||
"""
|
||||
# Extract boolean values for this dimension, converting to 0/1
|
||||
values = [
|
||||
1 if v else 0
|
||||
for v in [sample.get(dimension) for sample in self.samples]
|
||||
if v is not None
|
||||
]
|
||||
if values:
|
||||
return sum(values) / len(values)
|
||||
return None
|
||||
|
||||
def to_row(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert the embedding to a flat dictionary suitable for DataFrame construction.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Dictionary with metadata fields and feature coefficients.
|
||||
"""
|
||||
return {
|
||||
'response_latency': self.response_latency,
|
||||
'prompt_tokens': self.prompt_tokens,
|
||||
'completion_tokens': self.completion_tokens,
|
||||
**{dimension: self.coefficient(dimension) for dimension in self.dimensions},
|
||||
}
|
||||
|
||||
def sample_entropy(self) -> dict[str, float]:
|
||||
"""
|
||||
Calculate the Shannon entropy of feature evaluations across samples.
|
||||
|
||||
Higher entropy indicates more variability in LLM responses for a feature,
|
||||
which may suggest ambiguity in the feature definition or issue description.
|
||||
|
||||
Returns:
|
||||
dict[str, float]: Mapping of feature identifiers to their entropy values (0-1).
|
||||
"""
|
||||
from collections import Counter
|
||||
from math import log2
|
||||
|
||||
entropy = {}
|
||||
for dimension in self.dimensions:
|
||||
# Count True/False occurrences for this feature across samples
|
||||
counts = Counter(sample.get(dimension, False) for sample in self.samples)
|
||||
total = sum(counts.values())
|
||||
if total == 0:
|
||||
entropy[dimension] = 0.0
|
||||
continue
|
||||
# Calculate Shannon entropy: -Σ(p * log2(p))
|
||||
entropy_value = -sum(
|
||||
(count / total) * log2(count / total)
|
||||
for count in counts.values()
|
||||
if count > 0
|
||||
)
|
||||
entropy[dimension] = entropy_value
|
||||
return entropy
|
||||
|
||||
|
||||
class Featurizer(BaseModel):
|
||||
"""
|
||||
Orchestrates LLM-based feature extraction from issue descriptions.
|
||||
|
||||
The Featurizer uses structured LLM tool calling to evaluate boolean features
|
||||
for issue descriptions. It handles prompt construction, tool schema generation,
|
||||
and batch processing with concurrency.
|
||||
"""
|
||||
|
||||
system_prompt: str
|
||||
"""System prompt that provides context and instructions to the LLM."""
|
||||
|
||||
message_prefix: str
|
||||
"""Prefix added to user messages before the issue description."""
|
||||
|
||||
features: list[Feature]
|
||||
"""List of features to extract from each issue description."""
|
||||
|
||||
def system_message(self) -> dict[str, Any]:
|
||||
"""
|
||||
Construct the system message for LLM conversations.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: System message dictionary for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'role': 'system',
|
||||
'content': self.system_prompt,
|
||||
}
|
||||
|
||||
def user_message(
|
||||
self, issue_description: str, set_cache: bool = True
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Construct the user message containing the issue description.
|
||||
|
||||
Args:
|
||||
issue_description: The description of the issue to analyze.
|
||||
set_cache: Whether to enable ephemeral caching for this message.
|
||||
Should be False for single samples to avoid cache overhead.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: User message dictionary for LLM API calls.
|
||||
"""
|
||||
message: dict[str, Any] = {
|
||||
'role': 'user',
|
||||
'content': f'{self.message_prefix}{issue_description}',
|
||||
}
|
||||
if set_cache:
|
||||
message['cache_control'] = {'type': 'ephemeral'}
|
||||
return message
|
||||
|
||||
@property
|
||||
def tool_choice(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get the tool choice configuration for forcing LLM to use the featurizer tool.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Tool choice configuration for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {'name': 'call_featurizer'},
|
||||
}
|
||||
|
||||
@property
|
||||
def tool_description(self) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the tool schema for the featurizer function.
|
||||
|
||||
Creates a JSON schema that describes the featurizer tool with all configured
|
||||
features as boolean parameters.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Complete tool description for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'call_featurizer',
|
||||
'description': 'Record the features present in the issue.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
feature.identifier: feature.to_tool_description_field
|
||||
for feature in self.features
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def embed(
|
||||
self,
|
||||
issue_description: str,
|
||||
llm_config: LLMConfig,
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> FeatureEmbedding:
|
||||
"""
|
||||
Generate a feature embedding for a single issue description.
|
||||
|
||||
Makes multiple LLM calls to collect samples and reduce variance in feature evaluations.
|
||||
Each call uses tool calling to extract structured boolean feature values.
|
||||
|
||||
Args:
|
||||
issue_description: The description of the issue to analyze.
|
||||
llm_config: Configuration for the LLM to use.
|
||||
temperature: Sampling temperature for the model. Higher values increase randomness.
|
||||
samples: Number of samples to generate for averaging.
|
||||
|
||||
Returns:
|
||||
FeatureEmbedding: Complete embedding with samples and metadata.
|
||||
"""
|
||||
embedding_samples: list[dict[str, Any]] = []
|
||||
response_latency: float = 0.0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
|
||||
# TODO: use llm registry
|
||||
llm = LLM(llm_config, service_id='solvability')
|
||||
|
||||
# Generate multiple samples to account for LLM variability
|
||||
for _ in range(samples):
|
||||
start_time = time.time()
|
||||
response = llm.completion(
|
||||
messages=[
|
||||
self.system_message(),
|
||||
self.user_message(issue_description, set_cache=(samples > 1)),
|
||||
],
|
||||
tools=[self.tool_description],
|
||||
tool_choice=self.tool_choice,
|
||||
temperature=temperature,
|
||||
)
|
||||
stop_time = time.time()
|
||||
|
||||
# Extract timing and token usage metrics
|
||||
latency = stop_time - start_time
|
||||
# Parse the structured tool call response containing feature evaluations
|
||||
features = response.choices[0].message.tool_calls[0].function.arguments # type: ignore[index, union-attr]
|
||||
embedding = json.loads(features)
|
||||
|
||||
# Accumulate results and metrics
|
||||
embedding_samples.append(embedding)
|
||||
prompt_tokens += response.usage.prompt_tokens # type: ignore[union-attr, attr-defined]
|
||||
completion_tokens += response.usage.completion_tokens # type: ignore[union-attr, attr-defined]
|
||||
response_latency += latency
|
||||
|
||||
return FeatureEmbedding(
|
||||
samples=embedding_samples,
|
||||
response_latency=response_latency,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
||||
def embed_batch(
|
||||
self,
|
||||
issue_descriptions: list[str],
|
||||
llm_config: LLMConfig,
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> list[FeatureEmbedding]:
|
||||
"""
|
||||
Generate embeddings for a batch of issue descriptions using concurrent processing.
|
||||
|
||||
Processes multiple issues in parallel to improve throughput while maintaining
|
||||
result ordering.
|
||||
|
||||
Args:
|
||||
issue_descriptions: List of issue descriptions to analyze.
|
||||
llm_config: Configuration for the LLM to use.
|
||||
temperature: Sampling temperature for the model.
|
||||
samples: Number of samples to generate per issue.
|
||||
|
||||
Returns:
|
||||
list[FeatureEmbedding]: List of embeddings in the same order as input.
|
||||
"""
|
||||
with ThreadPoolExecutor() as executor:
|
||||
# Submit all embedding tasks concurrently
|
||||
future_to_desc = {
|
||||
executor.submit(
|
||||
self.embed,
|
||||
desc,
|
||||
llm_config,
|
||||
temperature=temperature,
|
||||
samples=samples,
|
||||
): i
|
||||
for i, desc in enumerate(issue_descriptions)
|
||||
}
|
||||
|
||||
# Collect results in original order to maintain consistency
|
||||
results: list[FeatureEmbedding] = [None] * len(issue_descriptions) # type: ignore[list-item]
|
||||
for future in as_completed(future_to_desc):
|
||||
index = future_to_desc[future]
|
||||
results[index] = future.result()
|
||||
|
||||
return results
|
||||
|
||||
def feature_identifiers(self) -> list[str]:
|
||||
"""
|
||||
Get the identifiers of all configured features.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers in the order they were defined.
|
||||
"""
|
||||
return [feature.identifier for feature in self.features]
|
||||
@@ -1,23 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ImportanceStrategy(str, Enum):
|
||||
"""
|
||||
Strategy to use for calculating feature importances, which are used to estimate the predictive power of each feature
|
||||
in training loops and explanations.
|
||||
"""
|
||||
|
||||
SHAP = 'shap'
|
||||
"""
|
||||
Use SHAP (SHapley Additive exPlanations) to calculate feature importances.
|
||||
"""
|
||||
|
||||
PERMUTATION = 'permutation'
|
||||
"""
|
||||
Use the permutation-based feature importances.
|
||||
"""
|
||||
|
||||
IMPURITY = 'impurity'
|
||||
"""
|
||||
Use the impurity-based feature importances from the RandomForestClassifier.
|
||||
"""
|
||||
@@ -1,87 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SolvabilityReport(BaseModel):
|
||||
"""
|
||||
Comprehensive report containing solvability predictions and analysis for a single issue.
|
||||
|
||||
This report includes the solvability score, extracted feature values, feature importance analysis,
|
||||
cost metrics (tokens and latency), and metadata about the prediction process. It serves as the
|
||||
primary output format for solvability analysis and can be used for logging, debugging, and
|
||||
generating human-readable summaries.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""
|
||||
The identifier of the solvability model used to generate the report.
|
||||
"""
|
||||
|
||||
issue: str
|
||||
"""
|
||||
The issue description for which the solvability is predicted.
|
||||
|
||||
This field is exactly the input to the solvability model.
|
||||
"""
|
||||
|
||||
score: float
|
||||
"""
|
||||
[0, 1]-valued score indicating the likelihood of the issue being solvable.
|
||||
"""
|
||||
|
||||
prompt_tokens: int
|
||||
"""
|
||||
Total number of prompt tokens used in API calls made to generate the features.
|
||||
"""
|
||||
|
||||
completion_tokens: int
|
||||
"""
|
||||
Total number of completion tokens used in API calls made to generate the features.
|
||||
"""
|
||||
|
||||
response_latency: float
|
||||
"""
|
||||
Total response latency of API calls made to generate the features.
|
||||
"""
|
||||
|
||||
features: dict[str, float]
|
||||
"""
|
||||
[0, 1]-valued scores for each feature in the model.
|
||||
|
||||
These are the values fed to the random forest classifier to generate the solvability score.
|
||||
"""
|
||||
|
||||
samples: int
|
||||
"""
|
||||
Number of samples used to compute the feature embedding coefficients.
|
||||
"""
|
||||
|
||||
importance_strategy: ImportanceStrategy
|
||||
"""
|
||||
Strategy used to calculate feature importances.
|
||||
"""
|
||||
|
||||
feature_importances: dict[str, float]
|
||||
"""
|
||||
Importance scores for each feature in the model.
|
||||
|
||||
Interpretation of these scores depends on the importance strategy used.
|
||||
"""
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
"""
|
||||
Datetime when the report was created.
|
||||
"""
|
||||
|
||||
random_state: int | None = None
|
||||
"""
|
||||
Classifier random state used when generating this report.
|
||||
"""
|
||||
|
||||
metadata: dict[str, Any] | None = None
|
||||
"""
|
||||
Metadata for logging and debugging purposes.
|
||||
"""
|
||||
@@ -1,172 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from integrations.solvability.models.difficulty_level import DifficultyLevel
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from integrations.solvability.prompts import load_prompt
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.llm import LLM
|
||||
|
||||
|
||||
class SolvabilitySummary(BaseModel):
|
||||
"""Summary of the solvability analysis in human-readable format."""
|
||||
|
||||
score: float
|
||||
"""
|
||||
Solvability score indicating the likelihood of the issue being solvable.
|
||||
"""
|
||||
|
||||
summary: str
|
||||
"""
|
||||
The executive summary content generated by the LLM.
|
||||
"""
|
||||
|
||||
actionable_feedback: str
|
||||
"""
|
||||
Actionable feedback content generated by the LLM.
|
||||
"""
|
||||
|
||||
positive_feedback: str
|
||||
"""
|
||||
Positive feedback content generated by the LLM, highlighting what is good about the issue.
|
||||
"""
|
||||
|
||||
prompt_tokens: int
|
||||
"""
|
||||
Number of prompt tokens used in the API call to generate the summary.
|
||||
"""
|
||||
|
||||
completion_tokens: int
|
||||
"""
|
||||
Number of completion tokens used in the API call to generate the summary.
|
||||
"""
|
||||
|
||||
response_latency: float
|
||||
"""
|
||||
Response latency of the API call to generate the summary.
|
||||
"""
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
"""
|
||||
Datetime when the summary was created.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def tool_description() -> dict[str, Any]:
|
||||
"""Get the tool description for the LLM."""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'solvability_summary',
|
||||
'description': 'Generate a human-readable summary of the solvability analysis.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'summary': {
|
||||
'type': 'string',
|
||||
'description': 'A high-level (at most two sentences) summary of the solvability report.',
|
||||
},
|
||||
'actionable_feedback': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Bullet list of 1-3 pieces of actionable feedback on how the user can address the lowest scoring relevant features.'
|
||||
),
|
||||
},
|
||||
'positive_feedback': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Bullet list of 1-3 pieces of positive feedback on the issue, highlighting what is good about it.'
|
||||
),
|
||||
},
|
||||
},
|
||||
'required': ['summary', 'actionable_feedback'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def tool_choice() -> dict[str, Any]:
|
||||
"""Get the tool choice for the LLM."""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'solvability_summary',
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def system_message() -> dict[str, Any]:
|
||||
"""Get the system message for the LLM."""
|
||||
return {
|
||||
'role': 'system',
|
||||
'content': load_prompt('summary_system_message'),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def user_message(report: SolvabilityReport) -> dict[str, Any]:
|
||||
"""Get the user message for the LLM."""
|
||||
return {
|
||||
'role': 'user',
|
||||
'content': load_prompt(
|
||||
'summary_user_message',
|
||||
report=report.model_dump(),
|
||||
difficulty_level=DifficultyLevel.from_score(report.score).value[0],
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_report(report: SolvabilityReport, llm: LLM) -> SolvabilitySummary:
|
||||
"""Create a SolvabilitySummary from a SolvabilityReport."""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
response = llm.completion(
|
||||
messages=[
|
||||
SolvabilitySummary.system_message(),
|
||||
SolvabilitySummary.user_message(report),
|
||||
],
|
||||
tools=[SolvabilitySummary.tool_description()],
|
||||
tool_choice=SolvabilitySummary.tool_choice(),
|
||||
)
|
||||
response_latency = time.time() - start_time
|
||||
|
||||
# Grab the arguments from the forced function call
|
||||
arguments = json.loads(
|
||||
response.choices[0].message.tool_calls[0].function.arguments
|
||||
)
|
||||
|
||||
return SolvabilitySummary(
|
||||
# The score is copied directly from the report
|
||||
score=report.score,
|
||||
# Performance and usage metrics are pulled from the response
|
||||
prompt_tokens=response.usage.prompt_tokens,
|
||||
completion_tokens=response.usage.completion_tokens,
|
||||
response_latency=response_latency,
|
||||
# Every other field should be taken from the forced function call
|
||||
**arguments,
|
||||
)
|
||||
|
||||
def format_as_markdown(self) -> str:
|
||||
"""Format the summary content as Markdown."""
|
||||
# Convert score to difficulty level enum
|
||||
difficulty_level = DifficultyLevel.from_score(self.score)
|
||||
|
||||
# Create the main difficulty display
|
||||
result = f'{difficulty_level.format_display()}\n\n{self.summary}'
|
||||
|
||||
# If not easy, show the three features with lowest importance scores
|
||||
if difficulty_level != DifficultyLevel.EASY:
|
||||
# Add dropdown with lowest importance features
|
||||
result += '\n\nYou can make the issue easier to resolve by addressing these concerns in the conversation:\n\n'
|
||||
result += self.actionable_feedback
|
||||
|
||||
# If the difficulty isn't hard, add some positive feedback
|
||||
if difficulty_level != DifficultyLevel.HARD:
|
||||
result += '\n\nPositive feedback:\n\n'
|
||||
result += self.positive_feedback
|
||||
|
||||
return result
|
||||
@@ -1,13 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
|
||||
|
||||
def load_prompt(prompt: str, **kwargs) -> str:
|
||||
"""Load a prompt by name. Passes all the keyword arguments to the prompt template."""
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent))
|
||||
template = env.get_template(f'{prompt}.j2')
|
||||
return template.render(**kwargs)
|
||||
|
||||
|
||||
__all__ = ['load_prompt']
|
||||
@@ -1,10 +0,0 @@
|
||||
You are a helpful assistant that generates human-readable summaries of solvability reports.
|
||||
The report predicts how likely it is that the issue can be resolved, and is produced purely based on the information provided in the issue description and comments.
|
||||
The report explains which features are present in the issue and how impactful they are to the solvability score (using SHAP values).
|
||||
Your task is to create a concise, high-level summary of the solvability analysis,
|
||||
with an emphasis on the key factors that make the issue easy or hard to resolve.
|
||||
Focus on the features with extreme scores, BUT ONLY if they are related to the issue at hand after careful consideration.
|
||||
You should NEVER mention: SHAP, scores, feature names, or technical metrics.
|
||||
You will also be given the expected difficulty of the issue, as EASY/MEDIUM/HARD.
|
||||
Be sure to frame your responses with that difficulty in mind.
|
||||
For example, if the issue is HARD you should not describe it as "straightforward".
|
||||
@@ -1,9 +0,0 @@
|
||||
Generate a high-level summary of the solvability report:
|
||||
|
||||
{{ report }}
|
||||
|
||||
We estimate the issue is {{ difficulty_level }}.
|
||||
The summary should be concise (at most two sentences) and describe the primary characteristics of this issue.
|
||||
Focus on what information is present and what factors are most relevant to resolution.
|
||||
Actionable feedback should be something that can be addressed by the user purely by providing more information.
|
||||
Positive feedback should explain the features that are positively contributing to the solvability score.
|
||||
@@ -59,11 +59,11 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
# Create the customer in stripe
|
||||
customer = await stripe.Customer.create_async(
|
||||
email=org.contact_email,
|
||||
metadata={'org_id': str(org.id)},
|
||||
)
|
||||
# Create the customer in stripe (only include email if available)
|
||||
create_params: dict = {'metadata': {'org_id': str(org.id)}}
|
||||
if org.contact_email:
|
||||
create_params['email'] = org.contact_email
|
||||
customer = await stripe.Customer.create_async(**create_params)
|
||||
|
||||
# Save the stripe customer in the local db
|
||||
async with a_session_maker() as session:
|
||||
@@ -108,11 +108,14 @@ async def migrate_customer(session, user_id: str, org: Org):
|
||||
if stripe_customer is None:
|
||||
return
|
||||
stripe_customer.org_id = org.id
|
||||
customer = await stripe.Customer.modify_async(
|
||||
id=stripe_customer.stripe_customer_id,
|
||||
email=org.contact_email,
|
||||
metadata={'user_id': '', 'org_id': str(org.id)},
|
||||
)
|
||||
# Only include email if available to avoid sending empty strings to Stripe
|
||||
modify_params: dict = {
|
||||
'id': stripe_customer.stripe_customer_id,
|
||||
'metadata': {'user_id': '', 'org_id': str(org.id)},
|
||||
}
|
||||
if org.contact_email:
|
||||
modify_params['email'] = org.contact_email
|
||||
customer = await stripe.Customer.modify_async(**modify_params)
|
||||
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from jinja2 import Environment
|
||||
from pydantic import BaseModel
|
||||
@@ -10,7 +11,6 @@ if TYPE_CHECKING:
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
|
||||
class GitLabResourceType(Enum):
|
||||
@@ -53,11 +53,11 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
"""Instructions passed when conversation is first initialized."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def initialize_new_conversation(self) -> 'ConversationMetadata':
|
||||
"""Initialize a new conversation and return metadata.
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
"""Initialize a new conversation and return the conversation ID.
|
||||
|
||||
For V1 conversations, creates a dummy ConversationMetadata.
|
||||
For V0 conversations, initializes through the conversation store.
|
||||
This method resolves the target organization and generates a new
|
||||
conversation ID.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -65,7 +65,7 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: 'PROVIDER_TOKEN_TYPE',
|
||||
conversation_metadata: 'ConversationMetadata',
|
||||
conversation_id: UUID,
|
||||
saas_user_auth: 'UserAuth',
|
||||
) -> None:
|
||||
"""Create a new conversation.
|
||||
@@ -73,7 +73,7 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
Args:
|
||||
jinja_env: Jinja2 environment for template rendering
|
||||
git_provider_tokens: Token mapping for git providers
|
||||
conversation_metadata: Metadata for the conversation
|
||||
conversation_id: The UUID of the conversation to create
|
||||
saas_user_auth: User authentication for SaaS
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.constants import WEB_HOST
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events import Event, EventSource
|
||||
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
|
||||
|
||||
# ---- DO NOT REMOVE ----
|
||||
@@ -27,10 +15,8 @@ HOST = WEB_HOST
|
||||
|
||||
IS_LOCAL_DEPLOYMENT = 'localhost' in HOST
|
||||
HOST_URL = f'https://{HOST}' if not IS_LOCAL_DEPLOYMENT else f'http://{HOST}'
|
||||
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
|
||||
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
|
||||
conversation_prefix = 'conversations/{}'
|
||||
CONVERSATION_URL = f'{HOST_URL}/{conversation_prefix}'
|
||||
CONVERSATION_URL = f'{HOST_URL}/conversations/{{}}'
|
||||
|
||||
# Toggle for auto-response feature that proactively starts conversations with users when workflow tests fail
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS = (
|
||||
@@ -77,30 +63,11 @@ def get_user_not_found_message(username: str | None = None) -> str:
|
||||
return f"It looks like you haven't created an OpenHands account yet. Please sign up 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'
|
||||
)
|
||||
|
||||
# Toggle for V1 GitLab resolver feature
|
||||
ENABLE_V1_GITLAB_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_GITLAB_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
|
||||
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
|
||||
or 'openhands/integrations/templates/resolver/'
|
||||
)
|
||||
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
_jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
|
||||
|
||||
def get_oh_labels(web_host: str) -> tuple[str, str]:
|
||||
@@ -122,31 +89,11 @@ def get_oh_labels(web_host: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
def get_summary_instruction():
|
||||
summary_instruction_template = jinja_env.get_template('summary_prompt.j2')
|
||||
summary_instruction_template = _jinja_env.get_template('summary_prompt.j2')
|
||||
summary_instruction = summary_instruction_template.render()
|
||||
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 not user_id:
|
||||
return False
|
||||
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
|
||||
if not org or org.v1_enabled is None:
|
||||
return False
|
||||
|
||||
return org.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).
|
||||
|
||||
@@ -173,205 +120,6 @@ def has_exact_mention(text: str, mention: str) -> bool:
|
||||
return bool(re.search(rf'(?:^|[^\w@]){pattern}(?![\w-])', text_lower))
|
||||
|
||||
|
||||
def confirm_event_type(event: Event):
|
||||
return isinstance(event, AgentStateChangedObservation) and not (
|
||||
event.agent_state == AgentState.REJECTED
|
||||
or event.agent_state == AgentState.USER_CONFIRMED
|
||||
or event.agent_state == AgentState.USER_REJECTED
|
||||
or event.agent_state == AgentState.LOADING
|
||||
or event.agent_state == AgentState.RUNNING
|
||||
)
|
||||
|
||||
|
||||
def get_readable_error_reason(reason: str):
|
||||
if reason == 'STATUS$ERROR_LLM_AUTHENTICATION':
|
||||
reason = 'Authentication with the LLM provider failed. Please check your API key or credentials'
|
||||
elif reason == 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE':
|
||||
reason = 'The LLM service is temporarily unavailable. Please try again later'
|
||||
elif reason == 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR':
|
||||
reason = 'The LLM provider encountered an internal error. Please try again soon'
|
||||
elif reason == 'STATUS$ERROR_LLM_OUT_OF_CREDITS':
|
||||
reason = "You've run out of credits. Please top up to continue"
|
||||
elif reason == 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION':
|
||||
reason = 'Content policy violation. The output was blocked by content filtering policy'
|
||||
return reason
|
||||
|
||||
|
||||
def get_summary_for_agent_state(
|
||||
observations: list[AgentStateChangedObservation], conversation_link: str
|
||||
) -> str:
|
||||
unknown_error_msg = f'OpenHands encountered an unknown error. [See the conversation]({conversation_link}) for more information, or try again'
|
||||
|
||||
if len(observations) == 0:
|
||||
logger.error(
|
||||
'Unknown error: No agent state observations found',
|
||||
extra={'conversation_link': conversation_link},
|
||||
)
|
||||
return unknown_error_msg
|
||||
|
||||
observation: AgentStateChangedObservation = observations[0]
|
||||
state = observation.agent_state
|
||||
|
||||
if state == AgentState.RATE_LIMITED:
|
||||
logger.warning(
|
||||
'Agent was rate limited',
|
||||
extra={
|
||||
'agent_state': state.value,
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': getattr(observation, 'reason', None),
|
||||
},
|
||||
)
|
||||
return 'OpenHands was rate limited by the LLM provider. Please try again later.'
|
||||
|
||||
if state == AgentState.ERROR:
|
||||
reason = observation.reason
|
||||
reason = get_readable_error_reason(reason)
|
||||
|
||||
logger.error(
|
||||
'Agent encountered an error',
|
||||
extra={
|
||||
'agent_state': state.value,
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': observation.reason,
|
||||
'readable_reason': reason,
|
||||
},
|
||||
)
|
||||
|
||||
return f'OpenHands encountered an error: **{reason}**.\n\n[See the conversation]({conversation_link}) for more information.'
|
||||
|
||||
if state == AgentState.AWAITING_USER_INPUT:
|
||||
logger.info(
|
||||
'Agent is awaiting user input',
|
||||
extra={
|
||||
'agent_state': state.value,
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': getattr(observation, 'reason', None),
|
||||
},
|
||||
)
|
||||
return f'OpenHands is waiting for your input. [Continue the conversation]({conversation_link}) to provide additional instructions.'
|
||||
|
||||
# Log unknown agent state as error
|
||||
logger.error(
|
||||
'Unknown error: Unhandled agent state',
|
||||
extra={
|
||||
'agent_state': state.value if hasattr(state, 'value') else str(state),
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': getattr(observation, 'reason', None),
|
||||
},
|
||||
)
|
||||
return unknown_error_msg
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
)
|
||||
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,
|
||||
)
|
||||
)
|
||||
result = [e for e in events if isinstance(e, MessageAction)]
|
||||
assert len(result) == len(events)
|
||||
return result
|
||||
|
||||
|
||||
def extract_summary_from_event_store(
|
||||
event_store: EventStoreABC, conversation_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Get agent summary or alternative message depending on current AgentState
|
||||
"""
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
final_agent_observation = get_final_agent_observation(event_store)
|
||||
|
||||
# Find summary instruction event ID
|
||||
if not instruction_events:
|
||||
logger.warning(
|
||||
'no_instruction_event_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
return get_summary_for_agent_state(
|
||||
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,
|
||||
reverse=True,
|
||||
start_id=instruction_events[0].id,
|
||||
)
|
||||
)
|
||||
|
||||
if not summary_events:
|
||||
logger.warning(
|
||||
'no_agent_messages_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
return get_summary_for_agent_state(
|
||||
final_agent_observation, conversation_link
|
||||
) # Agent failed to generate summary
|
||||
|
||||
summary_event = summary_events[0]
|
||||
if isinstance(summary_event, MessageAction):
|
||||
return summary_event.content
|
||||
|
||||
assert isinstance(summary_event, AgentFinishAction)
|
||||
return summary_event.final_thought
|
||||
|
||||
|
||||
def append_conversation_footer(message: str, conversation_id: str) -> str:
|
||||
"""
|
||||
Append a small footer with the conversation URL to a message.
|
||||
|
||||
Args:
|
||||
message: The original message content
|
||||
conversation_id: The conversation ID to link to
|
||||
|
||||
Returns:
|
||||
The message with the conversation footer appended
|
||||
"""
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
footer = f'\n\n[View full conversation]({conversation_link})'
|
||||
return message + footer
|
||||
|
||||
|
||||
def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
"""
|
||||
Extract all repository names in the format 'owner/repo' from various Git provider URLs
|
||||
|
||||
@@ -6,7 +6,8 @@ Create Date: 2026-03-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
@@ -21,6 +22,187 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
_EMPTY_JSON = sa.text("'{}'::json")
|
||||
|
||||
|
||||
def _deep_merge(
|
||||
base: dict[str, Any], overrides: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
merged = dict(base)
|
||||
for key, value in (overrides or {}).items():
|
||||
existing = merged.get(key)
|
||||
if isinstance(existing, dict) and isinstance(value, Mapping):
|
||||
merged[key] = _deep_merge(existing, value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _strip_none_and_empty(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
cleaned_item = _strip_none_and_empty(item)
|
||||
if cleaned_item is None:
|
||||
continue
|
||||
if isinstance(cleaned_item, dict) and not cleaned_item:
|
||||
continue
|
||||
cleaned[key] = cleaned_item
|
||||
return cleaned
|
||||
return value
|
||||
|
||||
|
||||
def _build_user_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'schema_version': 1,
|
||||
'agent': row['agent'],
|
||||
'llm': {
|
||||
'model': row['llm_model'],
|
||||
'base_url': row['llm_base_url'],
|
||||
},
|
||||
'condenser': {
|
||||
'enabled': row['enable_default_condenser'],
|
||||
'max_size': row['condenser_max_size'],
|
||||
},
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('agent_settings') or {})
|
||||
|
||||
|
||||
def _build_user_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'max_iterations': row['max_iterations'],
|
||||
'confirmation_mode': row['confirmation_mode'],
|
||||
'security_analyzer': row['security_analyzer'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('conversation_settings') or {})
|
||||
|
||||
|
||||
def _build_org_member_agent_settings_diff(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'schema_version': 1,
|
||||
'llm': {
|
||||
'model': row['llm_model'],
|
||||
'base_url': row['llm_base_url'],
|
||||
},
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('agent_settings_diff') or {})
|
||||
|
||||
|
||||
def _build_org_member_conversation_settings_diff(
|
||||
row: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty({'max_iterations': row['max_iterations']})
|
||||
return _deep_merge(generated, row.get('conversation_settings_diff') or {})
|
||||
|
||||
|
||||
def _build_org_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'schema_version': 1,
|
||||
'agent': row['agent'],
|
||||
'llm': {
|
||||
'model': row['default_llm_model'],
|
||||
'base_url': row['default_llm_base_url'],
|
||||
},
|
||||
'condenser': {
|
||||
'enabled': row['enable_default_condenser'],
|
||||
'max_size': row['condenser_max_size'],
|
||||
},
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('agent_settings') or {})
|
||||
|
||||
|
||||
def _build_org_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'max_iterations': row['default_max_iterations'],
|
||||
'confirmation_mode': row['confirmation_mode'],
|
||||
'security_analyzer': row['security_analyzer'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('conversation_settings') or {})
|
||||
|
||||
|
||||
def _get_nested_value(data: Mapping[str, Any] | None, *path: str) -> Any:
|
||||
current: Any = data or {}
|
||||
for key in path:
|
||||
if not isinstance(current, Mapping) or key not in current:
|
||||
return None
|
||||
current = current[key]
|
||||
return current
|
||||
|
||||
|
||||
def _legacy_user_settings_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent_settings = row.get('agent_settings') or {}
|
||||
conversation_settings = row.get('conversation_settings') or {}
|
||||
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
|
||||
return {
|
||||
'agent': _get_nested_value(agent_settings, 'agent'),
|
||||
'max_iterations': _get_nested_value(conversation_settings, 'max_iterations'),
|
||||
'security_analyzer': _get_nested_value(
|
||||
conversation_settings, 'security_analyzer'
|
||||
),
|
||||
'confirmation_mode': _get_nested_value(
|
||||
conversation_settings, 'confirmation_mode'
|
||||
),
|
||||
'llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
|
||||
'llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
|
||||
'enable_default_condenser': (
|
||||
True if condenser_enabled is None else condenser_enabled
|
||||
),
|
||||
'condenser_max_size': _get_nested_value(
|
||||
agent_settings, 'condenser', 'max_size'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _legacy_org_member_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent_settings_diff = row.get('agent_settings_diff') or {}
|
||||
conversation_settings_diff = row.get('conversation_settings_diff') or {}
|
||||
return {
|
||||
'llm_model': _get_nested_value(agent_settings_diff, 'llm', 'model'),
|
||||
'llm_base_url': _get_nested_value(agent_settings_diff, 'llm', 'base_url'),
|
||||
'max_iterations': _get_nested_value(
|
||||
conversation_settings_diff, 'max_iterations'
|
||||
),
|
||||
'mcp_config': _get_nested_value(agent_settings_diff, 'mcp_config'),
|
||||
}
|
||||
|
||||
|
||||
def _legacy_org_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent_settings = row.get('agent_settings') or {}
|
||||
conversation_settings = row.get('conversation_settings') or {}
|
||||
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
|
||||
return {
|
||||
'agent': _get_nested_value(agent_settings, 'agent'),
|
||||
'default_max_iterations': _get_nested_value(
|
||||
conversation_settings, 'max_iterations'
|
||||
),
|
||||
'security_analyzer': _get_nested_value(
|
||||
conversation_settings, 'security_analyzer'
|
||||
),
|
||||
'confirmation_mode': _get_nested_value(
|
||||
conversation_settings, 'confirmation_mode'
|
||||
),
|
||||
'default_llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
|
||||
'default_llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
|
||||
'enable_default_condenser': (
|
||||
True if condenser_enabled is None else condenser_enabled
|
||||
),
|
||||
'mcp_config': _get_nested_value(agent_settings, 'mcp_config'),
|
||||
'condenser_max_size': _get_nested_value(
|
||||
agent_settings, 'condenser', 'max_size'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
@@ -82,63 +264,125 @@ def upgrade() -> None:
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET agent_settings = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'agent', agent,
|
||||
'llm.model', llm_model,
|
||||
'llm.base_url', llm_base_url,
|
||||
'verification.confirmation_mode', confirmation_mode,
|
||||
'verification.security_analyzer', security_analyzer,
|
||||
'condenser.enabled', enable_default_condenser,
|
||||
'condenser.max_size', condenser_max_size,
|
||||
'max_iterations', max_iterations
|
||||
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
)
|
||||
bind = op.get_bind()
|
||||
|
||||
user_settings_table = sa.table(
|
||||
'user_settings',
|
||||
sa.column('id', sa.Integer()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org_member
|
||||
SET agent_settings_diff = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'llm.model', llm_model,
|
||||
'llm.base_url', llm_base_url,
|
||||
'max_iterations', max_iterations,
|
||||
'mcp_config', mcp_config
|
||||
) || COALESCE(agent_settings_diff::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
user_settings_rows = bind.execute(
|
||||
sa.select(
|
||||
user_settings_table.c.id,
|
||||
user_settings_table.c.agent,
|
||||
user_settings_table.c.max_iterations,
|
||||
user_settings_table.c.security_analyzer,
|
||||
user_settings_table.c.confirmation_mode,
|
||||
user_settings_table.c.llm_model,
|
||||
user_settings_table.c.llm_base_url,
|
||||
user_settings_table.c.enable_default_condenser,
|
||||
user_settings_table.c.condenser_max_size,
|
||||
user_settings_table.c.mcp_config,
|
||||
user_settings_table.c.agent_settings,
|
||||
user_settings_table.c.conversation_settings,
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org
|
||||
SET agent_settings = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'agent', agent,
|
||||
'llm.model', default_llm_model,
|
||||
'llm.base_url', default_llm_base_url,
|
||||
'verification.confirmation_mode', confirmation_mode,
|
||||
'verification.security_analyzer', security_analyzer,
|
||||
'condenser.enabled', enable_default_condenser,
|
||||
'condenser.max_size', condenser_max_size,
|
||||
'max_iterations', default_max_iterations,
|
||||
'mcp_config', mcp_config
|
||||
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
).mappings()
|
||||
for row in user_settings_rows:
|
||||
bind.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.id == row['id'])
|
||||
.values(
|
||||
agent_settings=_build_user_agent_settings(row),
|
||||
conversation_settings=_build_user_conversation_settings(row),
|
||||
)
|
||||
)
|
||||
|
||||
org_member_table = sa.table(
|
||||
'org_member',
|
||||
sa.column('org_id', sa.Uuid()),
|
||||
sa.column('user_id', sa.Uuid()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('agent_settings_diff', sa.JSON()),
|
||||
sa.column('conversation_settings_diff', sa.JSON()),
|
||||
)
|
||||
org_member_rows = bind.execute(
|
||||
sa.select(
|
||||
org_member_table.c.org_id,
|
||||
org_member_table.c.user_id,
|
||||
org_member_table.c.max_iterations,
|
||||
org_member_table.c.llm_model,
|
||||
org_member_table.c.llm_base_url,
|
||||
org_member_table.c.mcp_config,
|
||||
org_member_table.c.agent_settings_diff,
|
||||
org_member_table.c.conversation_settings_diff,
|
||||
)
|
||||
).mappings()
|
||||
for row in org_member_rows:
|
||||
bind.execute(
|
||||
org_member_table.update()
|
||||
.where(org_member_table.c.org_id == row['org_id'])
|
||||
.where(org_member_table.c.user_id == row['user_id'])
|
||||
.values(
|
||||
agent_settings_diff=_build_org_member_agent_settings_diff(row),
|
||||
conversation_settings_diff=_build_org_member_conversation_settings_diff(
|
||||
row
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
org_table = sa.table(
|
||||
'org',
|
||||
sa.column('id', sa.Uuid()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('default_max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('default_llm_model', sa.String()),
|
||||
sa.column('default_llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
)
|
||||
org_rows = bind.execute(
|
||||
sa.select(
|
||||
org_table.c.id,
|
||||
org_table.c.agent,
|
||||
org_table.c.default_max_iterations,
|
||||
org_table.c.security_analyzer,
|
||||
org_table.c.confirmation_mode,
|
||||
org_table.c.default_llm_model,
|
||||
org_table.c.default_llm_base_url,
|
||||
org_table.c.enable_default_condenser,
|
||||
org_table.c.mcp_config,
|
||||
org_table.c.condenser_max_size,
|
||||
org_table.c.agent_settings,
|
||||
org_table.c.conversation_settings,
|
||||
)
|
||||
).mappings()
|
||||
for row in org_rows:
|
||||
bind.execute(
|
||||
org_table.update()
|
||||
.where(org_table.c.id == row['id'])
|
||||
.values(
|
||||
agent_settings=_build_org_agent_settings(row),
|
||||
conversation_settings=_build_org_conversation_settings(row),
|
||||
)
|
||||
)
|
||||
|
||||
op.alter_column('user_settings', 'agent_settings', server_default=None)
|
||||
op.alter_column('user_settings', 'conversation_settings', server_default=None)
|
||||
@@ -223,73 +467,92 @@ def downgrade() -> None:
|
||||
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
|
||||
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET
|
||||
agent = agent_settings ->> 'agent',
|
||||
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer,
|
||||
security_analyzer =
|
||||
agent_settings ->> 'verification.security_analyzer',
|
||||
confirmation_mode = CASE
|
||||
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
|
||||
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
|
||||
ELSE NULL
|
||||
END,
|
||||
llm_model = agent_settings ->> 'llm.model',
|
||||
llm_base_url = agent_settings ->> 'llm.base_url',
|
||||
enable_default_condenser = CASE
|
||||
WHEN agent_settings::jsonb ? 'condenser.enabled'
|
||||
THEN (agent_settings ->> 'condenser.enabled')::boolean
|
||||
ELSE TRUE
|
||||
END,
|
||||
condenser_max_size =
|
||||
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
|
||||
"""
|
||||
)
|
||||
bind = op.get_bind()
|
||||
|
||||
user_settings_table = sa.table(
|
||||
'user_settings',
|
||||
sa.column('id', sa.Integer()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org_member
|
||||
SET
|
||||
llm_model = agent_settings_diff ->> 'llm.model',
|
||||
llm_base_url = agent_settings_diff ->> 'llm.base_url',
|
||||
max_iterations =
|
||||
NULLIF(agent_settings_diff ->> 'max_iterations', '')::integer,
|
||||
mcp_config = agent_settings_diff -> 'mcp_config'
|
||||
"""
|
||||
user_settings_rows = bind.execute(
|
||||
sa.select(
|
||||
user_settings_table.c.id,
|
||||
user_settings_table.c.agent_settings,
|
||||
user_settings_table.c.conversation_settings,
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org
|
||||
SET
|
||||
agent = agent_settings ->> 'agent',
|
||||
default_max_iterations =
|
||||
NULLIF(agent_settings ->> 'max_iterations', '')::integer,
|
||||
security_analyzer =
|
||||
agent_settings ->> 'verification.security_analyzer',
|
||||
confirmation_mode = CASE
|
||||
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
|
||||
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
|
||||
ELSE NULL
|
||||
END,
|
||||
default_llm_model = agent_settings ->> 'llm.model',
|
||||
default_llm_base_url = agent_settings ->> 'llm.base_url',
|
||||
enable_default_condenser = CASE
|
||||
WHEN agent_settings::jsonb ? 'condenser.enabled'
|
||||
THEN (agent_settings ->> 'condenser.enabled')::boolean
|
||||
ELSE TRUE
|
||||
END,
|
||||
mcp_config = agent_settings -> 'mcp_config',
|
||||
condenser_max_size =
|
||||
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
|
||||
"""
|
||||
).mappings()
|
||||
for row in user_settings_rows:
|
||||
bind.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.id == row['id'])
|
||||
.values(**_legacy_user_settings_values(row))
|
||||
)
|
||||
|
||||
org_member_table = sa.table(
|
||||
'org_member',
|
||||
sa.column('org_id', sa.Uuid()),
|
||||
sa.column('user_id', sa.Uuid()),
|
||||
sa.column('agent_settings_diff', sa.JSON()),
|
||||
sa.column('conversation_settings_diff', sa.JSON()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
)
|
||||
org_member_rows = bind.execute(
|
||||
sa.select(
|
||||
org_member_table.c.org_id,
|
||||
org_member_table.c.user_id,
|
||||
org_member_table.c.agent_settings_diff,
|
||||
org_member_table.c.conversation_settings_diff,
|
||||
)
|
||||
).mappings()
|
||||
for row in org_member_rows:
|
||||
bind.execute(
|
||||
org_member_table.update()
|
||||
.where(org_member_table.c.org_id == row['org_id'])
|
||||
.where(org_member_table.c.user_id == row['user_id'])
|
||||
.values(**_legacy_org_member_values(row))
|
||||
)
|
||||
|
||||
org_table = sa.table(
|
||||
'org',
|
||||
sa.column('id', sa.Uuid()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('default_max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('default_llm_model', sa.String()),
|
||||
sa.column('default_llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
)
|
||||
org_rows = bind.execute(
|
||||
sa.select(
|
||||
org_table.c.id,
|
||||
org_table.c.agent_settings,
|
||||
org_table.c.conversation_settings,
|
||||
)
|
||||
).mappings()
|
||||
for row in org_rows:
|
||||
bind.execute(
|
||||
org_table.update()
|
||||
.where(org_table.c.id == row['id'])
|
||||
.values(**_legacy_org_values(row))
|
||||
)
|
||||
|
||||
op.drop_column('org', 'agent_settings')
|
||||
op.drop_column('org', 'conversation_settings')
|
||||
op.drop_column('org', '_llm_api_key')
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add llm_profiles column to user table.
|
||||
|
||||
The Settings model exposes ``llm_profiles`` (saved LLM configurations plus
|
||||
the active profile name), but the SaaS path persists a flattened Settings
|
||||
dump onto the User/Org rows. Without a column here the field is silently
|
||||
dropped on store() and always defaults to empty on load(), so saved
|
||||
profiles disappear after any settings update or page refresh.
|
||||
|
||||
The column is plain ``String`` because the ORM-level ``EncryptedJSON``
|
||||
TypeDecorator stores JSON-serialized profiles as a JWE-encrypted string —
|
||||
profiles can carry per-profile ``api_key`` values, so the at-rest
|
||||
representation must match the existing org/member encrypted-secret pattern.
|
||||
|
||||
Revision ID: 109
|
||||
Revises: 108
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '109'
|
||||
down_revision: Union[str, None] = '108'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('user', sa.Column('llm_profiles', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'llm_profiles')
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add agent_kind column to conversation_metadata table.
|
||||
|
||||
Stores the agent type ('llm' or 'acp') for each conversation so the
|
||||
correct agent-server endpoint can be used when routing requests.
|
||||
|
||||
Revision ID: 110
|
||||
Revises: 109
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '110'
|
||||
down_revision: Union[str, None] = '109'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('agent_kind', sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('conversation_metadata', 'agent_kind')
|
||||
107
enterprise/poetry.lock
generated
107
enterprise/poetry.lock
generated
@@ -1708,61 +1708,61 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.6"
|
||||
version = "46.0.7"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"},
|
||||
{file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"},
|
||||
{file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"},
|
||||
{file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"},
|
||||
{file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"},
|
||||
{file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"},
|
||||
{file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"},
|
||||
{file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"},
|
||||
{file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"},
|
||||
{file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"},
|
||||
{file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1775,7 +1775,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
|
||||
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
|
||||
sdist = ["build (>=1.0.0)"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@@ -6547,7 +6547,7 @@ python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
python-json-logger = ">=3.2.1"
|
||||
python-multipart = ">=0.0.22"
|
||||
python-multipart = ">=0.0.26"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
@@ -6571,9 +6571,6 @@ uvicorn = "*"
|
||||
whatthepatch = ">=1.0.6"
|
||||
zope-interface = "7.2"
|
||||
|
||||
[package.extras]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2)", "modal (>=0.66.26,<1.2)", "runloop-api-client (==0.50)"]
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = ".."
|
||||
|
||||
@@ -28,7 +28,6 @@ from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
from server.routes.auth import api_router, oauth_router # noqa: E402
|
||||
from server.routes.billing import billing_router # noqa: E402
|
||||
from server.routes.email import api_router as email_router # noqa: E402
|
||||
from server.routes.feedback import router as feedback_router # noqa: E402
|
||||
from server.routes.github_proxy import add_github_proxy_routes # noqa: E402
|
||||
from server.routes.integration.jira import jira_integration_router # noqa: E402
|
||||
from server.routes.integration.jira_dc import jira_dc_integration_router # noqa: E402
|
||||
@@ -106,8 +105,15 @@ if GITHUB_APP_CLIENT_ID:
|
||||
|
||||
# Add GitLab integration router only if GITLAB_APP_CLIENT_ID is set
|
||||
if GITLAB_APP_CLIENT_ID:
|
||||
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
|
||||
from integrations.gitlab.gitlab_v1_callback_processor import ( # noqa: E402
|
||||
GitlabV1CallbackProcessor,
|
||||
)
|
||||
from server.routes.integration.gitlab import gitlab_integration_router # noqa: E402
|
||||
|
||||
# Bludgeon mypy into not deleting my import
|
||||
logger.debug(f'Loaded {GitlabV1CallbackProcessor.__name__}')
|
||||
|
||||
base_app.include_router(gitlab_integration_router)
|
||||
|
||||
base_app.include_router(api_keys_router) # Add routes for API key management
|
||||
@@ -140,7 +146,6 @@ if BITBUCKET_DATA_CENTER_HOST:
|
||||
|
||||
base_app.include_router(bitbucket_dc_proxy_router)
|
||||
base_app.include_router(email_router) # Add routes for email management
|
||||
base_app.include_router(feedback_router) # Add routes for conversation feedback
|
||||
|
||||
|
||||
base_app.add_middleware(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import os
|
||||
|
||||
from openhands.integrations.gitlab.constants import GITLAB_HOST
|
||||
|
||||
GITHUB_APP_CLIENT_ID = os.getenv('GITHUB_APP_CLIENT_ID', '').strip()
|
||||
GITHUB_APP_CLIENT_SECRET = os.getenv('GITHUB_APP_CLIENT_SECRET', '').strip()
|
||||
GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
|
||||
@@ -14,6 +16,7 @@ KEYCLOAK_SERVER_URL_EXT = os.getenv(
|
||||
KEYCLOAK_ADMIN_PASSWORD = os.getenv('KEYCLOAK_ADMIN_PASSWORD', '')
|
||||
GITLAB_APP_CLIENT_ID = os.getenv('GITLAB_APP_CLIENT_ID', '').strip()
|
||||
GITLAB_APP_CLIENT_SECRET = os.getenv('GITLAB_APP_CLIENT_SECRET', '').strip()
|
||||
GITLAB_TOKEN_URL = f'https://{GITLAB_HOST}/oauth/token'
|
||||
BITBUCKET_APP_CLIENT_ID = os.getenv('BITBUCKET_APP_CLIENT_ID', '').strip()
|
||||
BITBUCKET_APP_CLIENT_SECRET = os.getenv('BITBUCKET_APP_CLIENT_SECRET', '').strip()
|
||||
ENABLE_ENTERPRISE_SSO = os.getenv('ENABLE_ENTERPRISE_SSO', '').strip()
|
||||
|
||||
@@ -35,15 +35,15 @@ from storage.user_authorization_store import UserAuthorizationStore
|
||||
from storage.user_store import UserStore
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from server.auth.constants import (
|
||||
GITHUB_APP_CLIENT_SECRET,
|
||||
GITLAB_APP_CLIENT_ID,
|
||||
GITLAB_APP_CLIENT_SECRET,
|
||||
GITLAB_TOKEN_URL,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
@@ -417,7 +418,7 @@ class TokenManager:
|
||||
return await self._parse_refresh_response(data)
|
||||
|
||||
async def _refresh_gitlab_token(self, refresh_token: str) -> dict[str, str | int]:
|
||||
url = 'https://gitlab.com/oauth/token'
|
||||
url = GITLAB_TOKEN_URL
|
||||
logger.info(f'Refreshing GitLab token with URL: {url}')
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -72,12 +72,6 @@ class SaaSServerConfig(ServerConfig):
|
||||
auth_url: str | None = os.environ.get('AUTH_URL')
|
||||
settings_store_class: str = 'storage.saas_settings_store.SaasSettingsStore'
|
||||
secret_store_class: str = 'storage.saas_secrets_store.SaasSecretsStore'
|
||||
conversation_store_class: str = (
|
||||
'storage.saas_conversation_store.SaasConversationStore'
|
||||
)
|
||||
monitoring_listener_class: str = (
|
||||
'server.saas_monitoring_listener.SaaSMonitoringListener'
|
||||
)
|
||||
user_auth_class: str = 'server.auth.saas_user_auth.SaasUserAuth'
|
||||
# Maintenance window configuration
|
||||
maintenance_start_time: str = os.environ.get(
|
||||
|
||||
@@ -16,8 +16,8 @@ from server.routes.auth import set_response_cookie
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
from openhands.server.utils import config
|
||||
|
||||
|
||||
class SetAuthCookieMiddleware:
|
||||
|
||||
@@ -703,6 +703,41 @@ async def accept_tos(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
@api_router.get('/onboarding_status')
|
||||
async def onboarding_status(request: Request):
|
||||
"""Return whether the current user must still complete onboarding.
|
||||
|
||||
Kept as a dedicated endpoint instead of riding on ``GET /api/v1/settings``
|
||||
(the natural home for fields like ``email_verified``) because the settings
|
||||
response is heavyweight: ``SaasSettingsStore.load`` joins User, Org, and
|
||||
OrgMember rows and deep-merges the org-level and member-level
|
||||
``agent_settings`` before returning. Onboarding gating runs on every
|
||||
protected-route navigation, so we need a lightweight read of a single
|
||||
boolean rather than paying for the full settings aggregation.
|
||||
"""
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'User is not authenticated'},
|
||||
)
|
||||
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'User not found'},
|
||||
)
|
||||
|
||||
should_complete = await _should_redirect_to_onboarding(user_id, user)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'should_complete_onboarding': should_complete},
|
||||
)
|
||||
|
||||
|
||||
@api_router.post('/complete_onboarding')
|
||||
async def complete_onboarding(request: Request):
|
||||
"""Mark onboarding as completed for the current user."""
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.future import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
# TODO: It may be an error by you can actually post feedback to a conversation you don't
|
||||
# own right now - maybe this is useful in the context of public shared conversations?
|
||||
router = APIRouter(
|
||||
prefix='/feedback', tags=['feedback'], dependencies=get_dependencies()
|
||||
)
|
||||
|
||||
|
||||
async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
|
||||
"""Get all event IDs for a given conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to get events for
|
||||
user_id: The ID of the user who owns the conversation
|
||||
|
||||
Returns:
|
||||
List of event IDs in the conversation
|
||||
|
||||
Raises:
|
||||
HTTPException: If conversation metadata not found
|
||||
"""
|
||||
|
||||
# Verify the conversation belongs to the user
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id,
|
||||
StoredConversationMetadataSaas.user_id == user_id,
|
||||
)
|
||||
)
|
||||
metadata = result.scalars().first()
|
||||
if not metadata:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Conversation {conversation_id} not found',
|
||||
)
|
||||
|
||||
# Create an event store to access the events directly
|
||||
# This works even when the conversation is not running
|
||||
event_store = EventStore(
|
||||
sid=conversation_id,
|
||||
file_store=file_store,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Get events from the event store
|
||||
events = event_store.search_events(start_id=0)
|
||||
|
||||
# Return list of event IDs
|
||||
return [event.id for event in events]
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
conversation_id: str
|
||||
event_id: Optional[int] = None
|
||||
rating: int = Field(..., ge=1, le=5)
|
||||
reason: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.post('/conversation', status_code=status.HTTP_201_CREATED)
|
||||
async def submit_conversation_feedback(feedback: FeedbackRequest):
|
||||
"""
|
||||
Submit feedback for a conversation.
|
||||
|
||||
This endpoint accepts a rating (1-5) and optional reason for the feedback.
|
||||
The feedback is associated with a specific conversation and optionally a specific event.
|
||||
"""
|
||||
# Validate rating is between 1 and 5
|
||||
if feedback.rating < 1 or feedback.rating > 5:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Rating must be between 1 and 5',
|
||||
)
|
||||
|
||||
# Create new feedback record
|
||||
new_feedback = ConversationFeedback(
|
||||
conversation_id=feedback.conversation_id,
|
||||
event_id=feedback.event_id,
|
||||
rating=feedback.rating,
|
||||
reason=feedback.reason,
|
||||
metadata=feedback.metadata,
|
||||
)
|
||||
|
||||
# Add to database
|
||||
async with a_session_maker() as session:
|
||||
session.add(new_feedback)
|
||||
await session.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Feedback submitted successfully'}
|
||||
|
||||
|
||||
@router.get('/conversation/{conversation_id}/batch')
|
||||
async def get_batch_feedback(conversation_id: str, user_id: str = Depends(get_user_id)):
|
||||
"""
|
||||
Get feedback for all events in a conversation.
|
||||
|
||||
Returns feedback status for each event, including whether feedback exists
|
||||
and if so, the rating and reason.
|
||||
"""
|
||||
# Get all event IDs for the conversation
|
||||
event_ids = await get_event_ids(conversation_id, user_id)
|
||||
if not event_ids:
|
||||
return {}
|
||||
|
||||
# Query for existing feedback for all events
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ConversationFeedback).where(
|
||||
ConversationFeedback.conversation_id == conversation_id,
|
||||
ConversationFeedback.event_id.in_(event_ids),
|
||||
)
|
||||
)
|
||||
|
||||
# Create a mapping of event_id to feedback
|
||||
feedback_map = {
|
||||
feedback.event_id: {
|
||||
'exists': True,
|
||||
'rating': feedback.rating,
|
||||
'reason': feedback.reason,
|
||||
}
|
||||
for feedback in result.scalars()
|
||||
}
|
||||
|
||||
# Build response including all events
|
||||
response = {}
|
||||
for event_id in event_ids:
|
||||
response[str(event_id)] = feedback_map.get(event_id, {'exists': False})
|
||||
|
||||
return response
|
||||
@@ -3,8 +3,8 @@ import os
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import NpxStdioTransport
|
||||
|
||||
from openhands.app_server.mcp.mcp_router import mcp_server
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.routes.mcp import mcp_server
|
||||
|
||||
ENABLE_MCP_SEARCH_ENGINE = (
|
||||
os.getenv('ENABLE_MCP_SEARCH_ENGINE', 'false').lower() == 'true'
|
||||
|
||||
@@ -180,6 +180,18 @@ async def device_token(device_code: str = Form(...)):
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'authorized':
|
||||
# Verify user_id is set (should always be true for authorized status)
|
||||
if not device_code_entry.keycloak_user_id:
|
||||
logger.error(
|
||||
'Authorized device code missing user_id',
|
||||
extra={'user_code': device_code_entry.user_code},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'User identification missing',
|
||||
)
|
||||
|
||||
# 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})'
|
||||
|
||||
@@ -162,7 +162,6 @@ class OrgResponse(BaseModel):
|
||||
search_api_key: str | None = None
|
||||
sandbox_api_key: str | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
enable_solvability_analysis: bool | None = None
|
||||
v1_enabled: bool | None = None
|
||||
credits: float | None = None
|
||||
is_personal: bool = False
|
||||
@@ -195,7 +194,6 @@ class OrgResponse(BaseModel):
|
||||
search_api_key=None,
|
||||
sandbox_api_key=None,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
v1_enabled=org.v1_enabled,
|
||||
credits=credits,
|
||||
is_personal=str(org.id) == user_id if user_id else False,
|
||||
@@ -213,10 +211,9 @@ class OrgPage(BaseModel):
|
||||
class OrgUpdate(BaseModel):
|
||||
"""Request model for updating an organization.
|
||||
|
||||
``agent_settings`` and ``conversation_settings`` match the wire format
|
||||
the frontend already uses for ``OrgLLMSettingsUpdate``; they're
|
||||
applied to the org row as partial/diff patches via ``deep_merge`` in
|
||||
``OrgStore.update_org``.
|
||||
``agent_settings_diff`` and ``conversation_settings_diff`` are sparse diffs
|
||||
that are deep-merged into the org row and then validated as full settings
|
||||
before persistence.
|
||||
"""
|
||||
|
||||
name: Annotated[
|
||||
@@ -233,15 +230,146 @@ class OrgUpdate(BaseModel):
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
sandbox_api_key: str | None = None
|
||||
max_budget_per_task: float | None = Field(default=None, gt=0)
|
||||
enable_solvability_analysis: bool | None = None
|
||||
v1_enabled: bool | None = None
|
||||
search_api_key: str | None = None
|
||||
agent_settings: dict[str, Any] | None = None
|
||||
conversation_settings: dict[str, Any] | None = None
|
||||
llm_api_key: str | None = None
|
||||
agent_settings_diff: dict[str, Any] | None = None
|
||||
conversation_settings_diff: dict[str, Any] | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _normalize_settings_diffs(self) -> 'OrgUpdate':
|
||||
"""Normalize sparse settings diffs before merge/persistence."""
|
||||
self._normalize_agent_settings_diff()
|
||||
self._cleanup_empty_diff('agent_settings_diff', nested_key='llm')
|
||||
self._cleanup_empty_diff('conversation_settings_diff')
|
||||
return self
|
||||
|
||||
def _normalize_agent_settings_diff(self) -> None:
|
||||
"""Normalize nested LLM settings inside ``agent_settings_diff``."""
|
||||
llm_diff = self._get_agent_llm_diff()
|
||||
if llm_diff is None:
|
||||
return
|
||||
|
||||
self._lift_and_mask_llm_api_key(llm_diff)
|
||||
self._resolve_agent_llm_base_url(llm_diff)
|
||||
|
||||
def _get_agent_llm_diff(self) -> dict[str, Any] | None:
|
||||
"""Return the nested ``llm`` diff when present and dictionary-shaped."""
|
||||
if self.agent_settings_diff is None:
|
||||
return None
|
||||
llm_diff = self.agent_settings_diff.get('llm')
|
||||
return llm_diff if isinstance(llm_diff, dict) else None
|
||||
|
||||
def _lift_and_mask_llm_api_key(self, llm_diff: dict[str, Any]) -> None:
|
||||
"""Lift nested api keys to ``llm_api_key`` and mask the JSON diff."""
|
||||
if 'api_key' not in llm_diff:
|
||||
return
|
||||
|
||||
nested_key = llm_diff.pop('api_key')
|
||||
if (
|
||||
self.llm_api_key is None
|
||||
and nested_key is not None
|
||||
and nested_key != MASKED_API_KEY
|
||||
):
|
||||
self.llm_api_key = nested_key
|
||||
if nested_key is not None:
|
||||
llm_diff['api_key'] = MASKED_API_KEY
|
||||
|
||||
def _resolve_agent_llm_base_url(self, llm_diff: dict[str, Any]) -> None:
|
||||
"""Fill provider-default base URLs for sparse LLM diffs when needed."""
|
||||
resolved_base_url = resolve_llm_base_url(
|
||||
model=llm_diff.get('model'),
|
||||
base_url=llm_diff.get('base_url'),
|
||||
managed_proxy_url=LITE_LLM_API_URL,
|
||||
)
|
||||
if resolved_base_url is not None:
|
||||
llm_diff['base_url'] = resolved_base_url
|
||||
|
||||
def _cleanup_empty_diff(
|
||||
self,
|
||||
field_name: str,
|
||||
nested_key: str | None = None,
|
||||
) -> None:
|
||||
"""Drop empty nested diffs and collapse empty diff payloads to ``None``."""
|
||||
settings_diff = getattr(self, field_name)
|
||||
if not isinstance(settings_diff, dict):
|
||||
if not settings_diff:
|
||||
setattr(self, field_name, None)
|
||||
return
|
||||
|
||||
if nested_key is not None and not settings_diff.get(nested_key):
|
||||
settings_diff.pop(nested_key, None)
|
||||
if not settings_diff:
|
||||
setattr(self, field_name, None)
|
||||
|
||||
def updated_fields(self) -> set[str]:
|
||||
"""Return the public field names explicitly present on the update."""
|
||||
return {
|
||||
field
|
||||
for field in type(self).model_fields
|
||||
if getattr(self, field) is not None
|
||||
}
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any public update field is set (not None)."""
|
||||
return bool(self.updated_fields())
|
||||
|
||||
def touches_org_defaults(self) -> bool:
|
||||
"""Whether this update touches shared organization defaults."""
|
||||
return bool(
|
||||
self.updated_fields()
|
||||
& {
|
||||
'agent_settings_diff',
|
||||
'conversation_settings_diff',
|
||||
'search_api_key',
|
||||
'llm_api_key',
|
||||
}
|
||||
)
|
||||
|
||||
def restricted_fields(self) -> set[str]:
|
||||
"""Return fields that require elevated org settings permissions."""
|
||||
return self.updated_fields() & {
|
||||
'agent_settings_diff',
|
||||
'conversation_settings_diff',
|
||||
'search_api_key',
|
||||
'sandbox_api_key',
|
||||
'llm_api_key',
|
||||
}
|
||||
|
||||
def model_update_dict(self) -> dict[str, Any]:
|
||||
"""Return JSON-serializable scalar fields for persistence."""
|
||||
return self.model_dump(
|
||||
mode='json',
|
||||
exclude_none=True,
|
||||
exclude={'agent_settings_diff', 'conversation_settings_diff'},
|
||||
)
|
||||
|
||||
def apply_to_org(self, org: Org) -> None:
|
||||
"""Apply non-settings fields directly to the organization model."""
|
||||
for key, value in self.model_update_dict().items():
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
def get_member_updates(self) -> 'OrgMemberSettingsUpdate | None':
|
||||
"""Get shared updates that need to be propagated to org members.
|
||||
|
||||
An empty ``llm_api_key`` means the org-wide custom key is being cleared
|
||||
(e.g. owner switching to a managed/OpenHands provider). It must not
|
||||
land in member rows — ``OrgMember.llm_api_key``'s setter has no
|
||||
``if raw else None`` guard because the column is ``nullable=False``,
|
||||
so an empty string would become an encrypted empty blob rather than a
|
||||
cleared value. Coerce ``""`` to ``None`` so member rows are untouched.
|
||||
"""
|
||||
member_settings = OrgMemberSettingsUpdate(
|
||||
agent_settings_diff=self.agent_settings_diff,
|
||||
conversation_settings_diff=self.conversation_settings_diff,
|
||||
llm_api_key=self.llm_api_key or None,
|
||||
)
|
||||
return member_settings if member_settings.has_updates() else None
|
||||
|
||||
|
||||
class OrgLLMSettingsResponse(BaseModel):
|
||||
"""Response model for organization default LLM settings."""
|
||||
class OrgDefaultsSettingsResponse(BaseModel):
|
||||
"""Response model for organization default settings."""
|
||||
|
||||
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
|
||||
conversation_settings: ConversationSettings = Field(
|
||||
@@ -263,7 +391,7 @@ class OrgLLMSettingsResponse(BaseModel):
|
||||
return '****' + raw[-4:]
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
|
||||
def from_org(cls, org: Org) -> 'OrgDefaultsSettingsResponse':
|
||||
"""Create response from Org entity.
|
||||
|
||||
Denormalizes the SDK's ``litellm_proxy/`` prefix back to
|
||||
@@ -316,8 +444,8 @@ class OrgLLMSettingsResponse(BaseModel):
|
||||
llm.api_key = None
|
||||
|
||||
|
||||
class OrgMemberLLMSettings(BaseModel):
|
||||
"""Shared LLM settings that may be propagated to organization members.
|
||||
class OrgMemberSettingsUpdate(BaseModel):
|
||||
"""Shared settings updates that may be propagated to organization members.
|
||||
|
||||
``llm_api_key`` is typed as ``SecretStr`` so the raw value never ends up
|
||||
in logs or ``model_dump(mode='json')`` output by accident — the
|
||||
@@ -325,7 +453,7 @@ class OrgMemberLLMSettings(BaseModel):
|
||||
directly and unwraps via ``get_secret_value()``.
|
||||
|
||||
``has_custom_llm_api_key`` propagates through
|
||||
``update_all_members_llm_settings_async`` so an org-defaults save can
|
||||
``update_all_members_settings_async`` so an org-defaults save can
|
||||
reset every member's "I have a personal BYOR key" flag in one pass —
|
||||
managed-mode switches rely on this to stop load-time fallthrough from
|
||||
returning stale custom markers.
|
||||
@@ -343,129 +471,6 @@ class OrgMemberLLMSettings(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class OrgLLMSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization LLM settings.
|
||||
|
||||
``agent_settings`` and ``conversation_settings`` are applied to the org
|
||||
as partial/diff patches via ``deep_merge`` and are also propagated to
|
||||
each member's stored diff so stale member overrides don't mask the new
|
||||
org defaults.
|
||||
"""
|
||||
|
||||
agent_settings: dict[str, Any] | None = None
|
||||
conversation_settings: dict[str, Any] | None = None
|
||||
search_api_key: str | None = None
|
||||
llm_api_key: str | None = None
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _normalize_agent_settings(self) -> 'OrgLLMSettingsUpdate':
|
||||
"""Normalize ``agent_settings`` so post-save stored state stays
|
||||
consistent between the org row, every member row, and the encrypted
|
||||
``_llm_api_key`` column.
|
||||
|
||||
Two jobs:
|
||||
|
||||
* **Lift ``llm.api_key`` and mask it in the JSON.** The frontend
|
||||
posts the raw key nested inside ``agent_settings``. Leaving it
|
||||
nested would push a raw secret into the ``org.agent_settings``
|
||||
JSON column while ``org._llm_api_key`` (the encrypted column read
|
||||
by ``_get_effective_llm_api_key`` at load time) stays stale. We
|
||||
move the raw value up to ``self.llm_api_key`` (for the encrypted
|
||||
column) and leave a universal ``MASKED_API_KEY`` marker in the
|
||||
JSON. That marker then propagates through ``deep_merge`` into
|
||||
``org.agent_settings.llm.api_key`` and through
|
||||
``get_member_updates`` into every member's
|
||||
``agent_settings_diff.llm.api_key`` — matching the convention
|
||||
``SaasSettingsStore.store`` already follows via
|
||||
``model_dump(mode='json')``.
|
||||
|
||||
* **Fill ``llm.base_url`` for OpenHands / managed models.** The
|
||||
basic-view payload sends ``base_url: null`` when the user picks
|
||||
the OpenHands provider. ``deep_merge`` treats ``None`` as "delete
|
||||
this key," which would leave ``org.agent_settings.llm`` without a
|
||||
``base_url`` (and the frontend then can't tell which provider is
|
||||
configured — see the empty basic-view dropdowns). Substitute the
|
||||
managed LiteLLM proxy URL so the stored state is complete and
|
||||
self-describing.
|
||||
"""
|
||||
if self.agent_settings is None:
|
||||
return self
|
||||
llm = self.agent_settings.get('llm')
|
||||
if not isinstance(llm, dict):
|
||||
return self
|
||||
|
||||
if 'api_key' in llm:
|
||||
nested_key = llm.pop('api_key')
|
||||
# Don't re-lift the masked placeholder — the frontend echoes
|
||||
# it back when the user saves without editing the api_key
|
||||
# field. Treating it as a raw key would encrypt ``**********``
|
||||
# into the column and nuke the real key.
|
||||
if (
|
||||
self.llm_api_key is None
|
||||
and nested_key is not None
|
||||
and nested_key != MASKED_API_KEY
|
||||
):
|
||||
self.llm_api_key = nested_key
|
||||
if nested_key is not None:
|
||||
# Keep the JSON in sync with the encrypted column — both
|
||||
# ``org.agent_settings.llm.api_key`` and every member's
|
||||
# ``agent_settings_diff.llm.api_key`` will carry this marker
|
||||
# after ``deep_merge`` / propagation. An empty string still
|
||||
# gets the marker: the rotation step that runs after the
|
||||
# store update will write a freshly generated managed key
|
||||
# into the column, so "masked" in the JSON still reflects
|
||||
# reality by end of transaction.
|
||||
llm['api_key'] = MASKED_API_KEY
|
||||
|
||||
# Auto-fill ``base_url`` when the wire payload sends ``null``
|
||||
# (basic-view pattern). ``resolve_llm_base_url`` is shared with
|
||||
# ``_post_merge_llm_fixups`` in the personal-settings router so
|
||||
# both save paths agree on "this provider uses this base URL."
|
||||
resolved_base_url = resolve_llm_base_url(
|
||||
model=llm.get('model'),
|
||||
base_url=llm.get('base_url'),
|
||||
managed_proxy_url=LITE_LLM_API_URL,
|
||||
)
|
||||
if resolved_base_url is not None:
|
||||
llm['base_url'] = resolved_base_url
|
||||
|
||||
if not llm:
|
||||
self.agent_settings.pop('llm', None)
|
||||
if not self.agent_settings:
|
||||
self.agent_settings = None
|
||||
return self
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(
|
||||
getattr(self, field) is not None for field in type(self).model_fields
|
||||
)
|
||||
|
||||
def apply_to_org(self, org: Org) -> None:
|
||||
"""Apply non-None settings to the organization model."""
|
||||
if self.search_api_key is not None:
|
||||
org.search_api_key = self.search_api_key or None
|
||||
if self.llm_api_key is not None:
|
||||
org.llm_api_key = self.llm_api_key or None
|
||||
|
||||
def get_member_updates(self) -> OrgMemberLLMSettings | None:
|
||||
"""Get updates that need to be propagated to org members.
|
||||
|
||||
An empty ``llm_api_key`` means the org‑wide custom key is being cleared
|
||||
(e.g. owner switching to a managed/OpenHands provider). It must not
|
||||
land in member rows — ``OrgMember.llm_api_key``'s setter has no
|
||||
``if raw else None`` guard because the column is ``nullable=False``,
|
||||
so an empty string would become an encrypted empty blob rather than a
|
||||
cleared value. Coerce ``""`` to ``None`` so member rows are untouched.
|
||||
"""
|
||||
member_settings = OrgMemberLLMSettings(
|
||||
agent_settings_diff=self.agent_settings,
|
||||
conversation_settings_diff=self.conversation_settings,
|
||||
llm_api_key=self.llm_api_key or None,
|
||||
)
|
||||
return member_settings if member_settings.has_updates() else None
|
||||
|
||||
|
||||
class OrgMemberResponse(BaseModel):
|
||||
"""Response model for a single organization member."""
|
||||
|
||||
@@ -545,7 +550,6 @@ class OrgAppSettingsResponse(BaseModel):
|
||||
"""Response model for organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool = True
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@classmethod
|
||||
@@ -562,7 +566,6 @@ class OrgAppSettingsResponse(BaseModel):
|
||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
|
||||
if org.enable_proactive_conversation_starters is not None
|
||||
else True,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
)
|
||||
|
||||
@@ -571,7 +574,6 @@ class OrgAppSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool | None = None
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@field_validator('max_budget_per_task')
|
||||
|
||||
@@ -24,8 +24,7 @@ from server.routes.org_models import (
|
||||
OrgAuthorizationError,
|
||||
OrgCreate,
|
||||
OrgDatabaseError,
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgDefaultsSettingsResponse,
|
||||
OrgMemberFinancialPage,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
@@ -43,15 +42,12 @@ from server.services.org_app_settings_service import (
|
||||
OrgAppSettingsService,
|
||||
OrgAppSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_llm_settings_service import (
|
||||
OrgLLMSettingsService,
|
||||
OrgLLMSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_member_financial_service import OrgMemberFinancialService
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.org_store import OrgStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -60,9 +56,6 @@ from openhands.server.user_auth import get_user_id
|
||||
# Initialize API router
|
||||
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
|
||||
|
||||
# Create injector instance and dependency for LLM settings
|
||||
_org_llm_settings_injector = OrgLLMSettingsServiceInjector()
|
||||
org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends)
|
||||
# Create injector instance and dependency at module level
|
||||
_org_app_settings_injector = OrgAppSettingsServiceInjector()
|
||||
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
|
||||
@@ -228,34 +221,15 @@ async def create_org(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.VIEW_LLM_SETTINGS))],
|
||||
)
|
||||
async def get_org_llm_settings(
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for the user's current organization.
|
||||
|
||||
This endpoint retrieves the LLM configuration settings for the
|
||||
authenticated user's current organization. All organization members
|
||||
can view these settings.
|
||||
|
||||
Args:
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if not a member of any organization
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
@org_router.get('/{org_id}/settings', response_model=OrgDefaultsSettingsResponse)
|
||||
async def get_org_defaults_settings(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Get org-default settings for a specific organization."""
|
||||
try:
|
||||
return await service.get_org_llm_settings()
|
||||
org = await OrgService.get_org_by_id(org_id=org_id, user_id=user_id)
|
||||
return OrgDefaultsSettingsResponse.from_org(org)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -263,45 +237,45 @@ async def get_org_llm_settings(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error getting organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
'Error getting organization defaults settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve LLM settings',
|
||||
detail='Failed to retrieve organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.EDIT_LLM_SETTINGS))],
|
||||
)
|
||||
async def update_org_llm_settings(
|
||||
settings: OrgLLMSettingsUpdate,
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for the user's current organization.
|
||||
|
||||
This endpoint updates the LLM configuration settings for the
|
||||
authenticated user's current organization. Only admins and owners
|
||||
can update these settings.
|
||||
|
||||
Args:
|
||||
settings: The LLM settings to update (only non-None fields are updated)
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if user lacks EDIT_LLM_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
@org_router.patch('/{org_id}/settings', response_model=OrgDefaultsSettingsResponse)
|
||||
async def update_org_defaults_settings(
|
||||
org_id: UUID,
|
||||
settings: OrgUpdate,
|
||||
user_id: str = Depends(require_permission(Permission.EDIT_ORG_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Update org-default settings for a specific organization."""
|
||||
try:
|
||||
return await service.update_org_llm_settings(settings)
|
||||
allowed_fields = {
|
||||
'agent_settings_diff',
|
||||
'conversation_settings_diff',
|
||||
'search_api_key',
|
||||
'llm_api_key',
|
||||
}
|
||||
invalid_fields = settings.updated_fields() - allowed_fields
|
||||
if invalid_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
'Only organization default settings fields are supported on '
|
||||
'/api/organizations/{org_id}/settings'
|
||||
),
|
||||
)
|
||||
|
||||
updated_org = await OrgService.update_org_with_permissions(
|
||||
org_id=org_id,
|
||||
update_data=settings,
|
||||
user_id=user_id,
|
||||
)
|
||||
return OrgDefaultsSettingsResponse.from_org(updated_org)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
@@ -309,21 +283,94 @@ async def update_org_llm_settings(
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database error updating LLM settings',
|
||||
extra={'error': str(e)},
|
||||
'Database error updating organization defaults settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
detail='Failed to update organization defaults settings',
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error updating organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
'Error updating organization defaults settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
detail='Failed to update organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/llm',
|
||||
response_model=OrgDefaultsSettingsResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
async def get_legacy_org_defaults_settings(
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Get org-default settings through the deprecated ``/llm`` wrapper."""
|
||||
try:
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
return await get_org_defaults_settings(org_id=org.id, user_id=user_id)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error getting legacy organization defaults settings',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/llm',
|
||||
response_model=OrgDefaultsSettingsResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
async def update_legacy_org_defaults_settings(
|
||||
settings: OrgUpdate,
|
||||
user_id: str = Depends(require_permission(Permission.EDIT_LLM_SETTINGS)),
|
||||
) -> OrgDefaultsSettingsResponse:
|
||||
"""Update org-default settings through the deprecated ``/llm`` wrapper."""
|
||||
try:
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
if not settings.has_updates():
|
||||
return OrgDefaultsSettingsResponse.from_org(org)
|
||||
return await update_org_defaults_settings(
|
||||
org_id=org.id,
|
||||
settings=settings,
|
||||
user_id=user_id,
|
||||
)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error updating legacy organization defaults settings',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update organization defaults settings',
|
||||
)
|
||||
|
||||
|
||||
@@ -417,31 +464,17 @@ async def update_org_app_settings(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
|
||||
@org_router.get(
|
||||
'/{org_id}',
|
||||
response_model=OrgResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
deprecated=True,
|
||||
)
|
||||
async def get_org(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
|
||||
) -> OrgResponse:
|
||||
"""Get organization details by ID.
|
||||
|
||||
This endpoint retrieves details for a specific organization. Access requires
|
||||
the VIEW_ORG_SETTINGS permission, which is granted to all organization members
|
||||
(member, admin, and owner roles).
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
user_id: Authenticated user ID (injected by require_permission dependency)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The organization details
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission
|
||||
HTTPException: 404 if organization not found
|
||||
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
"""Get organization details by ID through the deprecated detail route."""
|
||||
logger.info(
|
||||
'Retrieving organization details',
|
||||
extra={
|
||||
@@ -451,15 +484,11 @@ async def get_org(
|
||||
)
|
||||
|
||||
try:
|
||||
# Use service layer to get organization with membership validation
|
||||
org = await OrgService.get_org_by_id(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Retrieve credits from LiteLLM
|
||||
credits = await OrgService.get_org_credits(user_id, org.id)
|
||||
|
||||
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from server.logger import logger
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
)
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
|
||||
|
||||
class SaaSMonitoringListener(MonitoringListener):
|
||||
"""Forward app signals to structured logging for GCP native monitoring."""
|
||||
|
||||
def on_session_event(self, event: Event) -> None:
|
||||
"""Track metrics about events being added to a Session's EventStream."""
|
||||
if (
|
||||
isinstance(event, AgentStateChangedObservation)
|
||||
and event.agent_state == AgentState.ERROR
|
||||
):
|
||||
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.
|
||||
|
||||
Success is true if startup completed without error.
|
||||
Duration is start time in seconds observed by AgentSession.
|
||||
"""
|
||||
logger.info(
|
||||
'Tracking agent session start',
|
||||
extra={
|
||||
'signal': 'saas_agent_session_start',
|
||||
'success': success,
|
||||
'duration': duration,
|
||||
},
|
||||
)
|
||||
|
||||
def on_create_conversation(self) -> None:
|
||||
"""Track the beginning of conversation creation.
|
||||
|
||||
Does not currently capture whether it succeed.
|
||||
"""
|
||||
logger.info(
|
||||
'Tracking create conversation', extra={'signal': 'saas_create_conversation'}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_instance(
|
||||
cls,
|
||||
config: OpenHandsConfig,
|
||||
) -> 'SaaSMonitoringListener':
|
||||
return cls()
|
||||
@@ -313,11 +313,22 @@ class OrgInvitationService:
|
||||
raise InvitationInvalidError('User not found')
|
||||
|
||||
user_email = user.email
|
||||
# Fallback: fetch email from Keycloak if not in database (for existing users)
|
||||
# Fallback: fetch email from Keycloak if not in database (for existing users).
|
||||
# When found, persist it back to User.email so the members list shows it
|
||||
# without requiring the user to log out and log back in.
|
||||
if not user_email:
|
||||
token_manager = TokenManager()
|
||||
user_info = await token_manager.get_user_info_from_user_id(str(user_id))
|
||||
user_email = user_info.get('email') if user_info else None
|
||||
if user_info:
|
||||
user_email = user_info.get('email')
|
||||
if user_email:
|
||||
await UserStore.backfill_user_email(
|
||||
str(user_id),
|
||||
{
|
||||
'email': user_email,
|
||||
'email_verified': user_info.get('emailVerified', False),
|
||||
},
|
||||
)
|
||||
|
||||
if not user_email:
|
||||
raise EmailMismatchError('Your account does not have an email address')
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
"""Service class for managing organization LLM settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from server.routes.org_models import (
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgMemberLLMSettings,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
|
||||
from storage.org import Org
|
||||
from storage.org_llm_settings_store import OrgLLMSettingsStore
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.llm import is_openhands_model
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsService:
|
||||
"""Service for org LLM settings with injected dependencies."""
|
||||
|
||||
store: OrgLLMSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_org_llm_settings(self) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for user's current organization.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Getting organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(org)
|
||||
|
||||
async def update_org_llm_settings(
|
||||
self,
|
||||
update_data: OrgLLMSettingsUpdate,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for user's current organization.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Updating organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Check if any fields are provided
|
||||
if not update_data.has_updates():
|
||||
# No fields to update, just return current settings
|
||||
return await self.get_org_llm_settings()
|
||||
|
||||
# Get user's current org first
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
# Update the org LLM settings
|
||||
updated_org = await self.store.update_org_llm_settings(
|
||||
org_id=org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not updated_org:
|
||||
raise OrgNotFoundError(str(org.id))
|
||||
|
||||
# Build the member-propagation payload from the update diff, then
|
||||
# let the managed-key rotation merge in a freshly generated /
|
||||
# reused managed key (OpenHands / LiteLLM proxy case). A single
|
||||
# ``update_all_members_llm_settings_async`` call at the end writes
|
||||
# the ``agent_settings_diff``, ``llm_api_key``, and a
|
||||
# ``has_custom_llm_api_key=False`` reset to every member row in one
|
||||
# pass. The flag reset is load-bearing: org-defaults saves are an
|
||||
# org-wide "use the org default" signal, so any lingering
|
||||
# "I have a personal BYOR key" marker on a member row would make
|
||||
# ``_get_effective_llm_api_key`` return the wrong key at read time.
|
||||
member_updates = update_data.get_member_updates()
|
||||
|
||||
effective_managed_key = await self._maybe_rotate_managed_llm_key_for_user(
|
||||
updated_org=updated_org,
|
||||
user_id=user_id,
|
||||
)
|
||||
if effective_managed_key is not None:
|
||||
if member_updates is None:
|
||||
member_updates = OrgMemberLLMSettings()
|
||||
member_updates.llm_api_key = SecretStr(effective_managed_key)
|
||||
|
||||
if member_updates is not None:
|
||||
member_updates.has_custom_llm_api_key = False
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
self.store.db_session,
|
||||
org.id,
|
||||
member_updates,
|
||||
)
|
||||
logger.info(
|
||||
'Propagated org LLM settings to members',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Organization LLM settings updated successfully',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(updated_org)
|
||||
|
||||
async def _maybe_rotate_managed_llm_key_for_user(
|
||||
self,
|
||||
updated_org: Org,
|
||||
user_id: str,
|
||||
) -> str | None:
|
||||
"""Return the managed LLM key every member row should carry, or
|
||||
``None`` if the org isn't in managed mode.
|
||||
|
||||
When the updated org defaults target a managed LLM (OpenHands
|
||||
provider or the LiteLLM proxy base URL), this reuses the acting
|
||||
user's (any admin or owner with ``EDIT_LLM_SETTINGS``) current
|
||||
managed key if ``verify_existing_key`` confirms it's still valid,
|
||||
otherwise generates a fresh one. The returned key is what every
|
||||
member's ``_llm_api_key`` column should hold — the caller bundles
|
||||
it into the single ``update_all_members_llm_settings_async`` call
|
||||
alongside the ``agent_settings_diff`` and a
|
||||
``has_custom_llm_api_key=False`` reset so one DB pass covers all
|
||||
three. Detection matches ``SaasSettingsStore.store`` so the two
|
||||
save paths agree on when managed mode is in play; propagation
|
||||
semantics differ because org-defaults saves are intentionally an
|
||||
org-wide operation.
|
||||
"""
|
||||
llm = (updated_org.agent_settings or {}).get('llm') or {}
|
||||
llm_model = llm.get('model')
|
||||
llm_base_url = llm.get('base_url')
|
||||
normalized_llm_base_url = llm_base_url.rstrip('/') if llm_base_url else None
|
||||
normalized_managed_base_url = LITE_LLM_API_URL.rstrip('/')
|
||||
openhands_type = is_openhands_model(llm_model)
|
||||
uses_managed_llm_key = (
|
||||
normalized_llm_base_url == normalized_managed_base_url
|
||||
or (normalized_llm_base_url is None and openhands_type)
|
||||
)
|
||||
if not uses_managed_llm_key:
|
||||
return None
|
||||
|
||||
result = await self.store.db_session.execute(
|
||||
select(OrgMember).where(
|
||||
OrgMember.org_id == updated_org.id,
|
||||
OrgMember.user_id == uuid.UUID(user_id),
|
||||
)
|
||||
)
|
||||
acting_member = result.scalars().first()
|
||||
if acting_member is None:
|
||||
# Shouldn't happen — the caller already resolved the user's
|
||||
# current org via ``get_current_org_by_user_id`` before calling
|
||||
# us, so the ``OrgMember`` row must exist. If it's missing
|
||||
# anyway, the org-wide managed-key propagation skips the
|
||||
# ``llm_api_key`` write (``effective_managed_key`` returns
|
||||
# ``None``) and members keep whatever was in their columns.
|
||||
# Log loudly so this data-consistency issue surfaces instead of
|
||||
# silently leaving stale keys on member rows.
|
||||
logger.error(
|
||||
'Acting member row not found during managed LLM key '
|
||||
'rotation; skipping managed-key propagation. Members may '
|
||||
'retain stale keys until they save personal settings.',
|
||||
extra={'user_id': user_id, 'org_id': str(updated_org.id)},
|
||||
)
|
||||
return None
|
||||
|
||||
existing_key = acting_member.llm_api_key
|
||||
existing_key_raw = existing_key.get_secret_value() if existing_key else None
|
||||
if existing_key_raw and await LiteLlmManager.verify_existing_key(
|
||||
existing_key_raw,
|
||||
user_id,
|
||||
str(updated_org.id),
|
||||
openhands_type=openhands_type,
|
||||
):
|
||||
# Reuse the acting user's still-valid managed key — no need to
|
||||
# burn a LiteLLM key rotation on a no-op save.
|
||||
effective_key = existing_key_raw
|
||||
rotated = False
|
||||
else:
|
||||
if openhands_type:
|
||||
effective_key = await LiteLlmManager.generate_key(
|
||||
user_id,
|
||||
str(updated_org.id),
|
||||
None,
|
||||
{'type': 'openhands'},
|
||||
)
|
||||
else:
|
||||
key_alias = get_openhands_cloud_key_alias(user_id, str(updated_org.id))
|
||||
await LiteLlmManager.delete_key_by_alias(key_alias=key_alias)
|
||||
effective_key = await LiteLlmManager.generate_key(
|
||||
user_id,
|
||||
str(updated_org.id),
|
||||
key_alias,
|
||||
None,
|
||||
)
|
||||
rotated = True
|
||||
|
||||
# The caller merges ``effective_key`` into ``member_updates`` and
|
||||
# issues a single ``update_all_members_llm_settings_async`` call
|
||||
# that writes the key column AND resets ``has_custom_llm_api_key``
|
||||
# on every member — including this acting row — so we don't touch
|
||||
# the row directly here.
|
||||
|
||||
if rotated:
|
||||
logger.info(
|
||||
'Generated managed LLM key for acting user on org-defaults save',
|
||||
extra={'user_id': user_id, 'org_id': str(updated_org.id)},
|
||||
)
|
||||
|
||||
return effective_key
|
||||
|
||||
|
||||
class OrgLLMSettingsServiceInjector(Injector[OrgLLMSettingsService]):
|
||||
"""Injector that composes store and user_context for OrgLLMSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[OrgLLMSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = OrgLLMSettingsStore(db_session=db_session)
|
||||
yield OrgLLMSettingsService(store=store, user_context=user_context)
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
# 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
|
||||
# from openhands.app_server.app_conversation.app_conversation_models import ConversationTrigger
|
||||
# For now, use Any to avoid import issues
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -26,6 +26,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.agent_server.utils import utc_now
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
StoredConversationMetadata,
|
||||
)
|
||||
@@ -146,9 +147,15 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def _fix_timezone(self, value: datetime) -> datetime:
|
||||
def _fix_timezone(self, value: datetime | None) -> datetime:
|
||||
"""Sqlite does not store timezones - and since we can't update the existing models
|
||||
we assume UTC if the timezone is missing."""
|
||||
we assume UTC if the timezone is missing. Returns current UTC time if value is None.
|
||||
"""
|
||||
if value is None:
|
||||
# Fallback for legacy data: use current time to match model defaults.
|
||||
# The DB columns have default=utc_now, so None only occurs in legacy records.
|
||||
# Using utc_now() keeps the API model non-nullable and matches new record behavior.
|
||||
return utc_now()
|
||||
if not value.tzinfo:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
|
||||
from server.logger import logger
|
||||
from sqlalchemy import and_, select
|
||||
from storage.conversation_callback import (
|
||||
CallbackStatus,
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
from storage.conversation_work import ConversationWork
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_from_dict
|
||||
from openhands.server.services.conversation_stats import ConversationStats
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_agent_state_filename,
|
||||
get_conversation_dir,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
config = load_openhands_config()
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
|
||||
|
||||
async def process_event(
|
||||
user_id: str, conversation_id: str, subpath: str, content: dict
|
||||
):
|
||||
"""
|
||||
Process a conversation event and invoke any registered callbacks.
|
||||
|
||||
Args:
|
||||
user_id: The user ID associated with the conversation
|
||||
conversation_id: The conversation ID
|
||||
subpath: The event subpath
|
||||
content: The event content
|
||||
"""
|
||||
logger.debug(
|
||||
'process_event',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'conversation_id': conversation_id,
|
||||
'content': content,
|
||||
},
|
||||
)
|
||||
write_path = get_conversation_dir(conversation_id, user_id) + subpath
|
||||
|
||||
# This writes to the google cloud storage, so we do this in a background thread to not block the main runloop...
|
||||
await call_sync_from_async(file_store.write, write_path, json.dumps(content))
|
||||
|
||||
event = event_from_dict(content)
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
# Load and invoke all active callbacks for this conversation
|
||||
await invoke_conversation_callbacks(conversation_id, event)
|
||||
|
||||
# Update active working seconds if agent state is not Running
|
||||
if event.agent_state != AgentState.RUNNING:
|
||||
event_store = EventStore(conversation_id, file_store, user_id)
|
||||
update_active_working_seconds(
|
||||
event_store, conversation_id, user_id, file_store
|
||||
)
|
||||
|
||||
|
||||
async def invoke_conversation_callbacks(
|
||||
conversation_id: str, observation: AgentStateChangedObservation
|
||||
):
|
||||
"""
|
||||
Load and invoke all active callbacks for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to process callbacks for
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ConversationCallback).filter(
|
||||
and_(
|
||||
ConversationCallback.conversation_id == conversation_id,
|
||||
ConversationCallback.status == CallbackStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
)
|
||||
callbacks = result.scalars().all()
|
||||
|
||||
for callback in callbacks:
|
||||
try:
|
||||
processor = callback.get_processor()
|
||||
await processor.__call__(callback, observation)
|
||||
logger.info(
|
||||
'callback_invoked_successfully',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'callback_id': callback.id,
|
||||
'processor_type': callback.processor_type,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'callback_invocation_failed',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'callback_id': callback.id,
|
||||
'processor_type': callback.processor_type,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Mark callback as error status
|
||||
callback.status = CallbackStatus.ERROR
|
||||
callback.updated_at = datetime.now()
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
def update_conversation_metadata(conversation_id: str, content: dict):
|
||||
"""
|
||||
Update conversation metadata with new content.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to update
|
||||
content: The metadata content to update
|
||||
"""
|
||||
logger.debug(
|
||||
'update_conversation_metadata',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'content': content,
|
||||
},
|
||||
)
|
||||
with session_maker() as session:
|
||||
conversation = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
conversation.title = content.get('title') or conversation.title
|
||||
conversation.last_updated_at = datetime.now()
|
||||
conversation.accumulated_cost = (
|
||||
content.get('accumulated_cost') or conversation.accumulated_cost
|
||||
)
|
||||
conversation.prompt_tokens = (
|
||||
content.get('prompt_tokens') or conversation.prompt_tokens
|
||||
)
|
||||
conversation.completion_tokens = (
|
||||
content.get('completion_tokens') or conversation.completion_tokens
|
||||
)
|
||||
conversation.total_tokens = (
|
||||
content.get('total_tokens') or conversation.total_tokens
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def register_callback_processor(
|
||||
conversation_id: str, processor: ConversationCallbackProcessor
|
||||
) -> int:
|
||||
"""
|
||||
Register a callback processor for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to register the callback for
|
||||
processor: The ConversationCallbackProcessor instance to register
|
||||
|
||||
Returns:
|
||||
int: The ID of the created callback
|
||||
"""
|
||||
with session_maker() as session:
|
||||
callback = ConversationCallback(
|
||||
conversation_id=conversation_id, status=CallbackStatus.ACTIVE
|
||||
)
|
||||
callback.set_processor(processor)
|
||||
session.add(callback)
|
||||
session.commit()
|
||||
return callback.id
|
||||
|
||||
|
||||
def update_active_working_seconds(
|
||||
event_store: EventStore, conversation_id: str, user_id: str, file_store: FileStore
|
||||
):
|
||||
"""
|
||||
Calculate and update the total active working seconds for a conversation.
|
||||
|
||||
This function reads all events for the conversation, looks for AgentStateChanged
|
||||
observations, and calculates the total time spent in a running state.
|
||||
|
||||
Args:
|
||||
event_store: The EventStore instance for reading events
|
||||
conversation_id: The conversation ID to process
|
||||
user_id: The user ID associated with the conversation
|
||||
file_store: The FileStore instance for accessing conversation data
|
||||
"""
|
||||
try:
|
||||
# Track agent state changes and calculate running time
|
||||
running_start_time = None
|
||||
total_running_seconds = 0.0
|
||||
|
||||
for event in event_store.search_events():
|
||||
if isinstance(event, AgentStateChangedObservation) and event.timestamp:
|
||||
event_timestamp = datetime.fromisoformat(event.timestamp).timestamp()
|
||||
|
||||
if event.agent_state == AgentState.RUNNING:
|
||||
# Agent started running
|
||||
if running_start_time is None:
|
||||
running_start_time = event_timestamp
|
||||
elif running_start_time is not None:
|
||||
# Agent stopped running, calculate duration
|
||||
duration = event_timestamp - running_start_time
|
||||
total_running_seconds += duration
|
||||
running_start_time = None
|
||||
|
||||
# If agent is still running at the end, don't count that time yet
|
||||
# (it will be counted when the agent stops)
|
||||
|
||||
# Create or update the conversation_work record
|
||||
with session_maker() as session:
|
||||
conversation_work = (
|
||||
session.query(ConversationWork)
|
||||
.filter(ConversationWork.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if conversation_work:
|
||||
# Update existing record
|
||||
conversation_work.seconds = total_running_seconds
|
||||
conversation_work.updated_at = datetime.now().isoformat()
|
||||
else:
|
||||
# Create new record
|
||||
conversation_work = ConversationWork(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
seconds=total_running_seconds,
|
||||
)
|
||||
session.add(conversation_work)
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'updated_active_working_seconds',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'total_seconds': total_running_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'failed_to_update_active_working_seconds',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def update_agent_state(user_id: str, conversation_id: str, content: bytes):
|
||||
"""
|
||||
Update agent state file for a conversation.
|
||||
|
||||
Args:
|
||||
user_id: The user ID associated with the conversation
|
||||
conversation_id: The conversation ID
|
||||
content: The agent state content as bytes
|
||||
"""
|
||||
logger.debug(
|
||||
'update_agent_state',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'conversation_id': conversation_id,
|
||||
'content_size': len(content),
|
||||
},
|
||||
)
|
||||
write_path = get_conversation_agent_state_filename(conversation_id, user_id)
|
||||
file_store.write(write_path, content)
|
||||
|
||||
|
||||
def update_conversation_stats(user_id: str, conversation_id: str, content: bytes):
|
||||
existing_convo_stats = ConversationStats(
|
||||
file_store=file_store, conversation_id=conversation_id, user_id=user_id
|
||||
)
|
||||
|
||||
incoming_convo_stats = ConversationStats(None, conversation_id, None)
|
||||
pickled = base64.b64decode(content)
|
||||
incoming_convo_stats.restored_metrics = pickle.loads(pickled)
|
||||
|
||||
# Merging automatically saves to file store
|
||||
existing_convo_stats.merge_and_save(incoming_convo_stats)
|
||||
@@ -5,7 +5,7 @@ from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import ColumnElement, func, select
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
@@ -242,7 +242,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
):
|
||||
"""Apply filters to query that includes SAAS metadata."""
|
||||
# Apply the same filters as the base class
|
||||
conditions = []
|
||||
conditions: list[ColumnElement[bool]] = []
|
||||
if title__contains is not None:
|
||||
conditions.append(
|
||||
StoredConversationMetadata.title.like(f'%{title__contains}%')
|
||||
@@ -350,8 +350,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
# Convert string user_id to UUID
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
user_query = select(User).where(User.id == user_id_uuid)
|
||||
result = await self.db_session.execute(user_query)
|
||||
user = result.scalar_one_or_none()
|
||||
user_result = await self.db_session.execute(user_query)
|
||||
user = user_result.scalar_one_or_none()
|
||||
assert user
|
||||
|
||||
# Determine org_id: prefer API key's org_id if authenticated via API key
|
||||
@@ -372,8 +372,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(info.id)
|
||||
)
|
||||
result = await self.db_session.execute(saas_query)
|
||||
existing_saas_metadata = result.scalar_one_or_none()
|
||||
saas_result = await self.db_session.execute(saas_query)
|
||||
existing_saas_metadata = saas_result.scalar_one_or_none()
|
||||
assert existing_saas_metadata is None or (
|
||||
existing_saas_metadata.user_id == user_id_uuid
|
||||
and existing_saas_metadata.org_id == org_id
|
||||
|
||||
@@ -16,7 +16,7 @@ from server.verified_models.verified_model_service import (
|
||||
)
|
||||
|
||||
from openhands.app_server.config import get_db_session
|
||||
from openhands.server.routes import public
|
||||
from openhands.app_server.config_api.config_router import get_llm_models_dependency
|
||||
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
|
||||
|
||||
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
|
||||
@@ -138,6 +138,4 @@ async def get_saas_llm_models_dependency(request: Request) -> ModelsResponse:
|
||||
# This must be called after the app is created in saas_server.py
|
||||
def override_llm_models_dependency(app):
|
||||
"""Override the default LLM models implementation with SaaS version."""
|
||||
app.dependency_overrides[public.get_llm_models_dependency] = (
|
||||
get_saas_llm_models_dependency
|
||||
)
|
||||
app.dependency_overrides[get_llm_models_dependency] = get_saas_llm_models_dependency
|
||||
|
||||
@@ -138,7 +138,8 @@ class VerifiedModelService:
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
stored = result.scalars().first()
|
||||
return verified_model(stored) if stored else None
|
||||
|
||||
async def create_verified_model(
|
||||
self,
|
||||
|
||||
@@ -2,7 +2,6 @@ from storage.api_key import ApiKey
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.billing_session_type import BillingSessionType
|
||||
from storage.conversation_callback import CallbackStatus, ConversationCallback
|
||||
from storage.conversation_work import ConversationWork
|
||||
from storage.feedback import ConversationFeedback, Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
@@ -45,8 +44,6 @@ __all__ = [
|
||||
'AuthTokens',
|
||||
'BillingSession',
|
||||
'BillingSessionType',
|
||||
'CallbackStatus',
|
||||
'ConversationCallback',
|
||||
'ConversationFeedback',
|
||||
'StoredConversationMetadataSaas',
|
||||
'ConversationWork',
|
||||
|
||||
@@ -3,7 +3,7 @@ Unified SQLAlchemy declarative base for all models.
|
||||
|
||||
Re-exports the core Base to ensure enterprise and core models share the same
|
||||
metadata registry. This allows foreign key relationships between enterprise
|
||||
models (e.g., ConversationCallback) and core models (e.g., StoredConversationMetadata).
|
||||
models and core models (e.g., StoredConversationMetadata).
|
||||
|
||||
The core Base now uses SQLAlchemy 2.0 DeclarativeBase for proper type inference
|
||||
with Mapped types, while remaining backward compatible with existing Column()
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, text
|
||||
from sqlalchemy import Enum as SQLEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from storage.base import Base
|
||||
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class ConversationCallbackProcessor(BaseModel, ABC):
|
||||
"""
|
||||
Abstract base class for conversation callback processors.
|
||||
|
||||
Conversation processors are invoked when events occur in a conversation
|
||||
to perform additional processing, notifications, or integrations.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
# Allow extra fields for flexibility
|
||||
extra='allow',
|
||||
# Allow arbitrary types
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: 'AgentStateChangedObservation',
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to process
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
callback: The conversation callback
|
||||
"""
|
||||
|
||||
|
||||
class CallbackStatus(Enum):
|
||||
"""Status of a conversation callback."""
|
||||
|
||||
ACTIVE = 'ACTIVE'
|
||||
COMPLETED = 'COMPLETED'
|
||||
ERROR = 'ERROR'
|
||||
|
||||
|
||||
class ConversationCallback(Base):
|
||||
"""
|
||||
Model for storing conversation callbacks that process conversation events.
|
||||
"""
|
||||
|
||||
__tablename__ = 'conversation_callbacks'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String,
|
||||
ForeignKey('conversation_metadata.conversation_id'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[CallbackStatus] = mapped_column(
|
||||
SQLEnum(CallbackStatus), nullable=False, default=CallbackStatus.ACTIVE
|
||||
)
|
||||
processor_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
processor_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
onupdate=datetime.now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def get_processor(self) -> ConversationCallbackProcessor:
|
||||
"""
|
||||
Get the processor instance from the stored processor type and JSON data.
|
||||
|
||||
Returns:
|
||||
ConversationCallbackProcessor: The processor instance
|
||||
"""
|
||||
# Import the processor class dynamically
|
||||
processor_class: type[ConversationCallbackProcessor] = get_impl(
|
||||
ConversationCallbackProcessor, self.processor_type
|
||||
)
|
||||
processor = processor_class.model_validate_json(self.processor_json)
|
||||
return processor
|
||||
|
||||
def set_processor(self, processor: ConversationCallbackProcessor) -> None:
|
||||
"""
|
||||
Set the processor instance, storing its type and JSON representation.
|
||||
|
||||
Args:
|
||||
processor: The ConversationCallbackProcessor instance to store
|
||||
"""
|
||||
self.processor_type = (
|
||||
f'{processor.__class__.__module__}.{processor.__class__.__name__}'
|
||||
)
|
||||
self.processor_json = processor.model_dump_json()
|
||||
@@ -1,10 +1,14 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
from base64 import b64decode, b64encode
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pydantic import SecretStr
|
||||
from server.config import get_config
|
||||
from sqlalchemy import String, TypeDecorator
|
||||
from sqlalchemy.engine.interfaces import Dialect
|
||||
|
||||
_jwt_service = None
|
||||
_fernet = None
|
||||
@@ -135,3 +139,31 @@ def model_to_kwargs(model_instance):
|
||||
column.name: getattr(model_instance, column.name)
|
||||
for column in model_instance.__table__.columns
|
||||
}
|
||||
|
||||
|
||||
class EncryptedJSON(TypeDecorator[dict[str, Any]]):
|
||||
"""JSON column whose serialized payload is encrypted at rest.
|
||||
|
||||
Use for JSON dicts that may contain secrets (e.g. nested ``api_key``
|
||||
fields) where the existing ``_<field>`` String + property pattern is
|
||||
awkward — this keeps the column accessible as a normal ORM attribute
|
||||
while encrypting the entire JSON blob via the same JWE service used
|
||||
by ``encrypt_value``/``decrypt_value``.
|
||||
"""
|
||||
|
||||
impl = String
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(
|
||||
self, value: dict[str, Any] | None, dialect: Dialect
|
||||
) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return encrypt_value(json.dumps(value))
|
||||
|
||||
def process_result_value(
|
||||
self, value: str | None, dialect: Dialect
|
||||
) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.loads(decrypt_value(value))
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from integrations.types import GitLabResourceType
|
||||
from sqlalchemy import and_, asc, select, text, update
|
||||
from sqlalchemy import and_, asc, delete, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from storage.database import a_session_maker
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
@@ -25,6 +25,8 @@ class GitlabWebhookStore:
|
||||
|
||||
if webhook.group_id:
|
||||
return (GitLabResourceType.GROUP, webhook.group_id)
|
||||
# At this point, project_id must be set (we checked at least one is set above)
|
||||
assert webhook.project_id is not None
|
||||
return (GitLabResourceType.PROJECT, webhook.project_id)
|
||||
|
||||
async def store_webhooks(self, project_details: list[GitlabWebhook]) -> None:
|
||||
@@ -123,11 +125,11 @@ class GitlabWebhookStore:
|
||||
async with session.begin():
|
||||
# Create query based on the identifier provided
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
query = GitlabWebhook.__table__.delete().where(
|
||||
query = delete(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id == resource_id
|
||||
)
|
||||
else: # has_group_id must be True based on validation
|
||||
query = GitlabWebhook.__table__.delete().where(
|
||||
query = delete(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id == resource_id
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from server.constants import (
|
||||
from server.logger import logger
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
# Timeout in seconds for key verification requests to LiteLLM
|
||||
@@ -217,7 +217,7 @@ class LiteLlmManager:
|
||||
|
||||
oss_settings.update(
|
||||
{
|
||||
'agent_settings': {
|
||||
'agent_settings_diff': {
|
||||
'agent': 'CodeActAgent',
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
@@ -402,9 +402,7 @@ class LiteLlmManager:
|
||||
extra={'org_id': org_id, 'user_id': keycloak_user_id},
|
||||
)
|
||||
# Update user_settings with the new key so it gets stored in org_member
|
||||
# agent_settings is a JSON column (dict) on UserSettings
|
||||
if user_settings.agent_settings is None:
|
||||
user_settings.agent_settings = {}
|
||||
# agent_settings is a non-nullable JSON column (dict) on UserSettings
|
||||
user_settings.agent_settings.setdefault('llm', {})[
|
||||
'api_key'
|
||||
] = new_key
|
||||
|
||||
@@ -62,9 +62,6 @@ class Org(Base):
|
||||
# encrypted column, don't set directly, set without the underscore
|
||||
_sandbox_api_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
max_budget_per_task: Mapped[float | None] = mapped_column(nullable=True)
|
||||
enable_solvability_analysis: Mapped[bool | None] = mapped_column(
|
||||
nullable=True, default=False
|
||||
)
|
||||
v1_enabled: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
conversation_expiration: Mapped[int | None] = mapped_column(nullable=True)
|
||||
byor_export_enabled: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
@@ -35,10 +35,10 @@ class OrgAppSettingsStore:
|
||||
Org: The organization object, or None if not found
|
||||
"""
|
||||
# Get user with their current_org_id
|
||||
result = await self.db_session.execute(
|
||||
user_result = await self.db_session.execute(
|
||||
select(User).filter(User.id == UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
user = user_result.scalars().first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
@@ -48,8 +48,8 @@ class OrgAppSettingsStore:
|
||||
return None
|
||||
|
||||
# Get the organization
|
||||
result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
org_result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = org_result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Store class for managing organization LLM settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsStore:
|
||||
"""Store for org LLM settings with injected db_session."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
|
||||
"""Get the user's current organization.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
Org: The user's current organization, or None if not found
|
||||
"""
|
||||
# First get the user to find their current_org_id
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user or not user.current_org_id:
|
||||
return None
|
||||
|
||||
# Then get the org
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == user.current_org_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_org_llm_settings(
|
||||
self, org_id: UUID, update_data: OrgLLMSettingsUpdate
|
||||
) -> Org | None:
|
||||
"""Update organization LLM settings.
|
||||
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
org_id: The organization's ID
|
||||
update_data: Pydantic model with fields to update
|
||||
|
||||
Returns:
|
||||
Org: The updated organization, or None if org not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == org_id).with_for_update()
|
||||
)
|
||||
org = result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
update_data.apply_to_org(org)
|
||||
if update_data.agent_settings:
|
||||
org.agent_settings = deep_merge(
|
||||
org.agent_settings,
|
||||
update_data.agent_settings,
|
||||
)
|
||||
if update_data.conversation_settings:
|
||||
org.conversation_settings = deep_merge(
|
||||
org.conversation_settings,
|
||||
update_data.conversation_settings,
|
||||
)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
return org
|
||||
@@ -5,7 +5,7 @@ Store class for managing organization-member relationships.
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from server.routes.org_models import OrgMemberSettingsUpdate
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
@@ -14,7 +14,7 @@ from storage.org_member import OrgMember
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
|
||||
|
||||
@@ -237,12 +237,12 @@ class OrgMemberStore:
|
||||
return members, has_more
|
||||
|
||||
@staticmethod
|
||||
async def update_all_members_llm_settings_async(
|
||||
async def update_all_members_settings_async(
|
||||
session: AsyncSession,
|
||||
org_id: UUID,
|
||||
member_settings: OrgMemberLLMSettings,
|
||||
member_settings: OrgMemberSettingsUpdate,
|
||||
) -> None:
|
||||
"""Update shared LLM settings for all members of an organization.
|
||||
"""Update shared settings for all members of an organization.
|
||||
|
||||
Args:
|
||||
session: Database session (passed from caller for transaction)
|
||||
|
||||
@@ -24,9 +24,9 @@ from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
class OrgService:
|
||||
@@ -539,41 +539,31 @@ class OrgService:
|
||||
)
|
||||
raise OrgNameExistsError(update_data.name)
|
||||
|
||||
# Convert to dict for OrgStore (excluding None values)
|
||||
update_dict = update_data.model_dump(exclude_none=True)
|
||||
if not update_dict:
|
||||
if not update_data.has_updates():
|
||||
logger.info(
|
||||
'No fields to update',
|
||||
extra={'org_id': str(org_id), 'user_id': user_id},
|
||||
)
|
||||
return existing_org
|
||||
|
||||
restricted_fields = {
|
||||
'agent_settings',
|
||||
'conversation_settings',
|
||||
'search_api_key',
|
||||
'sandbox_api_key',
|
||||
}
|
||||
if restricted_fields.intersection(
|
||||
update_dict
|
||||
) and not await OrgService.has_admin_or_owner_role(user_id, org_id):
|
||||
restricted_fields = update_data.restricted_fields()
|
||||
if restricted_fields and not await OrgService.has_admin_or_owner_role(
|
||||
user_id, org_id
|
||||
):
|
||||
logger.warning(
|
||||
'Insufficient role for restricted organization settings update',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'restricted_fields': sorted(
|
||||
restricted_fields.intersection(update_dict)
|
||||
),
|
||||
'restricted_fields': sorted(restricted_fields),
|
||||
},
|
||||
)
|
||||
raise PermissionError(
|
||||
'Admin or owner role required to update organization agent settings'
|
||||
'Admin or owner role required to update organization default settings'
|
||||
)
|
||||
|
||||
# Perform the update
|
||||
try:
|
||||
updated_org = await OrgStore.update_org(org_id, update_dict)
|
||||
updated_org = await OrgStore.update_org(org_id, update_data, user_id)
|
||||
if not updated_org:
|
||||
raise OrgDatabaseError('Failed to update organization in database')
|
||||
|
||||
@@ -582,7 +572,7 @@ class OrgService:
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'updated_fields': list(update_dict.keys()),
|
||||
'updated_fields': sorted(update_data.updated_fields()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
"""
|
||||
Store class for managing organizations.
|
||||
"""
|
||||
"""Store class for managing organizations."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import SecretStr
|
||||
from server.constants import (
|
||||
DEFAULT_V1_ENABLED,
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate, OrphanedUserError
|
||||
from server.routes.org_models import (
|
||||
OrgMemberSettingsUpdate,
|
||||
OrgUpdate,
|
||||
OrphanedUserError,
|
||||
)
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
from openhands.utils.llm import is_openhands_model
|
||||
|
||||
_ORG_SETTINGS_EXCLUDED_FIELDS = {
|
||||
'id',
|
||||
@@ -129,11 +133,11 @@ class OrgStore:
|
||||
async def _validate_org_version(org: Org | None) -> Org | None:
|
||||
"""Check if we need to update org version."""
|
||||
if org and org.org_version < ORG_SETTINGS_VERSION:
|
||||
org = await OrgStore.update_org(
|
||||
org = await OrgStore._update_org_kwargs(
|
||||
org.id,
|
||||
{
|
||||
'org_version': ORG_SETTINGS_VERSION,
|
||||
'agent_settings': {
|
||||
'agent_settings_diff': {
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
@@ -155,8 +159,7 @@ class OrgStore:
|
||||
async def get_user_orgs_paginated(
|
||||
user_id: UUID, page_id: str | None = None, limit: int = 100
|
||||
) -> tuple[list[Org], str | None]:
|
||||
"""
|
||||
Get paginated list of organizations for a user.
|
||||
"""Get paginated list of organizations for a user.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
@@ -211,41 +214,111 @@ class OrgStore:
|
||||
|
||||
return validated_orgs, next_page_id
|
||||
|
||||
@staticmethod
|
||||
def _merge_and_validate_settings(
|
||||
current_settings: dict[str, Any],
|
||||
settings_diff: dict[str, Any],
|
||||
settings_type: type[AgentSettings] | type[ConversationSettings],
|
||||
) -> AgentSettings | ConversationSettings:
|
||||
"""Deep-merge a sparse settings diff and validate the merged result."""
|
||||
merged_settings = deep_merge(current_settings or {}, settings_diff)
|
||||
return settings_type.model_validate(merged_settings)
|
||||
|
||||
@staticmethod
|
||||
async def update_org(
|
||||
org_id: UUID,
|
||||
kwargs: dict,
|
||||
update_data: OrgUpdate,
|
||||
user_id: str | None = None,
|
||||
) -> Optional[Org]:
|
||||
"""Update organization details."""
|
||||
"""Update organization details from a validated OrgUpdate payload."""
|
||||
return await OrgStore._update_org_kwargs(
|
||||
org_id,
|
||||
update_data.model_update_dict(),
|
||||
user_id=user_id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _update_org_kwargs(
|
||||
org_id: UUID,
|
||||
org_kwargs: dict[str, Any],
|
||||
user_id: str | None = None,
|
||||
update_data: OrgUpdate | None = None,
|
||||
) -> Optional[Org]:
|
||||
"""Internal helper for updating organization fields from raw kwargs."""
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
org_kwargs = dict(org_kwargs)
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
if not org:
|
||||
return None
|
||||
|
||||
if 'id' in kwargs:
|
||||
kwargs.pop('id')
|
||||
if 'id' in org_kwargs:
|
||||
org_kwargs.pop('id')
|
||||
|
||||
# Pop the diff-style kwargs before the setattr loop — otherwise
|
||||
# ``hasattr(org, 'agent_settings')`` is True and the loop would
|
||||
# *overwrite* the JSON column instead of deep-merging into it.
|
||||
agent_settings_diff = kwargs.pop('agent_settings', None)
|
||||
conversation_settings_diff = kwargs.pop('conversation_settings', None)
|
||||
for key, value in kwargs.items():
|
||||
agent_settings_diff = (
|
||||
update_data.agent_settings_diff
|
||||
if update_data is not None
|
||||
else org_kwargs.pop('agent_settings_diff', None)
|
||||
)
|
||||
conversation_settings_diff = (
|
||||
update_data.conversation_settings_diff
|
||||
if update_data is not None
|
||||
else org_kwargs.pop('conversation_settings_diff', None)
|
||||
)
|
||||
for key, value in org_kwargs.items():
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
if agent_settings_diff is not None:
|
||||
org.agent_settings = deep_merge(
|
||||
org.agent_settings = OrgStore._merge_and_validate_settings(
|
||||
org.agent_settings,
|
||||
agent_settings_diff,
|
||||
)
|
||||
AgentSettings,
|
||||
).model_dump(mode='json', exclude_unset=True)
|
||||
|
||||
if conversation_settings_diff is not None:
|
||||
org.conversation_settings = deep_merge(
|
||||
org.conversation_settings = OrgStore._merge_and_validate_settings(
|
||||
org.conversation_settings,
|
||||
conversation_settings_diff,
|
||||
ConversationSettings,
|
||||
).model_dump(mode='json', exclude_unset=True)
|
||||
|
||||
if update_data is not None and update_data.touches_org_defaults():
|
||||
if user_id is None:
|
||||
raise ValueError(
|
||||
'user_id is required when updating organization defaults'
|
||||
)
|
||||
|
||||
member_updates = update_data.get_member_updates()
|
||||
effective_managed_key = (
|
||||
await OrgStore._maybe_get_managed_llm_key_for_user(
|
||||
session,
|
||||
org,
|
||||
user_id,
|
||||
)
|
||||
)
|
||||
should_reset_custom_key_flag = (
|
||||
update_data.llm_api_key is not None
|
||||
or effective_managed_key is not None
|
||||
)
|
||||
if effective_managed_key is not None:
|
||||
if member_updates is None:
|
||||
member_updates = OrgMemberSettingsUpdate()
|
||||
member_updates.llm_api_key = SecretStr(effective_managed_key)
|
||||
|
||||
if member_updates is not None:
|
||||
if should_reset_custom_key_flag:
|
||||
member_updates.has_custom_llm_api_key = False
|
||||
await OrgMemberStore.update_all_members_settings_async(
|
||||
session, org_id, member_updates
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
@@ -273,8 +346,7 @@ class OrgStore:
|
||||
org: Org,
|
||||
org_member: OrgMember,
|
||||
) -> Org:
|
||||
"""
|
||||
Persist organization and owner membership in a single transaction.
|
||||
"""Persist organization and owner membership in a single transaction.
|
||||
|
||||
Args:
|
||||
org: Organization entity to persist
|
||||
@@ -295,8 +367,7 @@ class OrgStore:
|
||||
|
||||
@staticmethod
|
||||
async def delete_org_cascade(org_id: UUID) -> Org | None:
|
||||
"""
|
||||
Delete organization and all associated data in cascade, including external LiteLLM cleanup.
|
||||
"""Delete organization and all associated data in cascade, including external LiteLLM cleanup.
|
||||
|
||||
Args:
|
||||
org_id: UUID of the organization to delete
|
||||
@@ -445,46 +516,81 @@ class OrgStore:
|
||||
return await OrgStore.get_org_by_id(org_id)
|
||||
|
||||
@staticmethod
|
||||
async def update_org_llm_settings_async(
|
||||
async def _maybe_get_managed_llm_key_for_user(
|
||||
session,
|
||||
updated_org: Org,
|
||||
user_id: str,
|
||||
) -> str | None:
|
||||
"""Return the managed LLM key every member row should carry, if any."""
|
||||
llm_settings = OrgStore.get_agent_settings_from_org(updated_org).llm
|
||||
llm_model = llm_settings.model
|
||||
llm_base_url = llm_settings.base_url
|
||||
normalized_llm_base_url = llm_base_url.rstrip('/') if llm_base_url else None
|
||||
normalized_managed_base_url = LITE_LLM_API_URL.rstrip('/')
|
||||
openhands_type = is_openhands_model(llm_model)
|
||||
uses_managed_llm_key = (
|
||||
normalized_llm_base_url == normalized_managed_base_url
|
||||
or (normalized_llm_base_url is None and openhands_type)
|
||||
)
|
||||
if not uses_managed_llm_key:
|
||||
return None
|
||||
|
||||
result = await session.execute(
|
||||
select(OrgMember).where(
|
||||
OrgMember.org_id == updated_org.id,
|
||||
OrgMember.user_id == UUID(user_id),
|
||||
)
|
||||
)
|
||||
acting_member = result.scalars().first()
|
||||
if acting_member is None:
|
||||
logger.error(
|
||||
'Acting member row not found during managed LLM key '
|
||||
'rotation; skipping managed-key propagation. Members may '
|
||||
'retain stale keys until they save personal settings.',
|
||||
extra={'user_id': user_id, 'org_id': str(updated_org.id)},
|
||||
)
|
||||
return None
|
||||
|
||||
existing_key = acting_member.llm_api_key
|
||||
existing_key_raw = existing_key.get_secret_value() if existing_key else None
|
||||
if existing_key_raw and await LiteLlmManager.verify_existing_key(
|
||||
existing_key_raw,
|
||||
user_id,
|
||||
str(updated_org.id),
|
||||
openhands_type=openhands_type,
|
||||
):
|
||||
return existing_key_raw
|
||||
|
||||
if openhands_type:
|
||||
logger.info(
|
||||
'Generated managed LLM key for acting user on org-defaults save',
|
||||
extra={'user_id': user_id, 'org_id': str(updated_org.id)},
|
||||
)
|
||||
return await LiteLlmManager.generate_key(
|
||||
user_id,
|
||||
str(updated_org.id),
|
||||
None,
|
||||
{'type': 'openhands'},
|
||||
)
|
||||
|
||||
key_alias = get_openhands_cloud_key_alias(user_id, str(updated_org.id))
|
||||
await LiteLlmManager.delete_key_by_alias(key_alias=key_alias)
|
||||
logger.info(
|
||||
'Generated managed LLM key for acting user on org-defaults save',
|
||||
extra={'user_id': user_id, 'org_id': str(updated_org.id)},
|
||||
)
|
||||
return await LiteLlmManager.generate_key(
|
||||
user_id,
|
||||
str(updated_org.id),
|
||||
key_alias,
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def update_org_defaults_async(
|
||||
org_id: UUID,
|
||||
llm_settings: OrgLLMSettingsUpdate,
|
||||
update_data: OrgUpdate,
|
||||
user_id: str,
|
||||
) -> Org | None:
|
||||
"""Update organization LLM settings and propagate to members (async version).
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
llm_settings: Typed LLM settings update model
|
||||
|
||||
Returns:
|
||||
Updated Org or None if not found
|
||||
"""
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
if not org:
|
||||
return None
|
||||
|
||||
llm_settings.apply_to_org(org)
|
||||
if llm_settings.agent_settings is not None:
|
||||
org.agent_settings = deep_merge(
|
||||
org.agent_settings,
|
||||
llm_settings.agent_settings,
|
||||
)
|
||||
if llm_settings.conversation_settings is not None:
|
||||
org.conversation_settings = deep_merge(
|
||||
org.conversation_settings,
|
||||
llm_settings.conversation_settings,
|
||||
)
|
||||
|
||||
# Propagate relevant settings to all org members
|
||||
member_updates = llm_settings.get_member_updates()
|
||||
if member_updates:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_updates
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
return org
|
||||
"""Backward-compatible wrapper for org-defaults updates."""
|
||||
return await OrgStore.update_org(org_id, update_data, user_id)
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata_result_set import (
|
||||
ConversationMetadataResultSet,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.search_utils import offset_to_page_id, page_id_to_offset
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaasConversationStore(ConversationStore):
|
||||
user_id: str
|
||||
session_maker: sessionmaker
|
||||
org_id: UUID | None = None # will be fetched automatically
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
session_maker: sessionmaker,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
self.session_maker = session_maker
|
||||
self.resolver_org_id = resolver_org_id
|
||||
|
||||
def _select_by_id(self, session, conversation_id: str):
|
||||
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
|
||||
query = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.filter(StoredConversationMetadataSaas.user_id == UUID(self.user_id))
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
.filter(StoredConversationMetadata.conversation_version == 'V0')
|
||||
)
|
||||
|
||||
if self.org_id is not None:
|
||||
query = query.filter(StoredConversationMetadataSaas.org_id == self.org_id)
|
||||
|
||||
return query
|
||||
|
||||
def _to_external_model(self, conversation_metadata: StoredConversationMetadata):
|
||||
kwargs = {
|
||||
c.name: getattr(conversation_metadata, c.name)
|
||||
for c in StoredConversationMetadata.__table__.columns
|
||||
}
|
||||
# TODO: I'm not sure why the timezone is not set on the dates coming back out of the db
|
||||
kwargs['created_at'] = kwargs['created_at'].replace(tzinfo=UTC)
|
||||
kwargs['last_updated_at'] = kwargs['last_updated_at'].replace(tzinfo=UTC)
|
||||
if kwargs['trigger']:
|
||||
kwargs['trigger'] = ConversationTrigger(kwargs['trigger'])
|
||||
if kwargs['git_provider'] and isinstance(kwargs['git_provider'], str):
|
||||
# Convert string to ProviderType enum
|
||||
kwargs['git_provider'] = ProviderType(kwargs['git_provider'])
|
||||
|
||||
kwargs['user_id'] = self.user_id
|
||||
|
||||
# Remove V1 attributes
|
||||
kwargs.pop('max_budget_per_task', None)
|
||||
kwargs.pop('cache_read_tokens', None)
|
||||
kwargs.pop('cache_write_tokens', None)
|
||||
kwargs.pop('reasoning_tokens', None)
|
||||
kwargs.pop('context_window', None)
|
||||
kwargs.pop('per_turn_token', None)
|
||||
kwargs.pop('parent_conversation_id', None)
|
||||
kwargs.pop('public')
|
||||
|
||||
return ConversationMetadata(**kwargs)
|
||||
|
||||
async def save_metadata(self, metadata: ConversationMetadata):
|
||||
kwargs = dataclasses.asdict(metadata)
|
||||
|
||||
# Remove user_id and org_id from kwargs since they're no longer in StoredConversationMetadata
|
||||
kwargs.pop('user_id', None)
|
||||
kwargs.pop('org_id', None)
|
||||
|
||||
# Convert ProviderType enum to string for storage
|
||||
if kwargs.get('git_provider') is not None:
|
||||
kwargs['git_provider'] = (
|
||||
kwargs['git_provider'].value
|
||||
if hasattr(kwargs['git_provider'], 'value')
|
||||
else kwargs['git_provider']
|
||||
)
|
||||
|
||||
stored_metadata = StoredConversationMetadata(**kwargs)
|
||||
|
||||
# Override with resolver org_id if set (from git org claim resolution),
|
||||
# same pattern as V1's save_app_conversation_info in
|
||||
# saas_app_conversation_info_injector.py
|
||||
org_id = self.org_id
|
||||
if self.resolver_org_id is not None:
|
||||
org_id = self.resolver_org_id
|
||||
|
||||
def _save_metadata():
|
||||
with self.session_maker() as session:
|
||||
# Save the main conversation metadata
|
||||
session.merge(stored_metadata)
|
||||
|
||||
# Create or update the SaaS metadata record
|
||||
saas_metadata = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
== stored_metadata.conversation_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not saas_metadata:
|
||||
saas_metadata = StoredConversationMetadataSaas(
|
||||
conversation_id=stored_metadata.conversation_id,
|
||||
user_id=UUID(self.user_id),
|
||||
org_id=org_id,
|
||||
)
|
||||
session.add(saas_metadata)
|
||||
else:
|
||||
# Validate
|
||||
expected_user_id = UUID(self.user_id)
|
||||
expected_org_id = org_id
|
||||
|
||||
if saas_metadata.user_id != expected_user_id:
|
||||
raise ValueError(
|
||||
f'Existing user_id ({saas_metadata.user_id}) does not match expected value ({expected_user_id}).'
|
||||
)
|
||||
|
||||
if expected_org_id and saas_metadata.org_id != expected_org_id:
|
||||
raise ValueError(
|
||||
f'Existing org_id ({saas_metadata.org_id}) does not match expected value ({expected_org_id}).'
|
||||
)
|
||||
|
||||
session.commit()
|
||||
|
||||
await call_sync_from_async(_save_metadata)
|
||||
|
||||
async def get_metadata(self, conversation_id: str) -> ConversationMetadata:
|
||||
def _get_metadata():
|
||||
with self.session_maker() as session:
|
||||
conversation_metadata = self._select_by_id(
|
||||
session, conversation_id
|
||||
).first()
|
||||
if not conversation_metadata:
|
||||
raise FileNotFoundError(conversation_id)
|
||||
return self._to_external_model(conversation_metadata)
|
||||
|
||||
return await call_sync_from_async(_get_metadata)
|
||||
|
||||
async def delete_metadata(self, conversation_id: str) -> None:
|
||||
def _delete_metadata():
|
||||
with self.session_maker() as session:
|
||||
saas_record = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
== conversation_id,
|
||||
StoredConversationMetadataSaas.user_id == UUID(self.user_id),
|
||||
StoredConversationMetadataSaas.org_id == self.org_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if saas_record:
|
||||
# Delete both records, but only if the SaaS one exists
|
||||
session.query(StoredConversationMetadata).filter(
|
||||
StoredConversationMetadata.conversation_id == conversation_id,
|
||||
).delete()
|
||||
|
||||
session.delete(saas_record)
|
||||
|
||||
session.commit()
|
||||
else:
|
||||
# No SaaS record found → skip deleting main metadata
|
||||
session.rollback()
|
||||
|
||||
await call_sync_from_async(_delete_metadata)
|
||||
|
||||
async def exists(self, conversation_id: str) -> bool:
|
||||
def _exists():
|
||||
with self.session_maker() as session:
|
||||
result = self._select_by_id(session, conversation_id).scalar()
|
||||
return bool(result)
|
||||
|
||||
return await call_sync_from_async(_exists)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> ConversationMetadataResultSet:
|
||||
offset = page_id_to_offset(page_id)
|
||||
|
||||
def _search():
|
||||
with self.session_maker() as session:
|
||||
conversations = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.user_id == UUID(self.user_id)
|
||||
)
|
||||
.filter(StoredConversationMetadataSaas.org_id == self.org_id)
|
||||
.filter(StoredConversationMetadata.conversation_version == 'V0')
|
||||
.order_by(StoredConversationMetadata.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
conversations = [self._to_external_model(c) for c in conversations]
|
||||
current_page_size = len(conversations)
|
||||
next_page_id = offset_to_page_id(
|
||||
offset + limit, current_page_size > limit
|
||||
)
|
||||
conversations = conversations[:limit]
|
||||
return ConversationMetadataResultSet(conversations, next_page_id)
|
||||
|
||||
return await call_sync_from_async(_search)
|
||||
|
||||
@classmethod
|
||||
async def get_instance(
|
||||
cls,
|
||||
config: OpenHandsConfig,
|
||||
user_id: str, # type: ignore[override]
|
||||
) -> ConversationStore:
|
||||
# Use async version since callers now use asyncio.run_coroutine_threadsafe()
|
||||
# to dispatch to the main event loop where asyncpg connections work properly.
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker)
|
||||
|
||||
@classmethod
|
||||
async def get_resolver_instance(
|
||||
cls,
|
||||
config: OpenHandsConfig,
|
||||
user_id: str,
|
||||
resolver_org_id: UUID | None = None,
|
||||
) -> 'SaasConversationStore':
|
||||
"""Get a store for resolver conversations with explicit org routing.
|
||||
|
||||
Unlike get_instance, this accepts a resolver_org_id that overrides
|
||||
the user's default org when saving conversation metadata.
|
||||
"""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)
|
||||
@@ -1,148 +0,0 @@
|
||||
from server.auth.auth_error import AuthError, ExpiredError
|
||||
from server.auth.saas_user_auth import saas_user_auth_from_signed_token
|
||||
from server.auth.token_manager import TokenManager
|
||||
from socketio.exceptions import ConnectionRefusedError
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import ConversationStoreImpl
|
||||
from openhands.storage.conversation.conversation_validator import ConversationValidator
|
||||
|
||||
|
||||
class SaasConversationValidator(ConversationValidator):
|
||||
"""Storage for conversation metadata. May or may not support multiple users depending on the environment."""
|
||||
|
||||
async def _validate_api_key(self, api_key: str) -> str | None:
|
||||
"""
|
||||
Validate an API key and return the user_id if valid.
|
||||
|
||||
Args:
|
||||
api_key: The API key to validate
|
||||
|
||||
Returns:
|
||||
The user_id if the API key is valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
token_manager = TokenManager()
|
||||
|
||||
# Validate the API key and get the user_id
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
validation_result = await api_key_store.validate_api_key(api_key)
|
||||
|
||||
if not validation_result:
|
||||
logger.warning('Invalid API key')
|
||||
return None
|
||||
|
||||
user_id = validation_result.user_id
|
||||
|
||||
# Get the offline token for the user
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
if not offline_token:
|
||||
logger.warning(f'No offline token found for user {user_id}')
|
||||
return None
|
||||
|
||||
return user_id
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error validating API key: {str(e)}')
|
||||
return None
|
||||
|
||||
async def _validate_conversation_access(
|
||||
self, conversation_id: str, user_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that the user has access to the conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation
|
||||
user_id: The ID of the user
|
||||
github_user_id: The GitHub user ID, if available
|
||||
|
||||
Returns:
|
||||
True if the user has access to the conversation, False otherwise
|
||||
|
||||
Raises:
|
||||
ConnectionRefusedError: If the user does not have access to the conversation
|
||||
"""
|
||||
config = load_openhands_config()
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
|
||||
if not await conversation_store.validate_metadata(conversation_id, user_id):
|
||||
logger.error(
|
||||
f'User {user_id} is not allowed to join conversation {conversation_id}'
|
||||
)
|
||||
raise ConnectionRefusedError(
|
||||
f'User {user_id} is not allowed to join conversation {conversation_id}'
|
||||
)
|
||||
return True
|
||||
|
||||
async def validate(
|
||||
self,
|
||||
conversation_id: str,
|
||||
cookies_str: str,
|
||||
authorization_header: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Validate the conversation access using either an API key from the Authorization header
|
||||
or a keycloak_auth cookie.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation
|
||||
cookies_str: The cookies string from the request
|
||||
authorization_header: The Authorization header from the request, if available
|
||||
|
||||
Returns:
|
||||
A tuple of (user_id, github_user_id)
|
||||
|
||||
Raises:
|
||||
ConnectionRefusedError: If the user does not have access to the conversation
|
||||
AuthError: If the authentication fails
|
||||
RuntimeError: If there is an error with the configuration or user info
|
||||
"""
|
||||
# Try to authenticate using Authorization header first
|
||||
if authorization_header and authorization_header.startswith('Bearer '):
|
||||
api_key = authorization_header.replace('Bearer ', '')
|
||||
user_id = await self._validate_api_key(api_key)
|
||||
|
||||
if user_id:
|
||||
logger.info(
|
||||
f'User {user_id} is connecting to conversation {conversation_id} via API key'
|
||||
)
|
||||
|
||||
await self._validate_conversation_access(conversation_id, user_id)
|
||||
return user_id
|
||||
|
||||
# Fall back to cookie authentication
|
||||
token_manager = TokenManager()
|
||||
config = load_openhands_config()
|
||||
cookies = (
|
||||
dict(cookie.split('=', 1) for cookie in cookies_str.split('; '))
|
||||
if cookies_str
|
||||
else {}
|
||||
)
|
||||
|
||||
signed_token = cookies.get('keycloak_auth', '')
|
||||
if not signed_token:
|
||||
logger.warning('No keycloak_auth cookie or valid Authorization header')
|
||||
raise ConnectionRefusedError(
|
||||
'No keycloak_auth cookie or valid Authorization header'
|
||||
)
|
||||
if not config.jwt_secret:
|
||||
raise RuntimeError('JWT secret not found')
|
||||
|
||||
try:
|
||||
user_auth = await saas_user_auth_from_signed_token(signed_token)
|
||||
access_token = await user_auth.get_access_token()
|
||||
except ExpiredError:
|
||||
raise ConnectionRefusedError('SESSION$TIMEOUT_MESSAGE')
|
||||
if access_token is None:
|
||||
raise AuthError('no_access_token')
|
||||
user_info = await token_manager.get_user_info(access_token.get_secret_value())
|
||||
# sub is a required field in KeycloakUserInfo, validation happens in get_user_info
|
||||
user_id = user_info.sub
|
||||
|
||||
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
|
||||
|
||||
await self._validate_conversation_access(conversation_id, user_id) # type: ignore
|
||||
return user_id
|
||||
@@ -10,10 +10,10 @@ from storage.database import a_session_maker
|
||||
from storage.stored_custom_secrets import StoredCustomSecrets
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.app_server.secrets.secrets_store import SecretsStore
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -60,13 +60,11 @@ class SaasSecretsStore(SecretsStore):
|
||||
async with a_session_maker() as session:
|
||||
# Incoming secrets are always the most updated ones
|
||||
# Delete existing records for this user AND organization only
|
||||
# Note: user.current_org_id is non-nullable, so org_id is always set
|
||||
delete_query = delete(StoredCustomSecrets).filter(
|
||||
StoredCustomSecrets.keycloak_user_id == self.user_id
|
||||
StoredCustomSecrets.keycloak_user_id == self.user_id,
|
||||
StoredCustomSecrets.org_id == org_id,
|
||||
)
|
||||
if org_id is not None:
|
||||
delete_query = delete_query.filter(StoredCustomSecrets.org_id == org_id)
|
||||
else:
|
||||
delete_query = delete_query.filter(StoredCustomSecrets.org_id.is_(None))
|
||||
await session.execute(delete_query)
|
||||
|
||||
# Prepare the new secrets data
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from server.logger import logger
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from server.routes.org_models import OrgMemberSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker
|
||||
@@ -21,9 +21,9 @@ from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
from openhands.utils.llm import is_openhands_model
|
||||
|
||||
@@ -148,6 +148,10 @@ class SaasSettingsStore(SettingsStore):
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
if kwargs.get('sandbox_grouping_strategy') is None:
|
||||
kwargs.pop('sandbox_grouping_strategy', None)
|
||||
# Pre-migration rows read back as None; Settings.llm_profiles is
|
||||
# non-nullable, so let the default_factory take over.
|
||||
if kwargs.get('llm_profiles') is None:
|
||||
kwargs.pop('llm_profiles', None)
|
||||
|
||||
return Settings(**kwargs)
|
||||
|
||||
@@ -262,10 +266,10 @@ class SaasSettingsStore(SettingsStore):
|
||||
else None
|
||||
)
|
||||
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
await OrgMemberStore.update_all_members_settings_async(
|
||||
session,
|
||||
org_id,
|
||||
OrgMemberLLMSettings(
|
||||
OrgMemberSettingsUpdate(
|
||||
agent_settings_diff=effective_agent_settings_diff,
|
||||
conversation_settings_diff=effective_conversation_diff,
|
||||
llm_api_key=(
|
||||
|
||||
@@ -3,13 +3,14 @@ SQLAlchemy model for User.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from storage.base import Base
|
||||
from storage.encrypt_utils import EncryptedJSON
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from storage.org import Org
|
||||
@@ -36,6 +37,9 @@ class User(Base):
|
||||
git_user_email: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
sandbox_grouping_strategy: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
disabled_skills: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
llm_profiles: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
EncryptedJSON, nullable=True
|
||||
)
|
||||
onboarding_completed: Mapped[bool | None] = mapped_column(
|
||||
nullable=True, default=False
|
||||
)
|
||||
|
||||
@@ -50,9 +50,6 @@ class UserSettings(Base):
|
||||
search_api_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
sandbox_api_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
max_budget_per_task: Mapped[float | None] = mapped_column(nullable=True)
|
||||
enable_solvability_analysis: Mapped[bool | None] = mapped_column(
|
||||
nullable=True, default=False
|
||||
)
|
||||
email: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
email_verified: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
git_user_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
@@ -88,8 +85,8 @@ class UserSettings(Base):
|
||||
) # False = not migrated, True = migrated
|
||||
|
||||
def to_settings(self):
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
return Settings(
|
||||
agent_settings=AgentSettings.model_validate(self.agent_settings or {}),
|
||||
|
||||
@@ -230,19 +230,10 @@ class UserStore:
|
||||
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
org_kwargs = OrgStore.get_kwargs_from_user_settings(decrypted_user_settings)
|
||||
org_kwargs.pop('id', None)
|
||||
|
||||
# If the user has custom settings, keep the org defaults minimal.
|
||||
if custom_settings:
|
||||
org_kwargs['agent_settings'] = {
|
||||
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
}
|
||||
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
|
||||
org_kwargs = UserStore._get_org_kwargs_for_migration(
|
||||
decrypted_user_settings,
|
||||
custom_settings=custom_settings,
|
||||
)
|
||||
|
||||
for key, value in org_kwargs.items():
|
||||
if hasattr(org, key):
|
||||
@@ -940,7 +931,7 @@ class UserStore:
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
|
||||
@staticmethod
|
||||
async def create_default_settings(
|
||||
@@ -954,7 +945,7 @@ class UserStore:
|
||||
if not org_id:
|
||||
return None
|
||||
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
|
||||
default_settings = Settings(
|
||||
language='en', enable_proactive_conversation_starters=True
|
||||
@@ -978,11 +969,15 @@ class UserStore:
|
||||
|
||||
@staticmethod
|
||||
def get_kwargs_from_settings(settings: 'Settings'):
|
||||
kwargs = {
|
||||
normalized: getattr(settings, normalized)
|
||||
for c in User.__table__.columns
|
||||
if (normalized := c.name.lstrip('_')) and hasattr(settings, normalized)
|
||||
}
|
||||
kwargs = {}
|
||||
for c in User.__table__.columns:
|
||||
normalized = c.name.lstrip('_')
|
||||
if normalized and hasattr(settings, normalized):
|
||||
value = getattr(settings, normalized)
|
||||
# LLMProfiles must be serialized to dict for EncryptedJSON storage
|
||||
if normalized == 'llm_profiles' and value is not None:
|
||||
value = value.model_dump(mode='json')
|
||||
kwargs[normalized] = value
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
@@ -1058,7 +1053,6 @@ class UserStore:
|
||||
if org.sandbox_api_key
|
||||
else None,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
v1_enabled=org.v1_enabled,
|
||||
sandbox_grouping_strategy=org.sandbox_grouping_strategy,
|
||||
agent_settings=agent_settings,
|
||||
@@ -1066,6 +1060,27 @@ class UserStore:
|
||||
already_migrated=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_org_kwargs_for_migration(
|
||||
user_settings: UserSettings, *, custom_settings: bool
|
||||
) -> dict:
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
org_kwargs = OrgStore.get_kwargs_from_user_settings(user_settings)
|
||||
org_kwargs.pop('id', None)
|
||||
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
|
||||
|
||||
if custom_settings:
|
||||
org_kwargs['agent_settings'] = {
|
||||
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
}
|
||||
|
||||
return org_kwargs
|
||||
|
||||
@staticmethod
|
||||
def _has_custom_settings(
|
||||
user_settings: UserSettings, old_user_version: int | None
|
||||
|
||||
@@ -32,7 +32,6 @@ from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -76,7 +75,10 @@ def conversation_state_update_event():
|
||||
|
||||
@pytest.fixture
|
||||
def wrong_event():
|
||||
return MessageAction(content='Hello world')
|
||||
"""Return a mock event that is not a ConversationStateUpdateEvent."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.id = uuid4()
|
||||
return mock_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import (
|
||||
@@ -17,7 +17,6 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartTaskStatus,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -44,11 +43,8 @@ class _FakeAppConversationService:
|
||||
yield MagicMock(status=AppConversationStartTaskStatus.READY, detail=None)
|
||||
|
||||
|
||||
def _build_conversation_metadata() -> ConversationMetadata:
|
||||
return ConversationMetadata(
|
||||
conversation_id=str(uuid4()),
|
||||
selected_repository='test-owner/test-repo',
|
||||
)
|
||||
def _build_conversation_id() -> UUID:
|
||||
return uuid4()
|
||||
|
||||
|
||||
def _build_user_data() -> UserData:
|
||||
@@ -77,7 +73,6 @@ class TestGithubViewV1InitialUserMessage:
|
||||
title='ignored',
|
||||
description='ignored',
|
||||
previous_comments=[],
|
||||
v1_enabled=True,
|
||||
comment_body='please fix this',
|
||||
comment_id=999,
|
||||
)
|
||||
@@ -98,7 +93,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
await view._create_v1_conversation(
|
||||
jinja_env=jinja_env,
|
||||
saas_user_auth=MagicMock(),
|
||||
conversation_metadata=_build_conversation_metadata(),
|
||||
conversation_id=_build_conversation_id(),
|
||||
)
|
||||
|
||||
assert len(fake_service.requests) == 1
|
||||
@@ -131,7 +126,6 @@ class TestGithubViewV1InitialUserMessage:
|
||||
title='ignored',
|
||||
description='ignored',
|
||||
previous_comments=[],
|
||||
v1_enabled=True,
|
||||
comment_body='nit: rename variable',
|
||||
comment_id=1001,
|
||||
branch_name='feature-branch',
|
||||
@@ -155,7 +149,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
await view._create_v1_conversation(
|
||||
jinja_env=jinja_env,
|
||||
saas_user_auth=MagicMock(),
|
||||
conversation_metadata=_build_conversation_metadata(),
|
||||
conversation_id=_build_conversation_id(),
|
||||
)
|
||||
|
||||
assert len(fake_service.requests) == 1
|
||||
@@ -187,7 +181,6 @@ class TestGithubViewV1InitialUserMessage:
|
||||
title='ignored',
|
||||
description='ignored',
|
||||
previous_comments=[],
|
||||
v1_enabled=True,
|
||||
comment_body='please add a null check',
|
||||
comment_id=1002,
|
||||
branch_name='feature-branch',
|
||||
@@ -210,7 +203,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
await view._create_v1_conversation(
|
||||
jinja_env=jinja_env,
|
||||
saas_user_auth=MagicMock(),
|
||||
conversation_metadata=_build_conversation_metadata(),
|
||||
conversation_id=_build_conversation_id(),
|
||||
)
|
||||
|
||||
req = fake_service.requests[0]
|
||||
|
||||
@@ -5,13 +5,12 @@ All conversations now use V1 app conversation system.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.gitlab_view import GitlabIssue
|
||||
from integrations.types import UserData
|
||||
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gitlab_view():
|
||||
@@ -35,7 +34,6 @@ def mock_gitlab_view():
|
||||
description='Test description',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,12 +55,9 @@ def mock_saas_user_auth():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_convo_metadata():
|
||||
"""Create a mock ConversationMetadata."""
|
||||
return ConversationMetadata(
|
||||
conversation_id='test_conversation_id',
|
||||
selected_repository='test-group/test-repo',
|
||||
)
|
||||
def mock_conversation_id():
|
||||
"""Create a mock conversation UUID."""
|
||||
return uuid4()
|
||||
|
||||
|
||||
class TestGitlabManagerJobCreation:
|
||||
@@ -81,7 +76,7 @@ class TestGitlabManagerJobCreation:
|
||||
mock_token_manager,
|
||||
mock_gitlab_view,
|
||||
mock_saas_user_auth,
|
||||
mock_convo_metadata,
|
||||
mock_conversation_id,
|
||||
):
|
||||
"""Test that start_job creates a conversation and sends acknowledgment message."""
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
@@ -91,7 +86,7 @@ class TestGitlabManagerJobCreation:
|
||||
|
||||
# Mock the view's methods
|
||||
mock_gitlab_view.initialize_new_conversation = AsyncMock(
|
||||
return_value=mock_convo_metadata
|
||||
return_value=mock_conversation_id
|
||||
)
|
||||
mock_gitlab_view.create_new_conversation = AsyncMock()
|
||||
|
||||
|
||||
@@ -12,6 +12,16 @@ def gitlab_service():
|
||||
return SaaSGitLabService(external_auth_id='test_user_id')
|
||||
|
||||
|
||||
class TestSaaSGitLabServiceInit:
|
||||
"""Tests for SaaSGitLabService __init__."""
|
||||
|
||||
def test_explicit_base_domain_overrides_default(self):
|
||||
"""An explicit base_domain parameter overrides the upstream class default."""
|
||||
service = SaaSGitLabService(external_auth_id='u1', base_domain='other.host')
|
||||
|
||||
assert service.BASE_URL == 'https://other.host/api/v4'
|
||||
|
||||
|
||||
class TestGetUserResourcesWithAdminAccess:
|
||||
"""Test cases for get_user_resources_with_admin_access method."""
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -73,7 +72,10 @@ def conversation_state_update_event():
|
||||
|
||||
@pytest.fixture
|
||||
def wrong_event():
|
||||
return MessageAction(content='Hello world')
|
||||
"""Return a mock event that is not a ConversationStateUpdateEvent."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.id = uuid4()
|
||||
return mock_event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -215,7 +215,6 @@ def new_conversation_view(
|
||||
conversation_id='conv-123',
|
||||
_decrypted_api_key='decrypted_key',
|
||||
)
|
||||
view.v1_enabled = False
|
||||
return view
|
||||
|
||||
|
||||
|
||||
@@ -444,10 +444,10 @@ class TestJiraV1Conversation:
|
||||
"""Tests for V1 conversation creation and callback processor registration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_v1_metadata_generates_conversation_id(
|
||||
async def test_initialize_conversation_generates_conversation_id(
|
||||
self, new_conversation_view
|
||||
):
|
||||
"""Test that _create_v1_metadata generates a new conversation ID."""
|
||||
"""Test that _initialize_conversation generates a new conversation ID."""
|
||||
new_conversation_view.conversation_id = ''
|
||||
|
||||
with patch.object(
|
||||
@@ -455,17 +455,19 @@ class TestJiraV1Conversation:
|
||||
) as mock_get_org:
|
||||
mock_get_org.return_value = None
|
||||
|
||||
metadata = await new_conversation_view._create_v1_metadata()
|
||||
conversation_id = await new_conversation_view._initialize_conversation()
|
||||
|
||||
# Conversation ID should be generated
|
||||
assert new_conversation_view.conversation_id != ''
|
||||
assert len(new_conversation_view.conversation_id) == 32 # UUID hex format
|
||||
assert metadata.conversation_id == new_conversation_view.conversation_id
|
||||
assert conversation_id.hex == new_conversation_view.conversation_id
|
||||
mock_get_org.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_v1_metadata_sets_resolved_org(self, new_conversation_view):
|
||||
"""Test that _create_v1_metadata sets resolved_org_id."""
|
||||
async def test_initialize_conversation_sets_resolved_org(
|
||||
self, new_conversation_view
|
||||
):
|
||||
"""Test that _initialize_conversation sets resolved_org_id."""
|
||||
from uuid import UUID
|
||||
|
||||
test_org_id = UUID('12345678-1234-5678-1234-567812345678')
|
||||
@@ -475,7 +477,7 @@ class TestJiraV1Conversation:
|
||||
) as mock_get_org:
|
||||
mock_get_org.return_value = test_org_id
|
||||
|
||||
await new_conversation_view._create_v1_metadata()
|
||||
await new_conversation_view._initialize_conversation()
|
||||
|
||||
assert new_conversation_view.resolved_org_id == test_org_id
|
||||
|
||||
|
||||
@@ -28,9 +28,16 @@ from openhands.app_server.sandbox.sandbox_models import (
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
|
||||
|
||||
def _create_mock_event():
|
||||
"""Create a mock event that is not a ConversationStateUpdateEvent."""
|
||||
mock_event = MagicMock()
|
||||
mock_event.id = uuid4()
|
||||
return mock_event
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -105,8 +112,10 @@ class TestSlackV1CallbackProcessor:
|
||||
@pytest.mark.parametrize(
|
||||
'event,expected_result',
|
||||
[
|
||||
# Wrong event types should be ignored
|
||||
(MessageAction(content='Hello world'), None),
|
||||
# Wrong event types should be ignored (use lazy evaluation for mock)
|
||||
pytest.param(
|
||||
None, None, id='wrong_event_type', marks=pytest.mark.wrong_event_type
|
||||
),
|
||||
# Wrong state values should be ignored
|
||||
(
|
||||
ConversationStateUpdateEvent(key='execution_status', value='running'),
|
||||
@@ -120,9 +129,12 @@ class TestSlackV1CallbackProcessor:
|
||||
],
|
||||
)
|
||||
async def test_event_filtering(
|
||||
self, slack_callback_processor, event_callback, event, expected_result
|
||||
self, slack_callback_processor, event_callback, event, expected_result, request
|
||||
):
|
||||
"""Test that processor correctly filters events."""
|
||||
# Handle the mock event case specially
|
||||
if event is None and 'wrong_event_type' in request.node.name:
|
||||
event = _create_mock_event()
|
||||
result = await slack_callback_processor(uuid4(), event_callback, event)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ def slack_new_conversation_view(mock_slack_user, mock_user_auth):
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id='T1234567890',
|
||||
v1_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -99,7 +98,6 @@ def slack_update_conversation_view_v1(mock_slack_user, mock_user_auth):
|
||||
conversation_id=conversation_id,
|
||||
slack_conversation=mock_conversation,
|
||||
team_id='T1234567890',
|
||||
v1_enabled=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -111,18 +109,15 @@ def slack_update_conversation_view_v1(mock_slack_user, mock_user_auth):
|
||||
class TestV1ConversationCreation:
|
||||
"""Test V1 conversation creation in Slack integration."""
|
||||
|
||||
@patch('integrations.slack.slack_view.is_v1_enabled_for_slack_resolver')
|
||||
@patch.object(SlackNewConversationView, '_create_v1_conversation')
|
||||
async def test_v1_conversation_creation(
|
||||
self,
|
||||
mock_create_v1,
|
||||
mock_is_v1_enabled,
|
||||
slack_new_conversation_view,
|
||||
mock_jinja_env,
|
||||
):
|
||||
"""Test that V1 conversations are created correctly."""
|
||||
# Setup mocks
|
||||
mock_is_v1_enabled.return_value = True
|
||||
mock_create_v1.return_value = None
|
||||
|
||||
# Execute
|
||||
@@ -132,7 +127,6 @@ class TestV1ConversationCreation:
|
||||
|
||||
# Verify
|
||||
assert result == slack_new_conversation_view.conversation_id
|
||||
assert slack_new_conversation_view.v1_enabled is True
|
||||
mock_create_v1.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
|
||||
# Import the real classes we want to test
|
||||
from openhands.integrations.provider import CustomSecret, ProviderToken
|
||||
@@ -17,7 +18,6 @@ from openhands.integrations.service_types import ProviderType
|
||||
|
||||
# Import the SDK types we need for testing
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,171 +1,11 @@
|
||||
"""Tests for enterprise integrations utils module."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
append_conversation_footer,
|
||||
get_session_expired_message,
|
||||
get_summary_for_agent_state,
|
||||
get_user_not_found_message,
|
||||
)
|
||||
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
|
||||
|
||||
class TestGetSummaryForAgentState:
|
||||
"""Test cases for get_summary_for_agent_state function."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.conversation_link = 'https://example.com/conversation/123'
|
||||
|
||||
def test_empty_observations_list(self):
|
||||
"""Test handling of empty observations list."""
|
||||
result = get_summary_for_agent_state([], self.conversation_link)
|
||||
|
||||
assert 'unknown error' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'state,expected_text,includes_link',
|
||||
[
|
||||
(AgentState.RATE_LIMITED, 'rate limited', False),
|
||||
(AgentState.AWAITING_USER_INPUT, 'waiting for your input', True),
|
||||
],
|
||||
)
|
||||
def test_handled_agent_states(self, state, expected_text, includes_link):
|
||||
"""Test handling of states with specific behavior."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content=f'Agent state: {state.value}', agent_state=state
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert expected_text in result.lower()
|
||||
if includes_link:
|
||||
assert self.conversation_link in result
|
||||
else:
|
||||
assert self.conversation_link not in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'state',
|
||||
[
|
||||
AgentState.FINISHED,
|
||||
AgentState.PAUSED,
|
||||
AgentState.STOPPED,
|
||||
AgentState.AWAITING_USER_CONFIRMATION,
|
||||
],
|
||||
)
|
||||
def test_unhandled_agent_states(self, state):
|
||||
"""Test handling of unhandled states (should all return unknown error)."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content=f'Agent state: {state.value}', agent_state=state
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert 'unknown error' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'error_code,expected_text',
|
||||
[
|
||||
(
|
||||
'STATUS$ERROR_LLM_AUTHENTICATION',
|
||||
'authentication with the llm provider failed',
|
||||
),
|
||||
(
|
||||
'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE',
|
||||
'llm service is temporarily unavailable',
|
||||
),
|
||||
(
|
||||
'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR',
|
||||
'llm provider encountered an internal error',
|
||||
),
|
||||
('STATUS$ERROR_LLM_OUT_OF_CREDITS', "you've run out of credits"),
|
||||
('STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION', 'content policy violation'),
|
||||
],
|
||||
)
|
||||
def test_error_state_readable_reasons(self, error_code, expected_text):
|
||||
"""Test all readable error reason mappings."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content=f'Agent encountered error: {error_code}',
|
||||
agent_state=AgentState.ERROR,
|
||||
reason=error_code,
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert 'encountered an error' in result.lower()
|
||||
assert expected_text in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
def test_error_state_with_custom_reason(self):
|
||||
"""Test handling of ERROR state with a custom reason."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content='Agent encountered an error',
|
||||
agent_state=AgentState.ERROR,
|
||||
reason='Test error message',
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert 'encountered an error' in result.lower()
|
||||
assert 'test error message' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
def test_multiple_observations_uses_first(self):
|
||||
"""Test that when multiple observations are provided, only the first is used."""
|
||||
observation1 = AgentStateChangedObservation(
|
||||
content='Agent is awaiting user input',
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
)
|
||||
observation2 = AgentStateChangedObservation(
|
||||
content='Agent encountered an error',
|
||||
agent_state=AgentState.ERROR,
|
||||
reason='Should not be used',
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state(
|
||||
[observation1, observation2], self.conversation_link
|
||||
)
|
||||
|
||||
# Should handle the first observation (AWAITING_USER_INPUT), not the second (ERROR)
|
||||
assert 'waiting for your input' in result.lower()
|
||||
assert 'error' not in result.lower()
|
||||
|
||||
def test_awaiting_user_input_specific_message(self):
|
||||
"""Test that AWAITING_USER_INPUT returns the specific expected message."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content='Agent is awaiting user input',
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
# Test the exact message format
|
||||
assert 'waiting for your input' in result.lower()
|
||||
assert 'continue the conversation' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
assert 'unknown error' not in result.lower()
|
||||
|
||||
def test_rate_limited_specific_message(self):
|
||||
"""Test that RATE_LIMITED returns the specific expected message."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content='Agent was rate limited', agent_state=AgentState.RATE_LIMITED
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
# Test the exact message format
|
||||
assert 'rate limited' in result.lower()
|
||||
assert 'try again later' in result.lower()
|
||||
# RATE_LIMITED doesn't include conversation link in response
|
||||
assert self.conversation_link not in result
|
||||
|
||||
|
||||
class TestGetSessionExpiredMessage:
|
||||
"""Test cases for get_session_expired_message function."""
|
||||
@@ -293,138 +133,3 @@ class TestGetUserNotFoundMessage:
|
||||
result = get_user_not_found_message(None)
|
||||
assert not result.startswith('@')
|
||||
assert 'It looks like' in result
|
||||
|
||||
|
||||
class TestAppendConversationFooter:
|
||||
"""Test cases for append_conversation_footer function."""
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_appends_footer_with_markdown_link(self):
|
||||
"""Test that footer is appended with correct markdown link format."""
|
||||
# Arrange
|
||||
message = 'This is a test message'
|
||||
conversation_id = 'test-conv-123'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert result.startswith(message)
|
||||
assert (
|
||||
'[View full conversation](https://example.com/conversations/test-conv-123)'
|
||||
in result
|
||||
)
|
||||
assert result.endswith(
|
||||
'[View full conversation](https://example.com/conversations/test-conv-123)'
|
||||
)
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_footer_does_not_contain_html_tags(self):
|
||||
"""Test that footer does not contain HTML tags like <sub>."""
|
||||
# Arrange
|
||||
message = 'Test message'
|
||||
conversation_id = 'test-conv-456'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert '<sub>' not in result
|
||||
assert '</sub>' not in result
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_footer_format_with_newlines(self):
|
||||
"""Test that footer is properly separated with newlines."""
|
||||
# Arrange
|
||||
message = 'Original message content'
|
||||
conversation_id = 'test-conv-789'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert (
|
||||
result
|
||||
== 'Original message content\n\n[View full conversation](https://example.com/conversations/test-conv-789)'
|
||||
)
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_empty_message_still_appends_footer(self):
|
||||
"""Test that footer is appended even when message is empty."""
|
||||
# Arrange
|
||||
message = ''
|
||||
conversation_id = 'empty-msg-conv'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert result.startswith('\n\n')
|
||||
assert (
|
||||
'[View full conversation](https://example.com/conversations/empty-msg-conv)'
|
||||
in result
|
||||
)
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_conversation_id_with_special_characters(self):
|
||||
"""Test that footer handles conversation IDs with special characters."""
|
||||
# Arrange
|
||||
message = 'Test message'
|
||||
conversation_id = 'conv-123_abc-456'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
expected_url = 'https://example.com/conversations/conv-123_abc-456'
|
||||
assert expected_url in result
|
||||
assert '[View full conversation]' in result
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_multiline_message_preserves_content(self):
|
||||
"""Test that multiline messages are preserved correctly."""
|
||||
# Arrange
|
||||
message = 'Line 1\nLine 2\nLine 3'
|
||||
conversation_id = 'multiline-conv'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
assert result.startswith('Line 1\nLine 2\nLine 3')
|
||||
assert '\n\n[View full conversation]' in result
|
||||
assert message in result
|
||||
|
||||
@patch(
|
||||
'integrations.utils.CONVERSATION_URL', 'https://example.com/conversations/{}'
|
||||
)
|
||||
def test_footer_contains_only_markdown_syntax(self):
|
||||
"""Test that footer uses only markdown syntax, not HTML."""
|
||||
# Arrange
|
||||
message = 'Test message'
|
||||
conversation_id = 'markdown-test'
|
||||
|
||||
# Act
|
||||
result = append_conversation_footer(message, conversation_id)
|
||||
|
||||
# Assert
|
||||
footer_part = result[len(message) :]
|
||||
# Should only contain markdown link syntax: [text](url)
|
||||
assert footer_part.startswith('\n\n[')
|
||||
assert '](' in footer_part
|
||||
assert footer_part.endswith(')')
|
||||
# Should not contain any HTML tags (specifically <sub> tags that were removed)
|
||||
assert '<sub>' not in footer_part
|
||||
assert '</sub>' not in footer_part
|
||||
|
||||
@@ -4,8 +4,10 @@ Tests for:
|
||||
- _should_redirect_to_onboarding() function
|
||||
- _get_post_auth_redirect() function
|
||||
- /complete_onboarding endpoint
|
||||
- /onboarding_status endpoint
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -17,6 +19,7 @@ from server.routes.auth import (
|
||||
_get_post_auth_redirect,
|
||||
_should_redirect_to_onboarding,
|
||||
complete_onboarding,
|
||||
onboarding_status,
|
||||
)
|
||||
from storage.user import User
|
||||
|
||||
@@ -328,3 +331,78 @@ class TestCompleteOnboardingEndpoint:
|
||||
await complete_onboarding(mock_request)
|
||||
|
||||
mock_mark_completed.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
class TestOnboardingStatusEndpoint:
|
||||
"""Tests for the /onboarding_status API endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_401_when_not_authenticated(self, mock_request):
|
||||
"""Unauthenticated requests return 401."""
|
||||
mock_user_auth = MagicMock(spec=SaasUserAuth)
|
||||
mock_user_auth.get_user_id = AsyncMock(return_value=None)
|
||||
|
||||
with patch(
|
||||
'server.routes.auth.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
):
|
||||
result = await onboarding_status(mock_request)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_true_for_new_cloud_user(self, mock_request, mock_user):
|
||||
"""A cloud user whose onboarding is incomplete should be told to complete it."""
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_user.onboarding_completed = False
|
||||
mock_user_auth = MagicMock(spec=SaasUserAuth)
|
||||
mock_user_auth.get_user_id = AsyncMock(return_value=user_id)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.auth.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.auth.UserStore.get_user_by_id',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user,
|
||||
),
|
||||
patch('server.routes.auth.DEPLOYMENT_MODE', 'cloud'),
|
||||
):
|
||||
result = await onboarding_status(mock_request)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
body = json.loads(result.body)
|
||||
assert body == {'should_complete_onboarding': True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_for_completed_user(self, mock_request, mock_user):
|
||||
"""A user who already completed onboarding should not be told to complete it."""
|
||||
user_id = str(uuid.uuid4())
|
||||
mock_user.onboarding_completed = True
|
||||
mock_user_auth = MagicMock(spec=SaasUserAuth)
|
||||
mock_user_auth.get_user_id = AsyncMock(return_value=user_id)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.routes.auth.get_user_auth',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user_auth,
|
||||
),
|
||||
patch(
|
||||
'server.routes.auth.UserStore.get_user_by_id',
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_user,
|
||||
),
|
||||
):
|
||||
result = await onboarding_status(mock_request)
|
||||
|
||||
assert isinstance(result, JSONResponse)
|
||||
assert result.status_code == status.HTTP_200_OK
|
||||
body = json.loads(result.body)
|
||||
assert body == {'should_complete_onboarding': False}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user