Compare commits

..

69 Commits

Author SHA1 Message Date
openhands
982db9c6d8 Fix LLMProfiles serialization in user creation
The get_kwargs_from_settings method was passing LLMProfiles objects
directly to User model, which SQLAlchemy's EncryptedJSON column
couldn't serialize with json.dumps().

Fix: Explicitly serialize LLMProfiles to dict using model_dump(mode='json')
before passing to User model.

Fixes: TypeError: Object of type LLMProfiles is not JSON serializable

Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-28 17:39:11 +00:00
simonrosenberg
25262a3a3f fix(acp): defensive fallback for api_key_env_var missing from pinned SDK (#14185)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:51:17 -06:00
Xingyao Wang
862c363ded Bump SDK packages to v1.19.0 (#14180)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-28 11:34:50 -04:00
simonrosenberg
cf156b0073 feat(acp): inject user secrets into ACP subprocess env (#14171)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-28 08:32:36 -07:00
Hiep Le
703a1eeca2 fix(backend): persist keycloak email on invitation acceptance (#14059) 2026-04-28 22:19:20 +07:00
simonrosenberg
a6573de584 feat(enterprise): add migration 109 for agent_kind column in conversation_metadata (#14179)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:18:16 +00:00
Vasco Schiavo
23b3b188c4 feat(settings): add saved LLM profiles (BE) (#14146)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-28 15:09:26 +00:00
simonrosenberg
2ff094b363 feat(app-server): route ACP agents to the ACP conversation endpoint (#14004)
Co-authored-by: Debug Agent <debug@example.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:43:33 -07:00
Tim O'Farrell
b0169342f7 Remove ConversationMetadata dataclass and use UUID directly (#14176)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 21:42:01 -06:00
Tim O'Farrell
d5036c2813 refactor: Use direct file API in FileSettingsStore and FileSecretsStore (#14172)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 20:42:30 -06:00
Tim O'Farrell
8f0f3e49c8 refactor: move Settings and Secrets models to app_server (#14170)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 19:03:50 -06:00
Chris Bagwell
03f49a40a0 fix(settings): Only convert litellm_proxy/ to openhands/ for OpenHands proxy (#14173) 2026-04-27 23:27:40 +00:00
Tim O'Farrell
3a85dbce78 Remove deprecated V0 issue interface and migrate ConversationTrigger imports (#14169) 2026-04-27 15:58:50 -06:00
Joe Laverty
4e63531fa6 feat(enterprise): Self hosted gitlab support (#14141) 2026-04-27 17:22:21 -04:00
HeyItsChloe
db48a7af26 FE: Restore flag to block traffic to /onboarding (#14166) 2026-04-27 14:16:51 -07:00
Tim O'Farrell
4c8179cd08 Remove deprecated webhook file stores (V0 cleanup) (#14164)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 14:10:35 -06:00
Tim O'Farrell
9e3aed7f53 refactor: move settings and secrets stores to app_server (#14165)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 13:54:33 -06:00
Tim O'Farrell
3a40ecb931 Remove deprecated openhands.events package (V0) (#14162)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 12:53:41 -06:00
Tim O'Farrell
f8b4f9369f refactor: remove unused methods from ProviderHandler (#14160)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 12:23:43 -06:00
Tim O'Farrell
5bb6522f2f Remove dead code from enterprise/integrations/utils.py (#14161)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 12:11:29 -06:00
Tim O'Farrell
273c38f0b6 Remove ConversationCallback class (replaced by V1 EventCallback) (#14159)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 11:49:01 -06:00
Tim O'Farrell
02b999c166 Remove dead feedback routes (V0 code) (#14158)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 11:40:07 -06:00
Tim O'Farrell
28d26f8178 Remove dead MonitoringListener code (#14157)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 11:10:57 -06:00
Graham Neubig
2468708293 fix: restore local integration token removal in OSS settings (#14155)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-27 16:59:51 +00:00
Tim O'Farrell
a89811f952 refactor: move ConversationTrigger to app_conversation_models and remove unused data models (#14156)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 10:50:14 -06:00
Rohit Malhotra
aef5f9cc89 Make archived conversations read-only without loading states (#14077)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-27 12:32:47 -04:00
Tim O'Farrell
aea611602f Remove openhands.llm package (legacy V0 code) (#14154)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 10:13:24 -06:00
Tim O'Farrell
fc4c62a73d Removed V0 conversation stats (#14152) 2026-04-27 09:51:35 -06:00
Hiep Le
b41dd2ba8b fix: enforce onboarding completion on every navigation (#14142)
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-27 22:35:18 +07:00
Tim O'Farrell
731183e069 Remove enterprise/integrations/solvability package and related dead code (#14150)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 09:20:29 -06:00
Tim O'Farrell
c22c03eeb6 Remove ConversationStore interface and implementations (V1 migration cleanup) (#14147)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 08:05:53 -06:00
Hiep Le
1093afdced fix(frontend): prevent duplicate payment successful toast on stripe checkout return (#14143)
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-27 20:40:25 +07:00
Hiep Le
93355fd770 fix(frontend): block /settings/org-defaults* routes in OSS mode (#14144)
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-27 20:40:11 +07:00
Tim O'Farrell
6464eaed3c Remove v1_enabled flag from resolver integrations (#14145)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-27 07:36:34 -06:00
Tim O'Farrell
237948978b V0 Code Removals: Conversation Validator, MCP Updates, and Cleanup (#14135)
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-27 06:51:15 -06:00
Graham Neubig
baa3a7e5b7 Refactor verification settings to use schema rendering (#13978)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-25 10:22:23 -05:00
Engel Nyst
dd7234d712 ci: run PR review on fork PRs (#14109)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-25 03:20:34 +02:00
Juan Michelini
2a6f5c8976 feat: Auto-forward LMNR_* environment variables to agent-server (#14123)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 20:49:48 -03:00
Tim O'Farrell
e86067c15b Removed V0 runtime (#14117) 2026-04-24 15:40:37 -06:00
aivong-openhands
137bede1f5 APP-1325: show GitLab/Slack sections without GitHub App configured (#14097) 2026-04-24 15:10:38 -04:00
Tim O'Farrell
8a1d80ac8f Removed Architecture diagrams (#14120) 2026-04-24 12:45:02 -06:00
Tim O'Farrell
77043da280 Removed V0 third party runtimes (#14119) 2026-04-24 12:23:01 -06:00
Tim O'Farrell
180a35f013 Removed V0 controller (#14060) 2026-04-24 11:05:17 -06:00
Tim O'Farrell
18365e0323 APP-1359 Removed V0 microagent Package (#14053) 2026-04-24 09:28:19 -06:00
aivong-openhands
9a743ff51a APP-1325: register GitlabV1CallbackProcessor for deserialization (#14110) 2026-04-24 11:01:06 -04:00
Graham Neubig
29577935b4 fix: preserve LLM and MCP settings in migration 108 (#14112)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 14:36:12 +00:00
Tim O'Farrell
7498353ed5 APP-1360 Removed V0 memory package (#14057) 2026-04-24 08:22:16 -06:00
Tim O'Farrell
b62bdfd143 chore: delete unused Python code identified by vulture analysis (#14111)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 07:36:57 -06:00
Tim O'Farrell
fb98faf4ac refactor: remove external dependencies on V0 packages (controller, memory, microagent) (#14106)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 17:09:46 -06:00
John-Mason P. Shackelford
a8f62aa30c feat: add secrets field to AppConversationStartRequest for direct API secret passing (#14009)
Add the ability for API callers to pass secrets directly when starting
a conversation, without requiring them to be pre-stored in the database.

Changes:
- Add optional `secrets: dict[str, SecretStr]` field to
  AppConversationStartRequest model
- Update `_build_start_conversation_request_for_user()` to merge
  API-provided secrets with existing secrets (from git providers/database)
- API-provided secrets take precedence over existing secrets with same name
- Add new `openhands/app_server/constants.py` with secret validation:
  - Blocked names: container config vars (OH_*, WORKER_*, etc.)
  - Blocked prefixes: LLM_* (to enforce app-server LLM controls)
  - Configurable size limits via environment variables
- Add warning log when API secrets override existing secrets
- Bump agent-server image to 1.18.1-python (SDK v1.18.1 with MCP
  secrets expansion support)

Closes #14007
2026-04-23 18:23:31 -04:00
Tim O'Farrell
1a7449b03a Remove dead code. (#14103) 2026-04-23 13:42:40 -06:00
Rohit Malhotra
1091901be2 Fix: Register SetTitleCallbackProcessor for webhook-created conversations (#14102)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 14:53:37 -04:00
Hiep Le
15160f6733 fix(frontend): show members a read-only badge on org-defaults pages (#14098) 2026-04-23 23:52:43 +07:00
Graham Neubig
13dba59bb8 Fix enterprise migration 108 settings mapping (#14088)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 12:47:37 -04:00
Tim O'Farrell
478c998f04 APP-1363 : Remove V0 io Package (#14094) 2026-04-23 09:31:01 -06:00
Tim O'Farrell
a9fc93ffbf More pieces of V0 carved off (#14089) 2026-04-23 08:26:40 -06:00
Tim O'Farrell
cc100c0d10 Removed the V0 resolver (#14062) 2026-04-23 07:48:32 -06:00
Rohit Malhotra
7bc3300981 Add missing SqlAlchemy type stub to mypy (#13413)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:52:27 +00:00
Rohit Malhotra
3e0283796e fix: add return type annotation for ConversationMetadata conversion (SQLAlchemy typing PR7) (#14081)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:37:18 +00:00
Rohit Malhotra
cd0175d83e fix: correct return types and remove unreachable code (SQLAlchemy typing PR6) (#14079)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:17:11 +00:00
Rohit Malhotra
f313cfceb9 fix: correct SQLAlchemy type annotations in DbSessionInjector (#14075)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 16:13:39 -04:00
Rohit Malhotra
fb0108f946 fix: handle nullable arguments in enterprise code (#14078)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 16:10:08 -04:00
Rohit Malhotra
6b29a82de3 fix: correct SQLAlchemy Result and Table type annotations (#14076)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 19:43:14 +00:00
Rohit Malhotra
033c6202b7 fix: handle nullable datetime in _fix_timezone methods (#14073)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 19:16:26 +00:00
Graham Neubig
d64d0d6bf6 Hide All toggle on SaaS LLM settings (#14013)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: allhands-bot <allhands-bot@users.noreply.github.com>
2026-04-22 15:13:57 -04:00
aivong-openhands
b357c0c3bb Fix CVE-2026-39892: Update cryptography to 46.0.7 (#13968)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-04-22 21:07:29 +02:00
Rohit Malhotra
16374dc9c0 fix: add ColumnElement type annotation for SQLAlchemy filter conditions (#14072)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 19:06:08 +00:00
Graham Neubig
a8926068ff fix: restore org settings payload contract (#14051)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-04-22 15:03:40 -04:00
chuckbutkus
f318792a17 security: Invalidate SESSION_API_KEY on pause and require RUNNING status (#14001)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-04-22 12:49:28 -06:00
696 changed files with 10609 additions and 97175 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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.`
});

View File

@@ -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' &&

View File

@@ -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:

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -3,9 +3,9 @@ repos:
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
exclude: ^(docs/|modules/|python/|openhands-ui/|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/

View File

@@ -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

View File

@@ -1,5 +1,5 @@
# Exclude third-party runtime directory from linting
exclude = ["third_party/", "enterprise/"]
exclude = ["enterprise/"]
[lint]
select = [

View File

@@ -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

View File

@@ -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",

View File

@@ -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')

View File

@@ -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

View File

@@ -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(

View File

@@ -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()

View File

@@ -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:

View File

@@ -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:

View File

@@ -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}')

View File

@@ -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,

View File

@@ -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()

View File

@@ -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]:

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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)

View File

@@ -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}**'

View File

@@ -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]

View File

@@ -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.
"""

View File

@@ -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.
"""

View File

@@ -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

View File

@@ -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']

View File

@@ -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".

View File

@@ -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.

View File

@@ -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',

View File

@@ -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()

View File

@@ -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

View File

@@ -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')

View File

@@ -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')

View File

@@ -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
View File

@@ -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 = ".."

View File

@@ -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(

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 = {

View File

@@ -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(

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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

View File

@@ -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'

View File

@@ -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})'

View File

@@ -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 orgwide 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')

View File

@@ -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(

View File

@@ -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()

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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',

View File

@@ -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()

View File

@@ -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()

View File

@@ -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))

View File

@@ -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
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()),
},
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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=(

View File

@@ -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
)

View File

@@ -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 {}),

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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()

View File

@@ -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."""

View File

@@ -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

View File

@@ -215,7 +215,6 @@ def new_conversation_view(
conversation_id='conv-123',
_decrypted_api_key='decrypted_key',
)
view.v1_enabled = False
return view

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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