mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
120 Commits
enterprise
...
fix-basic-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
226d1f8a83 | ||
|
|
3a9ef1a538 | ||
|
|
b23ab33a01 | ||
|
|
a9ede73391 | ||
|
|
634c2439b4 | ||
|
|
a1989a40b3 | ||
|
|
e38f1283ea | ||
|
|
b607bc20ca | ||
|
|
07eb791735 | ||
|
|
c355c4819f | ||
|
|
9d8e4c44cc | ||
|
|
25cc55e558 | ||
|
|
0e825c38d7 | ||
|
|
ce04e70b5b | ||
|
|
7b0589ad40 | ||
|
|
682465a862 | ||
|
|
1bb4c844d4 | ||
|
|
d6c11fe517 | ||
|
|
b088d4857e | ||
|
|
0f05898d55 | ||
|
|
d1f0a01a57 | ||
|
|
f5a9d28999 | ||
|
|
afa0417608 | ||
|
|
e688fba761 | ||
|
|
d1ec5cbdf6 | ||
|
|
f42625f789 | ||
|
|
fe28519677 | ||
|
|
e62ceafa4a | ||
|
|
0b8c69fad2 | ||
|
|
37d9b672a4 | ||
|
|
c8b867a634 | ||
|
|
59834beba7 | ||
|
|
d2eced9cff | ||
|
|
7836136ff8 | ||
|
|
fdb04dfe5d | ||
|
|
3d4cb89441 | ||
|
|
9fb9efd3d2 | ||
|
|
5511c01c2e | ||
|
|
02825fb5bb | ||
|
|
876e773589 | ||
|
|
9e1ae86191 | ||
|
|
df47b7b79d | ||
|
|
7d1c105b55 | ||
|
|
db6a9e8895 | ||
|
|
d76ac44dc3 | ||
|
|
c483c80a3c | ||
|
|
570ab904f6 | ||
|
|
00a74731ae | ||
|
|
102095affb | ||
|
|
b6ce45b474 | ||
|
|
11c87caba4 | ||
|
|
b8a608c45e | ||
|
|
8a446787be | ||
|
|
353124e171 | ||
|
|
e9298c89bd | ||
|
|
29b77be807 | ||
|
|
7094835ef0 | ||
|
|
7ad0eec325 | ||
|
|
31d5081163 | ||
|
|
250736cb7a | ||
|
|
a9bd3a70c9 | ||
|
|
d7436a4af4 | ||
|
|
f327e76be7 | ||
|
|
52e39e5d12 | ||
|
|
6c5ef256fd | ||
|
|
373ade8554 | ||
|
|
9d0a19cf8f | ||
|
|
d60dd38d78 | ||
|
|
d5ee799670 | ||
|
|
b685fd43dd | ||
|
|
0e04f6fdbe | ||
|
|
9c40929197 | ||
|
|
af309e8586 | ||
|
|
cc5d5c2335 | ||
|
|
60e668f4a7 | ||
|
|
743f6256a6 | ||
|
|
a87b4efd41 | ||
|
|
730d9970f5 | ||
|
|
2440593431 | ||
|
|
8c94ddbf1a | ||
|
|
af60183f22 | ||
|
|
40fb69325f | ||
|
|
f9891a2c0d | ||
|
|
44f1bb022b | ||
|
|
95c2a0285c | ||
|
|
8ea4813936 | ||
|
|
650bf8c0c0 | ||
|
|
dd4bb5d362 | ||
|
|
b3137e7ae8 | ||
|
|
0d740925c5 | ||
|
|
030ff59c40 | ||
|
|
3bc56740b9 | ||
|
|
87a5762bf2 | ||
|
|
5b149927fb | ||
|
|
275bb689e4 | ||
|
|
d595945309 | ||
|
|
e8ba741f28 | ||
|
|
57929b73ee | ||
|
|
9fd4e42438 | ||
|
|
0176cd9669 | ||
|
|
2e9b29970e | ||
|
|
a07fc1510b | ||
|
|
78f067acef | ||
|
|
0d5f97c8c7 | ||
|
|
a987387353 | ||
|
|
941b6dd340 | ||
|
|
23251a2487 | ||
|
|
6af8301996 | ||
|
|
b57ec9eda1 | ||
|
|
c8594a2eaa | ||
|
|
102715a3c9 | ||
|
|
d5e66b4f3a | ||
|
|
f5315887ec | ||
|
|
6e4ac8e2ce | ||
|
|
efb54fd791 | ||
|
|
53f86955e0 | ||
|
|
b5a615aa67 | ||
|
|
3cb76f5cbb | ||
|
|
91fb97a13f | ||
|
|
9af3ee8298 |
137
.github/ISSUE_TEMPLATE/bug_template.yml
vendored
137
.github/ISSUE_TEMPLATE/bug_template.yml
vendored
@@ -5,52 +5,113 @@ labels: ['bug']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for taking the time to fill out this bug report. Please provide as much information as possible
|
||||
to help us understand and address the issue effectively.
|
||||
value: |
|
||||
## Thank you for reporting a bug! 🐛
|
||||
|
||||
**Please fill out all required fields.** Issues missing critical information (version, installation method, reproduction steps, etc.) will be delayed or closed until complete details are provided.
|
||||
|
||||
Clear, detailed reports help us resolve issues faster.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for the same bug? (If one exists, thumbs up or comment on the issue instead).
|
||||
description: Please check if an issue already exists for the bug you encountered.
|
||||
label: Is there an existing issue for the same bug?
|
||||
description: Please search existing issues before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
|
||||
options:
|
||||
- label: I have checked the existing issues.
|
||||
- label: I have searched existing issues and this is not a duplicate.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: Describe the bug and reproduction steps
|
||||
description: Provide a description of the issue along with any reproduction steps.
|
||||
label: Bug Description
|
||||
description: Clearly describe what went wrong. Be specific and concise.
|
||||
placeholder: Example - "When I run a Python task, OpenHands crashes after 30 seconds with a connection timeout error."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Example - "OpenHands should execute the Python script and return results."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Example - "Connection timed out after 30 seconds, task failed with error code 500."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Provide clear, step-by-step instructions to reproduce the bug.
|
||||
placeholder: |
|
||||
1. Install OpenHands using Docker
|
||||
2. Configure with Claude 3.5 Sonnet
|
||||
3. Run command: `openhands run "write a python script"`
|
||||
4. Wait 30 seconds
|
||||
5. Error appears
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: installation
|
||||
attributes:
|
||||
label: OpenHands Installation
|
||||
label: OpenHands Installation Method
|
||||
description: How are you running OpenHands?
|
||||
options:
|
||||
- Docker command in README
|
||||
- GitHub resolver
|
||||
- CLI (uv tool install)
|
||||
- CLI (executable binary)
|
||||
- CLI (Docker)
|
||||
- Local GUI (Docker web interface)
|
||||
- OpenHands Cloud (app.all-hands.dev)
|
||||
- SDK (Python library)
|
||||
- Development workflow
|
||||
- CLI
|
||||
- app.all-hands.dev
|
||||
- Other
|
||||
default: 0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: installation-other
|
||||
attributes:
|
||||
label: If you selected "Other", please specify
|
||||
description: Describe your installation method
|
||||
placeholder: ex. Custom Kubernetes deployment, pip install from source, etc.
|
||||
|
||||
- type: input
|
||||
id: openhands-version
|
||||
attributes:
|
||||
label: OpenHands Version
|
||||
description: What version of OpenHands are you using?
|
||||
placeholder: ex. 0.9.8, main, etc.
|
||||
description: What version are you using? Find this in settings or by running `openhands --version`
|
||||
placeholder: ex. 0.9.8, main, commit hash, etc.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: version-confirmation
|
||||
attributes:
|
||||
label: Version Confirmation
|
||||
description: Bugs on older versions may already be fixed. Please upgrade before submitting.
|
||||
options:
|
||||
- label: "I have confirmed this bug exists on the LATEST version of OpenHands"
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: model-name
|
||||
attributes:
|
||||
label: Model Name
|
||||
description: What model are you using?
|
||||
placeholder: ex. gpt-4o, claude-3-5-sonnet, openrouter/deepseek-r1, etc.
|
||||
description: Which LLM model are you using?
|
||||
placeholder: ex. gpt-4o, claude-3-5-sonnet-20241022, openrouter/deepseek-r1, etc.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
@@ -60,12 +121,46 @@ body:
|
||||
- MacOS
|
||||
- Linux
|
||||
- WSL on Windows
|
||||
- Windows (Docker Desktop)
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser (if using web UI)
|
||||
description: |
|
||||
If applicable, which browser and version?
|
||||
|
||||
placeholder: ex. Chrome 131, Firefox 133, Safari 17.2
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs and Error Messages
|
||||
description: |
|
||||
**Paste relevant logs, error messages, or stack traces.** Use code blocks (```) for formatting.
|
||||
|
||||
LLM logs are in `logs/llm/default/`. Include timestamps if errors occurred at a specific time.
|
||||
placeholder: |
|
||||
```
|
||||
Paste error logs here
|
||||
```
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Logs, Errors, Screenshots, and Additional Context
|
||||
description: Please provide any additional information you think might help. If you want to share the chat history
|
||||
you can click the thumbs-down (👎) button above the input field and you will get a shareable link
|
||||
(you can also click thumbs up when things are going well of course!). LLM logs will be stored in the
|
||||
`logs/llm/default` folder. Please add any additional context about the problem here.
|
||||
label: Screenshots and Additional Context
|
||||
description: |
|
||||
Add screenshots, videos, runtime environment, or other context that helps explain the issue.
|
||||
|
||||
💡 **Share conversation history:** In the OpenHands chat UI, click the 👎 or 👍 button (above the message input) to generate a shareable link to your conversation.
|
||||
|
||||
placeholder: Drag and drop screenshots here, paste links, or add additional context.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
**Note:** Issues with incomplete information may be closed or deprioritized. Maintainers and community members have limited bandwidth and prioritize well-documented bugs that are easier to reproduce and fix. Thank you for your understanding!
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
17
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: Feature Request or Enhancement
|
||||
about: Suggest an idea for an OpenHands feature or enhancement
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**What problem or use case are you trying to solve?**
|
||||
|
||||
**Describe the UX or technical implementation you have in mind**
|
||||
|
||||
**Additional context**
|
||||
|
||||
|
||||
### If you find this feature request or enhancement useful, make sure to add a 👍 to the issue
|
||||
105
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
105
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Feature Request or Enhancement
|
||||
description: Suggest a new feature or improvement for OpenHands
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Thank you for suggesting a feature! 💡
|
||||
|
||||
**Please provide detailed information.** Vague or low-effort requests may be closed. Well-documented feature requests with strong community support are more likely to be added to the roadmap.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing feature request for this?
|
||||
description: Please search existing issues and feature requests before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
|
||||
options:
|
||||
- label: I have searched existing issues and feature requests, and this is not a duplicate.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: Problem or Use Case
|
||||
description: What problem are you trying to solve? What use case would this feature enable?
|
||||
placeholder: |
|
||||
Example - "As a developer working on large codebases, I need to search across multiple files simultaneously. Currently, I have to search file-by-file which is time-consuming and inefficient."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe your ideal solution. What should this feature do? How should it work?
|
||||
placeholder: |
|
||||
Example - "Add a global search feature that allows searching across all files in the workspace. Results should show file name, line number, and context around matches. Include regex support and filtering options."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions or workarounds? What are their limitations?
|
||||
placeholder: Example - "I tried using grep in the terminal, but it's not integrated with the UI and doesn't provide click-to-navigate functionality."
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: Priority / Severity
|
||||
description: How important is this feature to your workflow?
|
||||
options:
|
||||
- "Critical - Blocking my work, no workaround available"
|
||||
- "High - Significant impact on productivity"
|
||||
- "Medium - Would improve experience"
|
||||
- "Low - Nice to have"
|
||||
default: 2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Estimated Scope
|
||||
description: To the best of your knowledge, how complex do you think this feature would be to implement?
|
||||
options:
|
||||
- "Small - UI tweak, config option, or minor change"
|
||||
- "Medium - New feature with moderate complexity"
|
||||
- "Large - Significant feature requiring architecture changes"
|
||||
- "Unknown - Not sure about the technical complexity"
|
||||
default: 3
|
||||
|
||||
- type: dropdown
|
||||
id: feature-area
|
||||
attributes:
|
||||
label: Feature Area
|
||||
description: Which part of OpenHands does this feature relate to? If you select "Other", please specify the area in the Additional Context section below.
|
||||
options:
|
||||
- "Agent / AI behavior"
|
||||
- "User Interface / UX"
|
||||
- "CLI / Command-line interface"
|
||||
- "File system / Workspace management"
|
||||
- "Configuration / Settings"
|
||||
- "Integrations (GitHub, GitLab, etc.)"
|
||||
- "Performance / Optimization"
|
||||
- "Documentation"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: technical-details
|
||||
attributes:
|
||||
label: Technical Implementation Ideas (Optional)
|
||||
description: If you have technical expertise, share implementation ideas, API suggestions, or relevant technical details.
|
||||
placeholder: |
|
||||
Example - "Could use ripgrep library for fast search. Expose results via /api/search endpoint. Frontend can use virtualized list for rendering large result sets."
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, screenshots, mockups, or examples that help illustrate this feature request.
|
||||
placeholder: Drag and drop screenshots, mockups, or links here.
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -1,4 +1,4 @@
|
||||
<!-- Ideally you should open a PR when it is ready for review. Draft PRs will not be reviewed -->
|
||||
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
|
||||
|
||||
## Summary of PR
|
||||
|
||||
|
||||
6
.github/workflows/ghcr-build.yml
vendored
6
.github/workflows/ghcr-build.yml
vendored
@@ -39,8 +39,7 @@ jobs:
|
||||
run: |
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
|
||||
]')
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
@@ -149,6 +148,9 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAGS }}
|
||||
platforms: ${{ env.DOCKER_PLATFORM }}
|
||||
# Caching directives to boost performance
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }},mode=max
|
||||
build-args: ${{ env.DOCKER_BUILD_ARGS }}
|
||||
context: containers/runtime
|
||||
provenance: false
|
||||
|
||||
@@ -6,11 +6,12 @@ Thanks for your interest in contributing to OpenHands! We welcome and appreciate
|
||||
|
||||
To understand the codebase, please refer to the README in each module:
|
||||
- [frontend](./frontend/README.md)
|
||||
- [evaluation](./evaluation/README.md)
|
||||
- [openhands](./openhands/README.md)
|
||||
- [agenthub](./openhands/agenthub/README.md)
|
||||
- [server](./openhands/server/README.md)
|
||||
|
||||
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
|
||||
|
||||
## Setting up Your Development Environment
|
||||
|
||||
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
|
||||
|
||||
@@ -161,7 +161,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.1-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
@@ -200,7 +200,7 @@ Here's a guide to the important documentation files in the repository:
|
||||
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
|
||||
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [/evaluation/README.md](./evaluation/README.md): Documentation for the evaluation framework and benchmarks
|
||||
- [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/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -440,12 +440,6 @@ type = "noop"
|
||||
#temperature = 0.1
|
||||
#max_input_tokens = 1024
|
||||
|
||||
#################################### Eval ####################################
|
||||
# Configuration for the evaluation, please refer to the specific evaluation
|
||||
# plugin for the available options
|
||||
##############################################################################
|
||||
|
||||
|
||||
########################### Kubernetes #######################################
|
||||
# Kubernetes configuration when using the Kubernetes runtime
|
||||
##############################################################################
|
||||
|
||||
@@ -12,7 +12,8 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:1.1-nikolaik}
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/runtime}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-1.2-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,8 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:1.1-nikolaik}
|
||||
- AGENT_SERVER_IMAGE_REPOSITORY=${AGENT_SERVER_IMAGE_REPOSITORY:-ghcr.io/openhands/agent-server}
|
||||
- AGENT_SERVER_IMAGE_TAG=${AGENT_SERVER_IMAGE_TAG:-31536c8-python}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# ============================================
|
||||
# OpenHands Enterprise Local Development
|
||||
# Environment Variables Example
|
||||
# ============================================
|
||||
# Copy this to .env and fill in your values
|
||||
|
||||
# ============================================
|
||||
# Database Configuration
|
||||
# ============================================
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASS=postgres
|
||||
DB_NAME=openhands
|
||||
|
||||
# ============================================
|
||||
# Redis Configuration
|
||||
# ============================================
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# ============================================
|
||||
# Keycloak Configuration
|
||||
# ============================================
|
||||
# For local development with the docker-compose setup
|
||||
KEYCLOAK_SERVER_URL=http://localhost:8080
|
||||
KEYCLOAK_SERVER_URL_EXT=http://localhost:8080
|
||||
KEYCLOAK_REALM_NAME=openhands
|
||||
KEYCLOAK_CLIENT_ID=openhands-client
|
||||
KEYCLOAK_CLIENT_SECRET=your-client-secret
|
||||
KEYCLOAK_PROVIDER_NAME=github
|
||||
KEYCLOAK_ADMIN_PASSWORD=admin
|
||||
|
||||
# ============================================
|
||||
# LiteLLM Configuration
|
||||
# ============================================
|
||||
LITE_LLM_API_URL=http://localhost:4000
|
||||
LITE_LLM_API_KEY=sk-local-dev-master-key
|
||||
LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514
|
||||
|
||||
# ============================================
|
||||
# OpenHands Configuration
|
||||
# ============================================
|
||||
OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig
|
||||
OPENHANDS_GITHUB_SERVICE_CLS=integrations.github.github_service.SaaSGitHubService
|
||||
OPENHANDS_GITLAB_SERVICE_CLS=integrations.gitlab.gitlab_service.SaaSGitLabService
|
||||
OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService
|
||||
OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator
|
||||
|
||||
# ============================================
|
||||
# GitHub App Configuration (Optional)
|
||||
# Required for GitHub OAuth and GitHub integration features
|
||||
# Create a GitHub App at: https://github.com/settings/apps
|
||||
# ============================================
|
||||
# GITHUB_APP_CLIENT_ID=
|
||||
# GITHUB_APP_CLIENT_SECRET=
|
||||
# GITHUB_APP_WEBHOOK_SECRET=
|
||||
# GITHUB_APP_PRIVATE_KEY=
|
||||
|
||||
# ============================================
|
||||
# GitLab App Configuration (Optional)
|
||||
# Required for GitLab OAuth and integration features
|
||||
# ============================================
|
||||
# GITLAB_APP_CLIENT_ID=
|
||||
# GITLAB_APP_CLIENT_SECRET=
|
||||
|
||||
# ============================================
|
||||
# Bitbucket App Configuration (Optional)
|
||||
# ============================================
|
||||
# BITBUCKET_APP_CLIENT_ID=
|
||||
# BITBUCKET_APP_CLIENT_SECRET=
|
||||
|
||||
# ============================================
|
||||
# Feature Flags
|
||||
# ============================================
|
||||
ENABLE_BILLING=false
|
||||
HIDE_LLM_SETTINGS=false
|
||||
ENABLE_JIRA=false
|
||||
ENABLE_JIRA_DC=false
|
||||
ENABLE_LINEAR=false
|
||||
LOCAL_DEPLOYMENT=true
|
||||
|
||||
# ============================================
|
||||
# Frontend Configuration
|
||||
# ============================================
|
||||
# Path to the frontend build directory
|
||||
# FRONTEND_DIRECTORY=../frontend/build
|
||||
|
||||
# ============================================
|
||||
# Logging
|
||||
# ============================================
|
||||
LOG_PLAIN_TEXT=1
|
||||
|
||||
# ============================================
|
||||
# Runtime Configuration
|
||||
# ============================================
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
|
||||
# ============================================
|
||||
# PostHog (Analytics - can use dummy for local)
|
||||
# ============================================
|
||||
POSTHOG_CLIENT_KEY=test
|
||||
|
||||
# ============================================
|
||||
# LLM API Keys (for LiteLLM proxy)
|
||||
# Add these to litellm container environment
|
||||
# ============================================
|
||||
# OPENAI_API_KEY=
|
||||
# ANTHROPIC_API_KEY=
|
||||
# GOOGLE_API_KEY=
|
||||
|
||||
# ============================================
|
||||
# CORS Configuration
|
||||
# ============================================
|
||||
WEB_HOST=localhost:3000
|
||||
PERMITTED_CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
@@ -1,323 +0,0 @@
|
||||
# OpenHands Enterprise Local Development Setup
|
||||
|
||||
This guide provides instructions for setting up OpenHands Enterprise locally for development.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** and **Docker Compose** (v2+)
|
||||
- **Python 3.12+**
|
||||
- **Poetry** (Python package manager)
|
||||
- **Node.js 18+** and **npm** (for frontend)
|
||||
- An LLM API key (OpenAI, Anthropic, or other supported provider)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start Infrastructure Services
|
||||
|
||||
Start all required services using Docker Compose:
|
||||
|
||||
```bash
|
||||
cd enterprise
|
||||
|
||||
# Start PostgreSQL, Redis, Keycloak, and LiteLLM
|
||||
docker-compose -f docker-compose.local.yml up -d
|
||||
```
|
||||
|
||||
This will start:
|
||||
- **PostgreSQL** on port `5432` (database for OpenHands and Keycloak)
|
||||
- **Redis** on port `6379` (caching and pub/sub)
|
||||
- **Keycloak** on port `8080` (authentication)
|
||||
- **LiteLLM** on port `4000` (LLM proxy)
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
|
||||
Copy the example environment file and configure it:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your LLM API keys. For LiteLLM to work, you need at least one of:
|
||||
- `OPENAI_API_KEY` - for GPT models
|
||||
- `ANTHROPIC_API_KEY` - for Claude models
|
||||
|
||||
Update the docker-compose to include your API keys:
|
||||
|
||||
```bash
|
||||
# Add to litellm service environment in docker-compose.local.yml
|
||||
# Or create a .env file for docker-compose
|
||||
|
||||
# Then restart litellm
|
||||
docker-compose -f docker-compose.local.yml up -d litellm
|
||||
```
|
||||
|
||||
### 3. Initialize the Database
|
||||
|
||||
Run Alembic migrations to set up the database schema:
|
||||
|
||||
```bash
|
||||
cd enterprise
|
||||
poetry install
|
||||
poetry run alembic upgrade head
|
||||
```
|
||||
|
||||
### 4. Configure Keycloak (Authentication)
|
||||
|
||||
#### Option A: Skip Keycloak (Simplified Setup)
|
||||
|
||||
For basic local development without authentication, you can skip Keycloak by not setting `GITHUB_APP_CLIENT_ID` and related variables. The app will still work but OAuth login won't be available.
|
||||
|
||||
#### Option B: Set Up Keycloak Realm
|
||||
|
||||
1. Access Keycloak Admin Console at `http://localhost:8080`
|
||||
2. Login with `admin` / `admin`
|
||||
3. Create a new realm called `openhands`
|
||||
4. Create a client:
|
||||
- Client ID: `openhands-client`
|
||||
- Client authentication: ON
|
||||
- Valid redirect URIs: `http://localhost:3000/*`, `http://localhost:3001/*`
|
||||
5. Copy the client secret and update your `.env`:
|
||||
```
|
||||
KEYCLOAK_CLIENT_SECRET=<your-client-secret>
|
||||
```
|
||||
|
||||
### 5. Build the Frontend
|
||||
|
||||
```bash
|
||||
# From the repository root (not enterprise/)
|
||||
cd ..
|
||||
make build-frontend
|
||||
# OR
|
||||
cd frontend && npm install && npm run build
|
||||
```
|
||||
|
||||
### 6. Start the Enterprise Backend
|
||||
|
||||
```bash
|
||||
cd enterprise
|
||||
|
||||
# Option A: Using Make
|
||||
OPENHANDS_PATH=../ make start-backend
|
||||
|
||||
# Option B: Using Poetry directly
|
||||
FRONTEND_DIRECTORY=../frontend/build poetry run uvicorn saas_server:app --host 127.0.0.1 --port 3000 --reload
|
||||
```
|
||||
|
||||
### 7. Access the Application
|
||||
|
||||
- **Frontend**: http://localhost:3000 (or 3001 if using dev server)
|
||||
- **Backend API**: http://localhost:3000/api
|
||||
- **Keycloak Admin**: http://localhost:8080
|
||||
- **LiteLLM Proxy**: http://localhost:4000
|
||||
|
||||
## Individual Service Setup
|
||||
|
||||
If you prefer to run services individually:
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name openhands-postgres \
|
||||
-p 5432:5432 \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=openhands \
|
||||
postgres:15
|
||||
```
|
||||
|
||||
### Redis
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name openhands-redis \
|
||||
-p 6379:6379 \
|
||||
redis:7-alpine
|
||||
```
|
||||
|
||||
### Keycloak
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name openhands-keycloak \
|
||||
-p 8080:8080 \
|
||||
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
|
||||
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
|
||||
-e KC_HTTP_ENABLED=true \
|
||||
-e KC_HOSTNAME_STRICT=false \
|
||||
quay.io/keycloak/keycloak:26.1.1 start-dev
|
||||
```
|
||||
|
||||
### LiteLLM
|
||||
|
||||
```bash
|
||||
# Create litellm-config.yaml first (see file in this directory)
|
||||
docker run -d \
|
||||
--name openhands-litellm \
|
||||
-p 4000:4000 \
|
||||
-v $(pwd)/litellm-config.yaml:/app/config.yaml \
|
||||
-e LITELLM_MASTER_KEY=sk-local-dev-master-key \
|
||||
-e OPENAI_API_KEY=your-openai-key \
|
||||
-e ANTHROPIC_API_KEY=your-anthropic-key \
|
||||
ghcr.io/berriai/litellm:main-latest \
|
||||
--config /app/config.yaml --port 4000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
## Configuration Details
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DB_HOST` | PostgreSQL host | `localhost` |
|
||||
| `DB_PORT` | PostgreSQL port | `5432` |
|
||||
| `DB_USER` | PostgreSQL user | `postgres` |
|
||||
| `DB_PASS` | PostgreSQL password | `postgres` |
|
||||
| `DB_NAME` | PostgreSQL database name | `openhands` |
|
||||
| `REDIS_HOST` | Redis host | `localhost` |
|
||||
| `REDIS_PORT` | Redis port | `6379` |
|
||||
| `KEYCLOAK_SERVER_URL` | Keycloak internal URL | - |
|
||||
| `KEYCLOAK_SERVER_URL_EXT` | Keycloak external URL | - |
|
||||
| `KEYCLOAK_REALM_NAME` | Keycloak realm | - |
|
||||
| `KEYCLOAK_CLIENT_ID` | Keycloak client ID | - |
|
||||
| `KEYCLOAK_CLIENT_SECRET` | Keycloak client secret | - |
|
||||
| `LITE_LLM_API_URL` | LiteLLM proxy URL | `http://localhost:4000` |
|
||||
| `LITE_LLM_API_KEY` | LiteLLM master key | - |
|
||||
| `LITELLM_DEFAULT_MODEL` | Default model to use | `litellm_proxy/claude-sonnet-4-20250514` |
|
||||
| `OPENHANDS_CONFIG_CLS` | Server config class | `server.config.SaaSServerConfig` |
|
||||
| `LOCAL_DEPLOYMENT` | Flag for local mode | `true` |
|
||||
|
||||
### LiteLLM Configuration
|
||||
|
||||
The `litellm-config.yaml` file defines which LLM models are available through the proxy. You can add or modify models as needed.
|
||||
|
||||
To test if LiteLLM is working:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4000/v1/models
|
||||
```
|
||||
|
||||
### Keycloak Realm Setup for GitHub OAuth
|
||||
|
||||
To enable GitHub OAuth through Keycloak:
|
||||
|
||||
1. Create a GitHub OAuth App at https://github.com/settings/developers
|
||||
2. In Keycloak:
|
||||
- Go to Identity Providers > Add provider > GitHub
|
||||
- Enter your GitHub Client ID and Secret
|
||||
- Set redirect URI to match your Keycloak URL
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Connection Issues
|
||||
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
docker ps | grep postgres
|
||||
|
||||
# Check logs
|
||||
docker logs openhands-postgres
|
||||
|
||||
# Test connection
|
||||
psql -h localhost -U postgres -d openhands
|
||||
```
|
||||
|
||||
### Redis Connection Issues
|
||||
|
||||
```bash
|
||||
# Check if Redis is running
|
||||
docker ps | grep redis
|
||||
|
||||
# Test connection
|
||||
redis-cli -h localhost ping
|
||||
```
|
||||
|
||||
### Keycloak Issues
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs openhands-keycloak
|
||||
|
||||
# Access admin console
|
||||
open http://localhost:8080/admin
|
||||
```
|
||||
|
||||
### LiteLLM Issues
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker logs openhands-litellm
|
||||
|
||||
# Check health
|
||||
curl http://localhost:4000/health
|
||||
|
||||
# List available models
|
||||
curl http://localhost:4000/v1/models
|
||||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Connection refused" errors**: Ensure all Docker containers are running and ports are not blocked.
|
||||
|
||||
2. **Authentication failures**: Check Keycloak configuration and client credentials.
|
||||
|
||||
3. **LLM errors**: Verify your API keys are correctly set in the LiteLLM environment.
|
||||
|
||||
4. **Database migration errors**: Ensure PostgreSQL is running before running migrations.
|
||||
|
||||
## Stopping Services
|
||||
|
||||
```bash
|
||||
# Stop all services
|
||||
docker-compose -f docker-compose.local.yml down
|
||||
|
||||
# Stop and remove volumes (clears all data)
|
||||
docker-compose -f docker-compose.local.yml down -v
|
||||
```
|
||||
|
||||
## Development Tips
|
||||
|
||||
1. **Hot reloading**: Use `--reload` flag with uvicorn for automatic server restarts.
|
||||
|
||||
2. **Frontend development**: Run the frontend dev server separately for faster iteration:
|
||||
```bash
|
||||
cd ../frontend && npm run dev
|
||||
```
|
||||
|
||||
3. **Database changes**: After modifying models, create a new migration:
|
||||
```bash
|
||||
poetry run alembic revision --autogenerate -m "description"
|
||||
poetry run alembic upgrade head
|
||||
```
|
||||
|
||||
4. **Debugging**: Set `LOG_PLAIN_TEXT=1` for readable logs.
|
||||
|
||||
## Minimal Setup (Without Authentication)
|
||||
|
||||
For the simplest possible setup without OAuth:
|
||||
|
||||
```bash
|
||||
# Start only PostgreSQL and Redis
|
||||
docker run -d --name openhands-postgres -p 5432:5432 \
|
||||
-e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=openhands postgres:15
|
||||
|
||||
docker run -d --name openhands-redis -p 6379:6379 redis:7-alpine
|
||||
|
||||
# Create minimal .env
|
||||
cat > .env << 'EOF'
|
||||
DB_HOST=localhost
|
||||
REDIS_HOST=localhost
|
||||
OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig
|
||||
LOCAL_DEPLOYMENT=true
|
||||
POSTHOG_CLIENT_KEY=test
|
||||
LOG_PLAIN_TEXT=1
|
||||
EOF
|
||||
|
||||
# Run migrations and start server
|
||||
poetry install
|
||||
poetry run alembic upgrade head
|
||||
FRONTEND_DIRECTORY=../frontend/build poetry run uvicorn saas_server:app --host 0.0.0.0 --port 3000 --reload
|
||||
```
|
||||
|
||||
This provides a working enterprise server without Keycloak authentication or LiteLLM proxy. You can configure LLM directly through the UI or environment variables.
|
||||
@@ -2,7 +2,7 @@ BACKEND_HOST ?= "127.0.0.1"
|
||||
BACKEND_PORT = 3000
|
||||
BACKEND_HOST_PORT = "$(BACKEND_HOST):$(BACKEND_PORT)"
|
||||
FRONTEND_PORT = 3001
|
||||
OPENHANDS_PATH ?= "../../OpenHands"
|
||||
OPENHANDS_PATH ?= ".."
|
||||
OPENHANDS := $(OPENHANDS_PATH)
|
||||
OPENHANDS_FRONTEND_PATH = $(OPENHANDS)/frontend/build
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Docker Compose for running OpenHands Enterprise locally
|
||||
# Usage: docker-compose -f docker-compose.local.yml up -d
|
||||
|
||||
services:
|
||||
# PostgreSQL database
|
||||
postgres:
|
||||
image: postgres:15
|
||||
container_name: openhands-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: openhands
|
||||
# Create additional databases for keycloak and litellm
|
||||
POSTGRES_MULTIPLE_DATABASES: keycloak,litellm
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Redis for caching and pub/sub
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: openhands-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Keycloak for authentication
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.1.1
|
||||
container_name: openhands-keycloak
|
||||
environment:
|
||||
# Admin credentials
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
# Database configuration (using embedded H2 for simplicity)
|
||||
KC_DB: postgres
|
||||
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
|
||||
KC_DB_USERNAME: postgres
|
||||
KC_DB_PASSWORD: postgres
|
||||
# Features
|
||||
KC_FEATURES: token-exchange
|
||||
KC_HEALTH_ENABLED: true
|
||||
KC_METRICS_ENABLED: true
|
||||
# HTTP settings (for local dev, disable HTTPS requirement)
|
||||
KC_HTTP_ENABLED: true
|
||||
KC_HTTP_PORT: 8080
|
||||
KC_HOSTNAME_STRICT: false
|
||||
KC_HOSTNAME_STRICT_HTTPS: false
|
||||
KC_PROXY_HEADERS: xforwarded
|
||||
ports:
|
||||
- "8080:8080"
|
||||
command: start-dev
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# LiteLLM Proxy for LLM routing
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-latest
|
||||
container_name: openhands-litellm
|
||||
environment:
|
||||
# Master key for admin access
|
||||
LITELLM_MASTER_KEY: sk-local-dev-master-key
|
||||
# Database for tracking spend/keys (using same postgres)
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/litellm
|
||||
# General settings
|
||||
LITELLM_LOG: DEBUG
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- ./litellm-config.yaml:/app/config.yaml
|
||||
command: --config /app/config.yaml --port 4000 --host 0.0.0.0 --detailed_debug
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: openhands-network
|
||||
207
enterprise/downgrade_migrated_users.py
Normal file
207
enterprise/downgrade_migrated_users.py
Normal file
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
This script can be removed once orgs is established - probably after Feb 15 2026
|
||||
|
||||
Downgrade script for migrated users.
|
||||
|
||||
This script identifies users who have been migrated (already_migrated=True)
|
||||
and reverts them back to the pre-migration state.
|
||||
|
||||
Usage:
|
||||
# Dry run - just list the users that would be downgraded
|
||||
python downgrade_migrated_users.py --dry-run
|
||||
|
||||
# Downgrade a specific user by their keycloak_user_id
|
||||
python downgrade_migrated_users.py --user-id <user_id>
|
||||
|
||||
# Downgrade all migrated users (with confirmation)
|
||||
python downgrade_migrated_users.py --all
|
||||
|
||||
# Downgrade all migrated users without confirmation (dangerous!)
|
||||
python downgrade_migrated_users.py --all --no-confirm
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
# Add the enterprise directory to the path
|
||||
sys.path.insert(0, '/workspace/project/OpenHands/enterprise')
|
||||
|
||||
from server.logger import logger
|
||||
from sqlalchemy import select, text
|
||||
from storage.database import session_maker
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.user_store import UserStore
|
||||
|
||||
|
||||
def get_migrated_users() -> list[str]:
|
||||
"""Get list of keycloak_user_ids for users who have been migrated.
|
||||
|
||||
This includes:
|
||||
1. Users with already_migrated=True in user_settings (migrated users)
|
||||
2. Users in the 'user' table who don't have a user_settings entry (new sign-ups)
|
||||
"""
|
||||
with session_maker() as session:
|
||||
# Get users from user_settings with already_migrated=True
|
||||
migrated_result = session.execute(
|
||||
select(UserSettings.keycloak_user_id).where(
|
||||
UserSettings.already_migrated.is_(True)
|
||||
)
|
||||
)
|
||||
migrated_users = {row[0] for row in migrated_result.fetchall() if row[0]}
|
||||
|
||||
# Get users from the 'user' table (new sign-ups won't have user_settings)
|
||||
# These are users who signed up after the migration was deployed
|
||||
new_signup_result = session.execute(
|
||||
text("""
|
||||
SELECT CAST(u.id AS VARCHAR)
|
||||
FROM "user" u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM user_settings us
|
||||
WHERE us.keycloak_user_id = CAST(u.id AS VARCHAR)
|
||||
)
|
||||
""")
|
||||
)
|
||||
new_signups = {row[0] for row in new_signup_result.fetchall() if row[0]}
|
||||
|
||||
# Combine both sets
|
||||
all_users = migrated_users | new_signups
|
||||
return list(all_users)
|
||||
|
||||
|
||||
async def downgrade_user(user_id: str) -> bool:
|
||||
"""Downgrade a single user.
|
||||
|
||||
Args:
|
||||
user_id: The keycloak_user_id to downgrade
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = await UserStore.downgrade_user(user_id)
|
||||
if result:
|
||||
print(f'✓ Successfully downgraded user: {user_id}')
|
||||
return True
|
||||
else:
|
||||
print(f'✗ Failed to downgrade user: {user_id}')
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f'✗ Error downgrading user {user_id}: {e}')
|
||||
logger.exception(
|
||||
'downgrade_script:error',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Downgrade migrated users back to pre-migration state'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Just list users that would be downgraded, without making changes',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--user-id',
|
||||
type=str,
|
||||
help='Downgrade a specific user by keycloak_user_id',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--all',
|
||||
action='store_true',
|
||||
help='Downgrade all migrated users',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--no-confirm',
|
||||
action='store_true',
|
||||
help='Skip confirmation prompt (use with caution!)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Get list of migrated users
|
||||
migrated_users = get_migrated_users()
|
||||
print(f'\nFound {len(migrated_users)} migrated user(s).')
|
||||
|
||||
if args.dry_run:
|
||||
print('\n--- DRY RUN MODE ---')
|
||||
print('The following users would be downgraded:')
|
||||
for user_id in migrated_users:
|
||||
print(f' - {user_id}')
|
||||
print('\nNo changes were made.')
|
||||
return
|
||||
|
||||
if args.user_id:
|
||||
# Downgrade a specific user
|
||||
if args.user_id not in migrated_users:
|
||||
print(f'\nUser {args.user_id} is not in the migrated users list.')
|
||||
print('Either the user was not migrated, or the user_id is incorrect.')
|
||||
return
|
||||
|
||||
print(f'\nDowngrading user: {args.user_id}')
|
||||
if not args.no_confirm:
|
||||
confirm = input('Are you sure? (yes/no): ')
|
||||
if confirm.lower() != 'yes':
|
||||
print('Cancelled.')
|
||||
return
|
||||
|
||||
success = await downgrade_user(args.user_id)
|
||||
if success:
|
||||
print('\nDowngrade completed successfully.')
|
||||
else:
|
||||
print('\nDowngrade failed. Check logs for details.')
|
||||
sys.exit(1)
|
||||
|
||||
elif args.all:
|
||||
# Downgrade all migrated users
|
||||
if not migrated_users:
|
||||
print('\nNo migrated users to downgrade.')
|
||||
return
|
||||
|
||||
print(f'\n⚠️ About to downgrade {len(migrated_users)} user(s).')
|
||||
if not args.no_confirm:
|
||||
print('\nThis will:')
|
||||
print(' - Revert LiteLLM team/user budget settings')
|
||||
print(' - Delete organization entries')
|
||||
print(' - Delete user entries in the new schema')
|
||||
print(' - Reset the already_migrated flag')
|
||||
print('\nUsers to downgrade:')
|
||||
for user_id in migrated_users[:10]: # Show first 10
|
||||
print(f' - {user_id}')
|
||||
if len(migrated_users) > 10:
|
||||
print(f' ... and {len(migrated_users) - 10} more')
|
||||
|
||||
confirm = input('\nType "yes" to proceed: ')
|
||||
if confirm.lower() != 'yes':
|
||||
print('Cancelled.')
|
||||
return
|
||||
|
||||
print('\nStarting downgrade...\n')
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for user_id in migrated_users:
|
||||
success = await downgrade_user(user_id)
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
print('\n--- Summary ---')
|
||||
print(f'Successful: {success_count}')
|
||||
print(f'Failed: {fail_count}')
|
||||
|
||||
if fail_count > 0:
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
print('\nPlease specify --dry-run, --user-id, or --all')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Initialize additional databases for OpenHands Enterprise
|
||||
|
||||
set -e
|
||||
|
||||
# Create keycloak database
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
CREATE DATABASE keycloak;
|
||||
CREATE DATABASE litellm;
|
||||
GRANT ALL PRIVILEGES ON DATABASE keycloak TO $POSTGRES_USER;
|
||||
GRANT ALL PRIVILEGES ON DATABASE litellm TO $POSTGRES_USER;
|
||||
EOSQL
|
||||
|
||||
echo "Additional databases created: keycloak, litellm"
|
||||
@@ -26,12 +26,14 @@ from integrations.utils import (
|
||||
from integrations.v1_utils import get_saas_user_auth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
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 server.utils.conversation_callback_utils import register_callback_processor
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import AuthenticationError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
@@ -347,7 +349,7 @@ class GithubManager(Manager):
|
||||
|
||||
msg_info = f'@{user_info.username} please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
except (AuthenticationError, ExpiredError, SessionExpiredError) as e:
|
||||
logger.warning(
|
||||
f'[GitHub] Session expired for user {user_info.username}: {str(e)}'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.utils import store_repositories_in_db
|
||||
from integrations.store_repo_utils import store_repositories_in_db
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.org_store import OrgStore
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
@@ -78,19 +78,17 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
settings = await call_sync_from_async(
|
||||
settings_store.get_user_settings_by_keycloak_id, user_id
|
||||
)
|
||||
|
||||
if not settings or settings.enable_proactive_conversation_starters is None:
|
||||
# Check global setting first - if disabled globally, return False
|
||||
if not ENABLE_PROACTIVE_CONVERSATION_STARTERS:
|
||||
return False
|
||||
|
||||
return settings.enable_proactive_conversation_starters
|
||||
def _get_setting():
|
||||
org = OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
if not org:
|
||||
return False
|
||||
return bool(org.enable_proactive_conversation_starters)
|
||||
|
||||
return await call_sync_from_async(_get_setting)
|
||||
|
||||
|
||||
# =================================================
|
||||
@@ -151,6 +149,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
issue_body=self.description,
|
||||
previous_comments=self.previous_comments,
|
||||
)
|
||||
|
||||
return user_instructions, conversation_instructions
|
||||
|
||||
async def _get_user_secrets(self):
|
||||
@@ -189,6 +188,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
@@ -327,7 +327,6 @@ class GithubIssueComment(GithubIssue):
|
||||
conversation_instructions_template = jinja_env.get_template(
|
||||
'issue_conversation_instructions.j2'
|
||||
)
|
||||
|
||||
conversation_instructions = conversation_instructions_template.render(
|
||||
issue_number=self.issue_number,
|
||||
issue_title=self.title,
|
||||
@@ -397,7 +396,6 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
conversation_instructions_template = jinja_env.get_template(
|
||||
'pr_update_conversation_instructions.j2'
|
||||
)
|
||||
|
||||
conversation_instructions = conversation_instructions_template.render(
|
||||
pr_number=self.issue_number,
|
||||
pr_title=self.title,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import asyncio
|
||||
|
||||
from integrations.store_repo_utils import store_repositories_in_db
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import store_repositories_in_db
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
"""Jira integration manager.
|
||||
|
||||
This module orchestrates the processing of Jira webhook events:
|
||||
1. Parse webhook payload (via JiraPayloadParser)
|
||||
2. Validate workspace
|
||||
3. Authenticate user
|
||||
4. Create view with repository selection (via JiraFactory)
|
||||
5. Start conversation job
|
||||
|
||||
The manager delegates payload parsing to JiraPayloadParser and view creation
|
||||
to JiraFactory, keeping the orchestration logic clean and traceable.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from integrations.jira.jira_types import JiraViewInterface
|
||||
from integrations.jira.jira_view import (
|
||||
JiraExistingConversationView,
|
||||
JiraFactory,
|
||||
JiraNewConversationView,
|
||||
from integrations.jira.jira_payload import (
|
||||
JiraPayloadError,
|
||||
JiraPayloadParser,
|
||||
JiraPayloadSkipped,
|
||||
JiraPayloadSuccess,
|
||||
JiraWebhookPayload,
|
||||
)
|
||||
from integrations.jira.jira_types import (
|
||||
JiraViewInterface,
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import JobContext, Message
|
||||
from integrations.models import Message
|
||||
from integrations.utils import (
|
||||
HOST,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_oh_labels,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
@@ -28,9 +43,6 @@ from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
@@ -41,303 +53,211 @@ from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
# Get OH labels for this environment
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
class JiraManager(Manager):
|
||||
"""Manager for processing Jira webhook events.
|
||||
|
||||
This class orchestrates the flow from webhook receipt to conversation creation,
|
||||
delegating parsing to JiraPayloadParser and view creation to JiraFactory.
|
||||
"""
|
||||
|
||||
def __init__(self, token_manager: TokenManager):
|
||||
self.token_manager = token_manager
|
||||
self.integration_store = JiraIntegrationStore.get_instance()
|
||||
self.jinja_env = Environment(
|
||||
loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR + 'jira')
|
||||
)
|
||||
self.payload_parser = JiraPayloadParser(
|
||||
oh_label=OH_LABEL,
|
||||
inline_oh_label=INLINE_OH_LABEL,
|
||||
)
|
||||
|
||||
async def authenticate_user(
|
||||
self, jira_user_id: str, workspace_id: int
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process incoming Jira webhook message.
|
||||
|
||||
Flow:
|
||||
1. Parse webhook payload
|
||||
2. Validate workspace exists and is active
|
||||
3. Authenticate user
|
||||
4. Create view (includes fetching issue details and selecting repository)
|
||||
5. Start job
|
||||
|
||||
Each step has clear logging for traceability.
|
||||
"""
|
||||
raw_payload = message.message.get('payload', {})
|
||||
|
||||
# Step 1: Parse webhook payload
|
||||
logger.info(
|
||||
'[Jira] Received webhook',
|
||||
extra={'raw_payload': raw_payload},
|
||||
)
|
||||
|
||||
parse_result = self.payload_parser.parse(raw_payload)
|
||||
|
||||
if isinstance(parse_result, JiraPayloadSkipped):
|
||||
logger.info(
|
||||
'[Jira] Webhook skipped', extra={'reason': parse_result.skip_reason}
|
||||
)
|
||||
return
|
||||
|
||||
if isinstance(parse_result, JiraPayloadError):
|
||||
logger.warning(
|
||||
'[Jira] Webhook parse failed', extra={'error': parse_result.error}
|
||||
)
|
||||
return
|
||||
|
||||
payload = parse_result.payload
|
||||
logger.info(
|
||||
'[Jira] Processing webhook',
|
||||
extra={
|
||||
'event_type': payload.event_type.value,
|
||||
'issue_key': payload.issue_key,
|
||||
'user_email': payload.user_email,
|
||||
},
|
||||
)
|
||||
|
||||
# Step 2: Validate workspace
|
||||
workspace = await self._get_active_workspace(payload)
|
||||
if not workspace:
|
||||
return
|
||||
|
||||
# Step 3: Authenticate user
|
||||
jira_user, saas_user_auth = await self._authenticate_user(payload, workspace)
|
||||
if not jira_user or not saas_user_auth:
|
||||
return
|
||||
|
||||
# Step 4: Create view (includes issue details fetch and repo selection)
|
||||
decrypted_api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
|
||||
try:
|
||||
view = await JiraFactory.create_view(
|
||||
payload=payload,
|
||||
workspace=workspace,
|
||||
user=jira_user,
|
||||
user_auth=saas_user_auth,
|
||||
decrypted_api_key=decrypted_api_key,
|
||||
)
|
||||
except RepositoryNotFoundError as e:
|
||||
logger.warning(
|
||||
'[Jira] Repository not found',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
await self._send_error_from_payload(payload, workspace, str(e))
|
||||
return
|
||||
except StartingConvoException as e:
|
||||
logger.warning(
|
||||
'[Jira] View creation failed',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
await self._send_error_from_payload(payload, workspace, str(e))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'[Jira] Unexpected error creating view',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload,
|
||||
workspace,
|
||||
'Failed to initialize conversation. Please try again.',
|
||||
)
|
||||
return
|
||||
|
||||
# Step 5: Start job
|
||||
await self.start_job(view)
|
||||
|
||||
async def _get_active_workspace(
|
||||
self, payload: JiraWebhookPayload
|
||||
) -> JiraWorkspace | None:
|
||||
"""Validate and return the workspace for the webhook.
|
||||
|
||||
Returns None if:
|
||||
- Workspace not found
|
||||
- Workspace is inactive
|
||||
- Request is from service account (to prevent recursion)
|
||||
"""
|
||||
workspace = await self.integration_store.get_workspace_by_name(
|
||||
payload.workspace_name
|
||||
)
|
||||
|
||||
if not workspace:
|
||||
logger.warning(
|
||||
'[Jira] Workspace not found',
|
||||
extra={'workspace_name': payload.workspace_name},
|
||||
)
|
||||
# Can't send error without workspace credentials
|
||||
return None
|
||||
|
||||
# Prevent recursive triggers from service account
|
||||
if payload.user_email == workspace.svc_acc_email:
|
||||
logger.debug(
|
||||
'[Jira] Ignoring service account trigger',
|
||||
extra={'workspace_name': payload.workspace_name},
|
||||
)
|
||||
return None
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(
|
||||
'[Jira] Workspace inactive',
|
||||
extra={'workspace_id': workspace.id, 'status': workspace.status},
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload, workspace, 'Jira integration is not active for your workspace.'
|
||||
)
|
||||
return None
|
||||
|
||||
return workspace
|
||||
|
||||
async def _authenticate_user(
|
||||
self, payload: JiraWebhookPayload, workspace: JiraWorkspace
|
||||
) -> tuple[JiraUser | None, UserAuth | None]:
|
||||
"""Authenticate Jira user and get their OpenHands user auth."""
|
||||
|
||||
# Find active Jira user by Keycloak user ID and workspace ID
|
||||
"""Authenticate the Jira user and get OpenHands auth."""
|
||||
jira_user = await self.integration_store.get_active_user(
|
||||
jira_user_id, workspace_id
|
||||
payload.account_id, workspace.id
|
||||
)
|
||||
|
||||
if not jira_user:
|
||||
logger.warning(
|
||||
f'[Jira] No active Jira user found for {jira_user_id} in workspace {workspace_id}'
|
||||
'[Jira] User not found or inactive',
|
||||
extra={
|
||||
'account_id': payload.account_id,
|
||||
'user_email': payload.user_email,
|
||||
'workspace_id': workspace.id,
|
||||
},
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload,
|
||||
workspace,
|
||||
f'User {payload.user_email} is not authenticated or active in the Jira integration.',
|
||||
)
|
||||
return None, None
|
||||
|
||||
saas_user_auth = await get_user_auth_from_keycloak_id(
|
||||
jira_user.keycloak_user_id
|
||||
)
|
||||
|
||||
if not saas_user_auth:
|
||||
logger.warning(
|
||||
'[Jira] Failed to get OpenHands auth',
|
||||
extra={
|
||||
'keycloak_user_id': jira_user.keycloak_user_id,
|
||||
'user_email': payload.user_email,
|
||||
},
|
||||
)
|
||||
await self._send_error_from_payload(
|
||||
payload,
|
||||
workspace,
|
||||
f'User {payload.user_email} is not authenticated with OpenHands.',
|
||||
)
|
||||
return None, None
|
||||
|
||||
return jira_user, saas_user_auth
|
||||
|
||||
async def _get_repositories(self, user_auth: UserAuth) -> list[Repository]:
|
||||
"""Get repositories that the user has access to."""
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
return []
|
||||
access_token = await user_auth.get_access_token()
|
||||
user_id = await user_auth.get_user_id()
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
repos: list[Repository] = await client.get_repositories(
|
||||
'pushed', server_config.app_mode, None, None, None, None
|
||||
)
|
||||
return repos
|
||||
|
||||
async def validate_request(
|
||||
self, request: Request
|
||||
) -> Tuple[bool, Optional[str], Optional[Dict]]:
|
||||
"""Verify Jira webhook signature."""
|
||||
signature_header = request.headers.get('x-hub-signature')
|
||||
signature = signature_header.split('=')[1] if signature_header else None
|
||||
body = await request.body()
|
||||
payload = await request.json()
|
||||
workspace_name = ''
|
||||
|
||||
if payload.get('webhookEvent') == 'comment_created':
|
||||
selfUrl = payload.get('comment', {}).get('author', {}).get('self')
|
||||
elif payload.get('webhookEvent') == 'jira:issue_updated':
|
||||
selfUrl = payload.get('user', {}).get('self')
|
||||
else:
|
||||
workspace_name = ''
|
||||
|
||||
parsedUrl = urlparse(selfUrl)
|
||||
if parsedUrl.hostname:
|
||||
workspace_name = parsedUrl.hostname
|
||||
|
||||
if not workspace_name:
|
||||
logger.warning('[Jira] No workspace name found in webhook payload')
|
||||
return False, None, None
|
||||
|
||||
if not signature:
|
||||
logger.warning('[Jira] No signature found in webhook headers')
|
||||
return False, None, None
|
||||
|
||||
workspace = await self.integration_store.get_workspace_by_name(workspace_name)
|
||||
|
||||
if not workspace:
|
||||
logger.warning('[Jira] Could not identify workspace for webhook')
|
||||
return False, None, None
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
|
||||
return False, None, None
|
||||
|
||||
webhook_secret = self.token_manager.decrypt_text(workspace.webhook_secret)
|
||||
digest = hmac.new(webhook_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
if hmac.compare_digest(signature, digest):
|
||||
logger.info('[Jira] Webhook signature verified successfully')
|
||||
return True, signature, payload
|
||||
|
||||
return False, None, None
|
||||
|
||||
def parse_webhook(self, payload: Dict) -> JobContext | None:
|
||||
event_type = payload.get('webhookEvent')
|
||||
|
||||
if event_type == 'comment_created':
|
||||
comment_data = payload.get('comment', {})
|
||||
comment = comment_data.get('body', '')
|
||||
|
||||
if '@openhands' not in comment:
|
||||
return None
|
||||
|
||||
issue_data = payload.get('issue', {})
|
||||
issue_id = issue_data.get('id')
|
||||
issue_key = issue_data.get('key')
|
||||
base_api_url = issue_data.get('self', '').split('/rest/')[0]
|
||||
|
||||
user_data = comment_data.get('author', {})
|
||||
user_email = user_data.get('emailAddress')
|
||||
display_name = user_data.get('displayName')
|
||||
account_id = user_data.get('accountId')
|
||||
elif event_type == 'jira:issue_updated':
|
||||
changelog = payload.get('changelog', {})
|
||||
items = changelog.get('items', [])
|
||||
labels = [
|
||||
item.get('toString', '')
|
||||
for item in items
|
||||
if item.get('field') == 'labels' and 'toString' in item
|
||||
]
|
||||
|
||||
if 'openhands' not in labels:
|
||||
return None
|
||||
|
||||
issue_data = payload.get('issue', {})
|
||||
issue_id = issue_data.get('id')
|
||||
issue_key = issue_data.get('key')
|
||||
base_api_url = issue_data.get('self', '').split('/rest/')[0]
|
||||
|
||||
user_data = payload.get('user', {})
|
||||
user_email = user_data.get('emailAddress')
|
||||
display_name = user_data.get('displayName')
|
||||
account_id = user_data.get('accountId')
|
||||
comment = ''
|
||||
else:
|
||||
return None
|
||||
|
||||
workspace_name = ''
|
||||
|
||||
parsedUrl = urlparse(base_api_url)
|
||||
if parsedUrl.hostname:
|
||||
workspace_name = parsedUrl.hostname
|
||||
|
||||
if not all(
|
||||
[
|
||||
issue_id,
|
||||
issue_key,
|
||||
user_email,
|
||||
display_name,
|
||||
account_id,
|
||||
workspace_name,
|
||||
base_api_url,
|
||||
]
|
||||
):
|
||||
return None
|
||||
|
||||
return JobContext(
|
||||
issue_id=issue_id,
|
||||
issue_key=issue_key,
|
||||
user_msg=comment,
|
||||
user_email=user_email,
|
||||
display_name=display_name,
|
||||
platform_user_id=account_id,
|
||||
workspace_name=workspace_name,
|
||||
base_api_url=base_api_url,
|
||||
)
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process incoming Jira webhook message."""
|
||||
|
||||
payload = message.message.get('payload', {})
|
||||
job_context = self.parse_webhook(payload)
|
||||
|
||||
if not job_context:
|
||||
logger.info('[Jira] Webhook does not match trigger conditions')
|
||||
return
|
||||
|
||||
# Get workspace by user email domain
|
||||
workspace = await self.integration_store.get_workspace_by_name(
|
||||
job_context.workspace_name
|
||||
)
|
||||
if not workspace:
|
||||
logger.warning(
|
||||
f'[Jira] No workspace found for email domain: {job_context.user_email}'
|
||||
)
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Your workspace is not configured with Jira integration.',
|
||||
None,
|
||||
)
|
||||
return
|
||||
|
||||
# Prevent any recursive triggers from the service account
|
||||
if job_context.user_email == workspace.svc_acc_email:
|
||||
return
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(f'[Jira] Workspace {workspace.id} is not active')
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Jira integration is not active for your workspace.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
# Authenticate user
|
||||
jira_user, saas_user_auth = await self.authenticate_user(
|
||||
job_context.platform_user_id, workspace.id
|
||||
)
|
||||
if not jira_user or not saas_user_auth:
|
||||
logger.warning(
|
||||
f'[Jira] User authentication failed for {job_context.user_email}'
|
||||
)
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
f'User {job_context.user_email} is not authenticated or active in the Jira integration.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
# Get issue details
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
issue_title, issue_description = await self.get_issue_details(
|
||||
job_context, workspace.jira_cloud_id, workspace.svc_acc_email, api_key
|
||||
)
|
||||
job_context.issue_title = issue_title
|
||||
job_context.issue_description = issue_description
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to get issue context: {str(e)}')
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Failed to retrieve issue details. Please check the issue key and try again.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
# Create Jira view
|
||||
jira_view = await JiraFactory.create_jira_view_from_payload(
|
||||
job_context,
|
||||
saas_user_auth,
|
||||
jira_user,
|
||||
workspace,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to create jira view: {str(e)}', exc_info=True)
|
||||
await self._send_error_comment(
|
||||
job_context,
|
||||
'Failed to initialize conversation. Please try again.',
|
||||
workspace,
|
||||
)
|
||||
return
|
||||
|
||||
if not await self.is_job_requested(message, jira_view):
|
||||
return
|
||||
|
||||
await self.start_job(jira_view)
|
||||
|
||||
async def is_job_requested(
|
||||
self, message: Message, jira_view: JiraViewInterface
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a job is requested and handle repository selection.
|
||||
"""
|
||||
|
||||
if isinstance(jira_view, JiraExistingConversationView):
|
||||
return True
|
||||
|
||||
try:
|
||||
# Get user repositories
|
||||
user_repos: list[Repository] = await self._get_repositories(
|
||||
jira_view.saas_user_auth
|
||||
)
|
||||
|
||||
target_str = f'{jira_view.job_context.issue_description}\n{jira_view.job_context.user_msg}'
|
||||
|
||||
# Try to infer repository from issue description
|
||||
match, repos = filter_potential_repos_by_user_msg(target_str, user_repos)
|
||||
|
||||
if match:
|
||||
# Found exact repository match
|
||||
jira_view.selected_repo = repos[0].full_name
|
||||
logger.info(f'[Jira] Inferred repository: {repos[0].full_name}')
|
||||
return True
|
||||
else:
|
||||
# No clear match - send repository selection comment
|
||||
await self._send_repo_selection_comment(jira_view)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Error in is_job_requested: {str(e)}')
|
||||
return False
|
||||
|
||||
async def start_job(self, jira_view: JiraViewInterface):
|
||||
async def start_job(self, view: JiraViewInterface):
|
||||
"""Start a Jira job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_callback_processor import (
|
||||
@@ -345,101 +265,79 @@ class JiraManager(Manager):
|
||||
)
|
||||
|
||||
try:
|
||||
user_info: JiraUser = jira_view.jira_user
|
||||
logger.info(
|
||||
f'[Jira] Starting job for user {user_info.keycloak_user_id} '
|
||||
f'issue {jira_view.job_context.issue_key}',
|
||||
'[Jira] Starting job',
|
||||
extra={
|
||||
'issue_key': view.payload.issue_key,
|
||||
'user_id': view.jira_user.keycloak_user_id,
|
||||
'selected_repo': view.selected_repo,
|
||||
},
|
||||
)
|
||||
|
||||
# Create conversation
|
||||
conversation_id = await jira_view.create_or_update_conversation(
|
||||
self.jinja_env
|
||||
)
|
||||
conversation_id = await view.create_or_update_conversation(self.jinja_env)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Created/Updated conversation {conversation_id} for issue {jira_view.job_context.issue_key}'
|
||||
'[Jira] Conversation created',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'issue_key': view.payload.issue_key,
|
||||
},
|
||||
)
|
||||
|
||||
# Register callback processor for updates
|
||||
if isinstance(jira_view, JiraNewConversationView):
|
||||
if isinstance(view, JiraNewConversationView):
|
||||
processor = JiraCallbackProcessor(
|
||||
issue_key=jira_view.job_context.issue_key,
|
||||
workspace_name=jira_view.jira_workspace.name,
|
||||
issue_key=view.payload.issue_key,
|
||||
workspace_name=view.jira_workspace.name,
|
||||
)
|
||||
|
||||
# Register the callback processor
|
||||
register_callback_processor(conversation_id, processor)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Created callback processor for conversation {conversation_id}'
|
||||
'[Jira] Callback processor registered',
|
||||
extra={'conversation_id': conversation_id},
|
||||
)
|
||||
|
||||
# Send initial response
|
||||
msg_info = jira_view.get_response_msg()
|
||||
# Send success response
|
||||
msg_info = view.get_response_msg()
|
||||
|
||||
except MissingSettingsError as e:
|
||||
logger.warning(f'[Jira] Missing settings error: {str(e)}')
|
||||
logger.warning(
|
||||
'[Jira] Missing settings error',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = f'Please re-login into [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except LLMAuthenticationError as e:
|
||||
logger.warning(f'[Jira] LLM authentication error: {str(e)}')
|
||||
logger.warning(
|
||||
'[Jira] LLM authentication error',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = f'Please set a valid LLM API key in [OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
|
||||
except SessionExpiredError as e:
|
||||
logger.warning(f'[Jira] Session expired: {str(e)}')
|
||||
logger.warning(
|
||||
'[Jira] Session expired',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = get_session_expired_message()
|
||||
|
||||
except StartingConvoException as e:
|
||||
logger.warning(
|
||||
'[Jira] Conversation start failed',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
msg_info = str(e)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Unexpected error starting job: {str(e)}', exc_info=True
|
||||
'[Jira] Unexpected error starting job',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
msg_info = 'Sorry, there was an unexpected error starting the job. Please try again.'
|
||||
|
||||
# Send response comment
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
jira_view.jira_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=msg_info),
|
||||
issue_key=jira_view.job_context.issue_key,
|
||||
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
|
||||
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to send response message: {str(e)}')
|
||||
|
||||
async def get_issue_details(
|
||||
self,
|
||||
job_context: JobContext,
|
||||
jira_cloud_id: str,
|
||||
svc_acc_email: str,
|
||||
svc_acc_api_key: str,
|
||||
) -> Tuple[str, str]:
|
||||
url = f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{job_context.issue_key}'
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.get(url, auth=(svc_acc_email, svc_acc_api_key))
|
||||
response.raise_for_status()
|
||||
issue_payload = response.json()
|
||||
|
||||
if not issue_payload:
|
||||
raise ValueError(f'Issue with key {job_context.issue_key} not found.')
|
||||
|
||||
title = issue_payload.get('fields', {}).get('summary', '')
|
||||
description = issue_payload.get('fields', {}).get('description', '')
|
||||
|
||||
if not title:
|
||||
raise ValueError(
|
||||
f'Issue with key {job_context.issue_key} does not have a title.'
|
||||
)
|
||||
|
||||
if not description:
|
||||
raise ValueError(
|
||||
f'Issue with key {job_context.issue_key} does not have a description.'
|
||||
)
|
||||
|
||||
return title, description
|
||||
await self._send_comment(view, msg_info)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -449,6 +347,7 @@ class JiraManager(Manager):
|
||||
svc_acc_email: str,
|
||||
svc_acc_api_key: str,
|
||||
):
|
||||
"""Send a comment to a Jira issue."""
|
||||
url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
|
||||
)
|
||||
@@ -460,54 +359,53 @@ class JiraManager(Manager):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def _send_error_comment(
|
||||
self,
|
||||
job_context: JobContext,
|
||||
error_msg: str,
|
||||
workspace: JiraWorkspace | None,
|
||||
):
|
||||
"""Send error comment to Jira issue."""
|
||||
if not workspace:
|
||||
logger.error('[Jira] Cannot send error comment - no workspace available')
|
||||
return
|
||||
async def _send_comment(self, view: JiraViewInterface, msg: str):
|
||||
"""Send a comment using credentials from the view."""
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
view.jira_workspace.svc_acc_api_key
|
||||
)
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=msg),
|
||||
issue_key=view.payload.issue_key,
|
||||
jira_cloud_id=view.jira_workspace.jira_cloud_id,
|
||||
svc_acc_email=view.jira_workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'[Jira] Failed to send comment',
|
||||
extra={'issue_key': view.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
|
||||
async def _send_error_from_payload(
|
||||
self,
|
||||
payload: JiraWebhookPayload,
|
||||
workspace: JiraWorkspace,
|
||||
error_msg: str,
|
||||
):
|
||||
"""Send error comment before view is created (using payload directly)."""
|
||||
try:
|
||||
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=error_msg),
|
||||
issue_key=job_context.issue_key,
|
||||
issue_key=payload.issue_key,
|
||||
jira_cloud_id=workspace.jira_cloud_id,
|
||||
svc_acc_email=workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'[Jira] Failed to send error comment: {str(e)}')
|
||||
|
||||
async def _send_repo_selection_comment(self, jira_view: JiraViewInterface):
|
||||
"""Send a comment with repository options for the user to choose."""
|
||||
try:
|
||||
comment_msg = (
|
||||
'I need to know which repository to work with. '
|
||||
'Please add it to your issue description or send a followup comment.'
|
||||
)
|
||||
|
||||
api_key = self.token_manager.decrypt_text(
|
||||
jira_view.jira_workspace.svc_acc_api_key
|
||||
)
|
||||
|
||||
await self.send_message(
|
||||
self.create_outgoing_message(msg=comment_msg),
|
||||
issue_key=jira_view.job_context.issue_key,
|
||||
jira_cloud_id=jira_view.jira_workspace.jira_cloud_id,
|
||||
svc_acc_email=jira_view.jira_workspace.svc_acc_email,
|
||||
svc_acc_api_key=api_key,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Jira] Sent repository selection comment for issue {jira_view.job_context.issue_key}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Failed to send repository selection comment: {str(e)}'
|
||||
'[Jira] Failed to send error comment',
|
||||
extra={'issue_key': payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
|
||||
def get_workspace_name_from_payload(self, payload: dict) -> str | None:
|
||||
"""Extract workspace name from Jira webhook payload.
|
||||
|
||||
This method is used by the route for signature verification.
|
||||
"""
|
||||
parse_result = self.payload_parser.parse(payload)
|
||||
if isinstance(parse_result, JiraPayloadSuccess):
|
||||
return parse_result.payload.workspace_name
|
||||
return None
|
||||
|
||||
267
enterprise/integrations/jira/jira_payload.py
Normal file
267
enterprise/integrations/jira/jira_payload.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Centralized payload parsing for Jira webhooks.
|
||||
|
||||
This module provides a single source of truth for parsing and validating
|
||||
Jira webhook payloads, replacing scattered parsing logic throughout the codebase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class JiraEventType(Enum):
|
||||
"""Types of Jira events we handle."""
|
||||
|
||||
LABELED_TICKET = 'labeled_ticket'
|
||||
COMMENT_MENTION = 'comment_mention'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraWebhookPayload:
|
||||
"""Normalized, validated representation of a Jira webhook payload.
|
||||
|
||||
This immutable dataclass replaces JobContext and provides a single
|
||||
source of truth for all webhook data. All parsing happens in
|
||||
JiraPayloadParser, ensuring consistent validation.
|
||||
"""
|
||||
|
||||
event_type: JiraEventType
|
||||
raw_event: str # Original webhookEvent value
|
||||
|
||||
# Issue data
|
||||
issue_id: str
|
||||
issue_key: str
|
||||
|
||||
# User data
|
||||
user_email: str
|
||||
display_name: str
|
||||
account_id: str
|
||||
|
||||
# Workspace data (derived from issue self URL)
|
||||
workspace_name: str
|
||||
base_api_url: str
|
||||
|
||||
# Event-specific data
|
||||
comment_body: str = '' # For comment events
|
||||
|
||||
@property
|
||||
def user_msg(self) -> str:
|
||||
"""Alias for comment_body for backward compatibility."""
|
||||
return self.comment_body
|
||||
|
||||
|
||||
class JiraPayloadParseError(Exception):
|
||||
"""Raised when payload parsing fails."""
|
||||
|
||||
def __init__(self, reason: str, event_type: str | None = None):
|
||||
self.reason = reason
|
||||
self.event_type = event_type
|
||||
super().__init__(reason)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraPayloadSuccess:
|
||||
"""Result when parsing succeeds."""
|
||||
|
||||
payload: JiraWebhookPayload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraPayloadSkipped:
|
||||
"""Result when event is intentionally skipped."""
|
||||
|
||||
skip_reason: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraPayloadError:
|
||||
"""Result when parsing fails due to invalid data."""
|
||||
|
||||
error: str
|
||||
|
||||
|
||||
JiraPayloadParseResult = JiraPayloadSuccess | JiraPayloadSkipped | JiraPayloadError
|
||||
|
||||
|
||||
class JiraPayloadParser:
|
||||
"""Centralized parser for Jira webhook payloads.
|
||||
|
||||
This class provides a single entry point for parsing webhooks,
|
||||
determining event types, and extracting all necessary fields.
|
||||
Replaces scattered parsing in JiraFactory and JiraManager.
|
||||
"""
|
||||
|
||||
def __init__(self, oh_label: str, inline_oh_label: str):
|
||||
"""Initialize parser with OpenHands label configuration.
|
||||
|
||||
Args:
|
||||
oh_label: Label that triggers OpenHands (e.g., 'openhands')
|
||||
inline_oh_label: Mention that triggers OpenHands (e.g., '@openhands')
|
||||
"""
|
||||
self.oh_label = oh_label
|
||||
self.inline_oh_label = inline_oh_label
|
||||
|
||||
def parse(self, raw_payload: dict) -> JiraPayloadParseResult:
|
||||
"""Parse a raw webhook payload into a normalized JiraWebhookPayload.
|
||||
|
||||
Args:
|
||||
raw_payload: The raw webhook payload dict from Jira
|
||||
|
||||
Returns:
|
||||
One of:
|
||||
- JiraPayloadSuccess: Valid, actionable event with payload
|
||||
- JiraPayloadSkipped: Event we intentionally don't process
|
||||
- JiraPayloadError: Malformed payload we expected to process
|
||||
"""
|
||||
webhook_event = raw_payload.get('webhookEvent', '')
|
||||
|
||||
logger.debug(
|
||||
'[Jira] Parsing webhook payload', extra={'webhook_event': webhook_event}
|
||||
)
|
||||
|
||||
if webhook_event == 'jira:issue_updated':
|
||||
return self._parse_label_event(raw_payload, webhook_event)
|
||||
elif webhook_event == 'comment_created':
|
||||
return self._parse_comment_event(raw_payload, webhook_event)
|
||||
else:
|
||||
return JiraPayloadSkipped(f'Unhandled webhook event type: {webhook_event}')
|
||||
|
||||
def _parse_label_event(
|
||||
self, payload: dict, webhook_event: str
|
||||
) -> JiraPayloadParseResult:
|
||||
"""Parse an issue_updated event for label changes."""
|
||||
changelog = payload.get('changelog', {})
|
||||
items = changelog.get('items', [])
|
||||
|
||||
# Extract labels that were added
|
||||
labels = [
|
||||
item.get('toString', '')
|
||||
for item in items
|
||||
if item.get('field') == 'labels' and 'toString' in item
|
||||
]
|
||||
|
||||
if self.oh_label not in labels:
|
||||
return JiraPayloadSkipped(
|
||||
f"Label event does not contain '{self.oh_label}' label"
|
||||
)
|
||||
|
||||
# For label events, user data comes from 'user' field
|
||||
user_data = payload.get('user', {})
|
||||
return self._extract_and_validate(
|
||||
payload=payload,
|
||||
user_data=user_data,
|
||||
event_type=JiraEventType.LABELED_TICKET,
|
||||
webhook_event=webhook_event,
|
||||
comment_body='',
|
||||
)
|
||||
|
||||
def _parse_comment_event(
|
||||
self, payload: dict, webhook_event: str
|
||||
) -> JiraPayloadParseResult:
|
||||
"""Parse a comment_created event."""
|
||||
comment_data = payload.get('comment', {})
|
||||
comment_body = comment_data.get('body', '')
|
||||
|
||||
if not self._has_mention(comment_body):
|
||||
return JiraPayloadSkipped(
|
||||
f"Comment does not mention '{self.inline_oh_label}'"
|
||||
)
|
||||
|
||||
# For comment events, user data comes from 'comment.author'
|
||||
user_data = comment_data.get('author', {})
|
||||
return self._extract_and_validate(
|
||||
payload=payload,
|
||||
user_data=user_data,
|
||||
event_type=JiraEventType.COMMENT_MENTION,
|
||||
webhook_event=webhook_event,
|
||||
comment_body=comment_body,
|
||||
)
|
||||
|
||||
def _has_mention(self, text: str) -> bool:
|
||||
"""Check if text contains an exact mention of OpenHands."""
|
||||
from integrations.utils import has_exact_mention
|
||||
|
||||
return has_exact_mention(text, self.inline_oh_label)
|
||||
|
||||
def _extract_and_validate(
|
||||
self,
|
||||
payload: dict,
|
||||
user_data: dict,
|
||||
event_type: JiraEventType,
|
||||
webhook_event: str,
|
||||
comment_body: str,
|
||||
) -> JiraPayloadParseResult:
|
||||
"""Extract common fields and validate required data is present."""
|
||||
issue_data = payload.get('issue', {})
|
||||
|
||||
# Extract all fields with empty string defaults (makes them str type)
|
||||
issue_id = issue_data.get('id', '')
|
||||
issue_key = issue_data.get('key', '')
|
||||
user_email = user_data.get('emailAddress', '')
|
||||
display_name = user_data.get('displayName', '')
|
||||
account_id = user_data.get('accountId', '')
|
||||
base_api_url, workspace_name = self._extract_workspace_from_url(
|
||||
issue_data.get('self', '')
|
||||
)
|
||||
|
||||
# Validate required fields
|
||||
missing: list[str] = []
|
||||
if not issue_id:
|
||||
missing.append('issue.id')
|
||||
if not issue_key:
|
||||
missing.append('issue.key')
|
||||
if not user_email:
|
||||
missing.append('user.emailAddress')
|
||||
if not display_name:
|
||||
missing.append('user.displayName')
|
||||
if not account_id:
|
||||
missing.append('user.accountId')
|
||||
if not workspace_name:
|
||||
missing.append('workspace_name (derived from issue.self)')
|
||||
if not base_api_url:
|
||||
missing.append('base_api_url (derived from issue.self)')
|
||||
|
||||
if missing:
|
||||
return JiraPayloadError(f"Missing required fields: {', '.join(missing)}")
|
||||
|
||||
return JiraPayloadSuccess(
|
||||
JiraWebhookPayload(
|
||||
event_type=event_type,
|
||||
raw_event=webhook_event,
|
||||
issue_id=issue_id,
|
||||
issue_key=issue_key,
|
||||
user_email=user_email,
|
||||
display_name=display_name,
|
||||
account_id=account_id,
|
||||
workspace_name=workspace_name,
|
||||
base_api_url=base_api_url,
|
||||
comment_body=comment_body,
|
||||
)
|
||||
)
|
||||
|
||||
def _extract_workspace_from_url(self, self_url: str) -> tuple[str, str]:
|
||||
"""Extract base API URL and workspace name from issue self URL.
|
||||
|
||||
Args:
|
||||
self_url: The 'self' URL from the issue data
|
||||
|
||||
Returns:
|
||||
Tuple of (base_api_url, workspace_name)
|
||||
"""
|
||||
if not self_url:
|
||||
return '', ''
|
||||
|
||||
# Extract base URL (everything before /rest/)
|
||||
if '/rest/' in self_url:
|
||||
base_api_url = self_url.split('/rest/')[0]
|
||||
else:
|
||||
parsed = urlparse(self_url)
|
||||
base_api_url = f'{parsed.scheme}://{parsed.netloc}'
|
||||
|
||||
# Extract workspace name (hostname)
|
||||
parsed = urlparse(base_api_url)
|
||||
workspace_name = parsed.hostname or ''
|
||||
|
||||
return base_api_url, workspace_name
|
||||
@@ -1,26 +1,42 @@
|
||||
from abc import ABC, abstractmethod
|
||||
"""Type definitions and interfaces for Jira integration."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from integrations.models import JobContext
|
||||
from jinja2 import Environment
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
|
||||
|
||||
class JiraViewInterface(ABC):
|
||||
"""Interface for Jira views that handle different types of Jira interactions."""
|
||||
"""Interface for Jira views that handle different types of Jira interactions.
|
||||
|
||||
job_context: JobContext
|
||||
Views hold the webhook payload directly rather than duplicating fields,
|
||||
and fetch issue details lazily when needed.
|
||||
"""
|
||||
|
||||
# Core data - view holds these references
|
||||
payload: 'JiraWebhookPayload'
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
|
||||
# Mutable state set during processing
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
@abstractmethod
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Get initial instructions for the conversation."""
|
||||
async def get_issue_details(self) -> tuple[str, str]:
|
||||
"""Fetch and cache issue title and description from Jira API.
|
||||
|
||||
Returns:
|
||||
Tuple of (issue_title, issue_description)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -35,6 +51,21 @@ class JiraViewInterface(ABC):
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""Exception raised when starting a conversation fails."""
|
||||
"""Exception raised when starting a conversation fails.
|
||||
|
||||
This provides user-friendly error messages that can be sent back to Jira.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RepositoryNotFoundError(Exception):
|
||||
"""Raised when a repository cannot be determined from the issue.
|
||||
|
||||
This is a separate error domain from StartingConvoException - it represents
|
||||
a precondition failure (no repo configured/found) rather than a conversation
|
||||
creation failure. The manager catches this and converts it to a user-friendly
|
||||
message.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
from dataclasses import dataclass
|
||||
"""Jira view implementations and factory.
|
||||
|
||||
from integrations.jira.jira_types import JiraViewInterface, StartingConvoException
|
||||
from integrations.models import JobContext
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
Views are responsible for:
|
||||
- Holding the webhook payload and auth context
|
||||
- Lazy-loading issue details from Jira API when needed
|
||||
- Creating conversations with the selected repository
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
from integrations.jira.jira_types import (
|
||||
JiraViewInterface,
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
|
||||
from jinja2 import Environment
|
||||
from storage.jira_conversation import JiraConversation
|
||||
from storage.jira_integration_store import JiraIntegrationStore
|
||||
@@ -10,55 +23,147 @@ from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import create_new_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
integration_store = JiraIntegrationStore.get_instance()
|
||||
|
||||
|
||||
@dataclass
|
||||
class JiraNewConversationView(JiraViewInterface):
|
||||
job_context: JobContext
|
||||
"""View for creating a new Jira conversation.
|
||||
|
||||
This view holds the webhook payload directly and lazily fetches
|
||||
issue details when needed for rendering templates.
|
||||
"""
|
||||
|
||||
payload: JiraWebhookPayload
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
selected_repo: str | None = None
|
||||
conversation_id: str = ''
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
# Lazy-loaded issue details (cached after first fetch)
|
||||
_issue_title: str | None = field(default=None, repr=False)
|
||||
_issue_description: str | None = field(default=None, repr=False)
|
||||
|
||||
# Decrypted API key (set by factory)
|
||||
_decrypted_api_key: str = field(default='', repr=False)
|
||||
|
||||
async def get_issue_details(self) -> tuple[str, str]:
|
||||
"""Fetch issue details from Jira API (cached after first call).
|
||||
|
||||
Returns:
|
||||
Tuple of (issue_title, issue_description)
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If issue details cannot be fetched
|
||||
"""
|
||||
if self._issue_title is not None and self._issue_description is not None:
|
||||
return self._issue_title, self._issue_description
|
||||
|
||||
try:
|
||||
url = f'{JIRA_CLOUD_API_URL}/{self.jira_workspace.jira_cloud_id}/rest/api/2/issue/{self.payload.issue_key}'
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
auth=(
|
||||
self.jira_workspace.svc_acc_email,
|
||||
self._decrypted_api_key,
|
||||
),
|
||||
)
|
||||
response.raise_for_status()
|
||||
issue_payload = response.json()
|
||||
|
||||
if not issue_payload:
|
||||
raise StartingConvoException(
|
||||
f'Issue {self.payload.issue_key} not found.'
|
||||
)
|
||||
|
||||
self._issue_title = issue_payload.get('fields', {}).get('summary', '')
|
||||
self._issue_description = (
|
||||
issue_payload.get('fields', {}).get('description', '') or ''
|
||||
)
|
||||
|
||||
if not self._issue_title:
|
||||
raise StartingConvoException(
|
||||
f'Issue {self.payload.issue_key} does not have a title.'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[Jira] Fetched issue details',
|
||||
extra={
|
||||
'issue_key': self.payload.issue_key,
|
||||
'has_description': bool(self._issue_description),
|
||||
},
|
||||
)
|
||||
|
||||
return self._issue_title, self._issue_description
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
'[Jira] Failed to fetch issue details',
|
||||
extra={
|
||||
'issue_key': self.payload.issue_key,
|
||||
'status': e.response.status_code,
|
||||
},
|
||||
)
|
||||
raise StartingConvoException(
|
||||
f'Failed to fetch issue details: HTTP {e.response.status_code}'
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, StartingConvoException):
|
||||
raise
|
||||
logger.error(
|
||||
'[Jira] Failed to fetch issue details',
|
||||
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
|
||||
)
|
||||
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Get instructions for the conversation.
|
||||
|
||||
This fetches issue details if not already cached.
|
||||
|
||||
Returns:
|
||||
Tuple of (system_instructions, user_message)
|
||||
"""
|
||||
issue_title, issue_description = await self.get_issue_details()
|
||||
|
||||
instructions_template = jinja_env.get_template('jira_instructions.j2')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
|
||||
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
issue_title=self.job_context.issue_title,
|
||||
issue_description=self.job_context.issue_description,
|
||||
user_message=self.job_context.user_msg or '',
|
||||
issue_key=self.payload.issue_key,
|
||||
issue_title=issue_title,
|
||||
issue_description=issue_description,
|
||||
user_message=self.payload.user_msg,
|
||||
)
|
||||
|
||||
return instructions, user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Jira conversation"""
|
||||
"""Create a new Jira conversation.
|
||||
|
||||
Returns:
|
||||
The conversation ID
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If conversation creation fails
|
||||
"""
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
instructions, user_msg = self._get_instructions(jinja_env)
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
|
||||
try:
|
||||
agent_loop_info = await create_new_conversation(
|
||||
@@ -76,149 +181,259 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
|
||||
logger.info(f'[Jira] Created conversation {self.conversation_id}')
|
||||
logger.info(
|
||||
'[Jira] Created conversation',
|
||||
extra={
|
||||
'conversation_id': self.conversation_id,
|
||||
'issue_key': self.payload.issue_key,
|
||||
'selected_repo': self.selected_repo,
|
||||
},
|
||||
)
|
||||
|
||||
# Store Jira conversation mapping
|
||||
jira_conversation = JiraConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
issue_id=self.job_context.issue_id,
|
||||
issue_key=self.job_context.issue_key,
|
||||
issue_id=self.payload.issue_id,
|
||||
issue_key=self.payload.issue_key,
|
||||
jira_user_id=self.jira_user.id,
|
||||
)
|
||||
|
||||
await integration_store.create_conversation(jira_conversation)
|
||||
|
||||
return self.conversation_id
|
||||
|
||||
except Exception as e:
|
||||
if isinstance(e, StartingConvoException):
|
||||
raise
|
||||
logger.error(
|
||||
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
|
||||
'[Jira] Failed to create conversation',
|
||||
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira"""
|
||||
"""Get the response message to send back to Jira."""
|
||||
conversation_link = CONVERSATION_URL.format(self.conversation_id)
|
||||
return f"I'm on it! {self.job_context.display_name} can [track my progress here|{conversation_link}]."
|
||||
|
||||
|
||||
@dataclass
|
||||
class JiraExistingConversationView(JiraViewInterface):
|
||||
job_context: JobContext
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
selected_repo: str | None
|
||||
conversation_id: str
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_existing_conversation.j2')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
user_message=self.job_context.user_msg or '',
|
||||
issue_title=self.job_context.issue_title,
|
||||
issue_description=self.job_context.issue_description,
|
||||
)
|
||||
|
||||
return '', user_msg
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Update an existing Jira conversation"""
|
||||
|
||||
user_id = self.jira_user.keycloak_user_id
|
||||
|
||||
try:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
# Should we raise here if there are no providers?
|
||||
providers_set = list(provider_tokens.keys()) if provider_tokens else []
|
||||
|
||||
conversation_init_data = await setup_init_conversation_settings(
|
||||
user_id, self.conversation_id, providers_set
|
||||
)
|
||||
|
||||
# Either join ongoing conversation, or restart the conversation
|
||||
agent_loop_info = await conversation_manager.maybe_start_agent_loop(
|
||||
self.conversation_id, conversation_init_data, user_id
|
||||
)
|
||||
|
||||
final_agent_observation = get_final_agent_observation(
|
||||
agent_loop_info.event_store
|
||||
)
|
||||
agent_state = (
|
||||
None
|
||||
if len(final_agent_observation) == 0
|
||||
else final_agent_observation[0].agent_state
|
||||
)
|
||||
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
_, user_msg = self._get_instructions(jinja_env)
|
||||
user_message_event = MessageAction(content=user_msg)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
self.conversation_id, event_to_dict(user_message_event)
|
||||
)
|
||||
|
||||
return self.conversation_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[Jira] Failed to create conversation: {str(e)}', exc_info=True
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira"""
|
||||
conversation_link = CONVERSATION_URL.format(self.conversation_id)
|
||||
return f"I'm on it! {self.job_context.display_name} can [continue tracking my progress here|{conversation_link}]."
|
||||
return f"I'm on it! {self.payload.display_name} can [track my progress here|{conversation_link}]."
|
||||
|
||||
|
||||
class JiraFactory:
|
||||
"""Factory for creating Jira views based on message content"""
|
||||
"""Factory for creating Jira views.
|
||||
|
||||
The factory is responsible for:
|
||||
- Creating the appropriate view type
|
||||
- Inferring and selecting the repository
|
||||
- Validating all required data is available
|
||||
|
||||
Repository selection happens here so that view creation either
|
||||
succeeds with a valid repo or fails with a clear error.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def create_jira_view_from_payload(
|
||||
job_context: JobContext,
|
||||
saas_user_auth: UserAuth,
|
||||
jira_user: JiraUser,
|
||||
jira_workspace: JiraWorkspace,
|
||||
async def _create_provider_handler(user_auth: UserAuth) -> ProviderHandler | None:
|
||||
"""Create a ProviderHandler for the user."""
|
||||
provider_tokens = await user_auth.get_provider_tokens()
|
||||
if provider_tokens is None:
|
||||
return None
|
||||
|
||||
access_token = await user_auth.get_access_token()
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
return ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_potential_repos(
|
||||
issue_key: str,
|
||||
issue_title: str,
|
||||
issue_description: str,
|
||||
user_msg: str,
|
||||
) -> list[str]:
|
||||
"""Extract potential repository names from issue content.
|
||||
|
||||
Raises:
|
||||
RepositoryNotFoundError: If no potential repos found in text.
|
||||
"""
|
||||
search_text = f'{issue_title}\n{issue_description}\n{user_msg}'
|
||||
potential_repos = infer_repo_from_message(search_text)
|
||||
|
||||
if not potential_repos:
|
||||
raise RepositoryNotFoundError(
|
||||
'Could not determine which repository to use. '
|
||||
'Please mention the repository (e.g., owner/repo) in the issue description or comment.'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[Jira] Found potential repositories in issue content',
|
||||
extra={'issue_key': issue_key, 'potential_repos': potential_repos},
|
||||
)
|
||||
return potential_repos
|
||||
|
||||
@staticmethod
|
||||
async def _verify_repos(
|
||||
issue_key: str,
|
||||
potential_repos: list[str],
|
||||
provider_handler: ProviderHandler,
|
||||
) -> list[str]:
|
||||
"""Verify which repos the user has access to."""
|
||||
verified_repos: list[str] = []
|
||||
|
||||
for repo_name in potential_repos:
|
||||
try:
|
||||
repository = await provider_handler.verify_repo_provider(repo_name)
|
||||
verified_repos.append(repository.full_name)
|
||||
logger.debug(
|
||||
'[Jira] Repository verification succeeded',
|
||||
extra={'issue_key': issue_key, 'repository': repository.full_name},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
'[Jira] Repository verification failed',
|
||||
extra={
|
||||
'issue_key': issue_key,
|
||||
'repo_name': repo_name,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
return verified_repos
|
||||
|
||||
@staticmethod
|
||||
def _select_single_repo(
|
||||
issue_key: str,
|
||||
potential_repos: list[str],
|
||||
verified_repos: list[str],
|
||||
) -> str:
|
||||
"""Select exactly one repo from verified repos.
|
||||
|
||||
Raises:
|
||||
RepositoryNotFoundError: If zero or multiple repos verified.
|
||||
"""
|
||||
if len(verified_repos) == 0:
|
||||
raise RepositoryNotFoundError(
|
||||
f'Could not access any of the mentioned repositories: {", ".join(potential_repos)}. '
|
||||
'Please ensure you have access to the repository and it exists.'
|
||||
)
|
||||
|
||||
if len(verified_repos) > 1:
|
||||
raise RepositoryNotFoundError(
|
||||
f'Multiple repositories found: {", ".join(verified_repos)}. '
|
||||
'Please specify exactly one repository in the issue description or comment.'
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'[Jira] Verified repository access',
|
||||
extra={'issue_key': issue_key, 'repository': verified_repos[0]},
|
||||
)
|
||||
return verified_repos[0]
|
||||
|
||||
@staticmethod
|
||||
async def _infer_repository(
|
||||
payload: JiraWebhookPayload,
|
||||
user_auth: UserAuth,
|
||||
issue_title: str,
|
||||
issue_description: str,
|
||||
) -> str:
|
||||
"""Infer and verify the repository from issue content.
|
||||
|
||||
Raises:
|
||||
RepositoryNotFoundError: If no valid repository can be determined.
|
||||
"""
|
||||
provider_handler = await JiraFactory._create_provider_handler(user_auth)
|
||||
if not provider_handler:
|
||||
raise RepositoryNotFoundError(
|
||||
'No Git provider connected. Please connect a Git provider in OpenHands settings.'
|
||||
)
|
||||
|
||||
potential_repos = JiraFactory._extract_potential_repos(
|
||||
payload.issue_key, issue_title, issue_description, payload.user_msg
|
||||
)
|
||||
|
||||
verified_repos = await JiraFactory._verify_repos(
|
||||
payload.issue_key, potential_repos, provider_handler
|
||||
)
|
||||
|
||||
return JiraFactory._select_single_repo(
|
||||
payload.issue_key, potential_repos, verified_repos
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def create_view(
|
||||
payload: JiraWebhookPayload,
|
||||
workspace: JiraWorkspace,
|
||||
user: JiraUser,
|
||||
user_auth: UserAuth,
|
||||
decrypted_api_key: str,
|
||||
) -> JiraViewInterface:
|
||||
"""Create appropriate Jira view based on the message and user state"""
|
||||
"""Create a Jira view with repository already selected.
|
||||
|
||||
if not jira_user or not saas_user_auth or not jira_workspace:
|
||||
raise StartingConvoException('User not authenticated with Jira integration')
|
||||
This factory method:
|
||||
1. Creates the view with payload and auth context
|
||||
2. Fetches issue details (needed for repo inference)
|
||||
3. Infers and selects the repository
|
||||
|
||||
conversation = await integration_store.get_user_conversations_by_issue_id(
|
||||
job_context.issue_id, jira_user.id
|
||||
If any step fails, an appropriate exception is raised with
|
||||
a user-friendly message.
|
||||
|
||||
Args:
|
||||
payload: Parsed webhook payload
|
||||
workspace: The Jira workspace
|
||||
user: The Jira user
|
||||
user_auth: OpenHands user authentication
|
||||
decrypted_api_key: Decrypted service account API key
|
||||
|
||||
Returns:
|
||||
A JiraViewInterface with selected_repo populated
|
||||
|
||||
Raises:
|
||||
StartingConvoException: If view creation fails
|
||||
RepositoryNotFoundError: If repository cannot be determined
|
||||
"""
|
||||
logger.info(
|
||||
'[Jira] Creating view',
|
||||
extra={
|
||||
'issue_key': payload.issue_key,
|
||||
'event_type': payload.event_type.value,
|
||||
},
|
||||
)
|
||||
|
||||
if conversation:
|
||||
logger.info(
|
||||
f'[Jira] Found existing conversation for issue {job_context.issue_id}'
|
||||
)
|
||||
return JiraExistingConversationView(
|
||||
job_context=job_context,
|
||||
saas_user_auth=saas_user_auth,
|
||||
jira_user=jira_user,
|
||||
jira_workspace=jira_workspace,
|
||||
selected_repo=None,
|
||||
conversation_id=conversation.conversation_id,
|
||||
)
|
||||
|
||||
return JiraNewConversationView(
|
||||
job_context=job_context,
|
||||
saas_user_auth=saas_user_auth,
|
||||
jira_user=jira_user,
|
||||
jira_workspace=jira_workspace,
|
||||
selected_repo=None, # Will be set later after repo inference
|
||||
conversation_id='', # Will be set when conversation is created
|
||||
# Create the view
|
||||
view = JiraNewConversationView(
|
||||
payload=payload,
|
||||
saas_user_auth=user_auth,
|
||||
jira_user=user,
|
||||
jira_workspace=workspace,
|
||||
_decrypted_api_key=decrypted_api_key,
|
||||
)
|
||||
|
||||
# Fetch issue details (needed for repo inference)
|
||||
try:
|
||||
issue_title, issue_description = await view.get_issue_details()
|
||||
except StartingConvoException:
|
||||
raise # Re-raise with original message
|
||||
except Exception as e:
|
||||
raise StartingConvoException(f'Failed to fetch issue details: {str(e)}')
|
||||
|
||||
# Infer and select repository
|
||||
selected_repo = await JiraFactory._infer_repository(
|
||||
payload=payload,
|
||||
user_auth=user_auth,
|
||||
issue_title=issue_title,
|
||||
issue_description=issue_description,
|
||||
)
|
||||
|
||||
view.selected_repo = selected_repo
|
||||
|
||||
logger.info(
|
||||
'[Jira] View created successfully',
|
||||
extra={
|
||||
'issue_key': payload.issue_key,
|
||||
'selected_repo': selected_repo,
|
||||
},
|
||||
)
|
||||
|
||||
return view
|
||||
|
||||
@@ -16,11 +16,6 @@ class Manager(ABC):
|
||||
"Send message to integration from Openhands server"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def is_job_requested(self, message: Message) -> bool:
|
||||
"Confirm that a job is being requested"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def start_job(self):
|
||||
"Kick off a job with openhands agent"
|
||||
|
||||
@@ -29,17 +29,20 @@ class ResolverUserContext(UserContext):
|
||||
|
||||
return UserInfo(id=user_id)
|
||||
|
||||
async def get_authenticated_git_url(self, repository: str) -> str:
|
||||
async def get_authenticated_git_url(
|
||||
self, repository: str, is_optional: bool = False
|
||||
) -> str:
|
||||
# This would need to be implemented based on the git provider tokens
|
||||
# For now, return a basic HTTPS URL
|
||||
return f'https://github.com/{repository}.git'
|
||||
|
||||
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
|
||||
# Return the appropriate token from git_provider_tokens
|
||||
|
||||
# Return the appropriate token string from git_provider_tokens
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if provider_tokens:
|
||||
return provider_tokens.get(provider_type)
|
||||
provider_token = provider_tokens.get(provider_type)
|
||||
if provider_token and provider_token.token:
|
||||
return provider_token.token.get_secret_value()
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
|
||||
@@ -189,6 +189,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'channel_id': self.channel_id,
|
||||
'conversation_id': self.conversation_id,
|
||||
'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,
|
||||
},
|
||||
@@ -197,6 +198,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
conversation_id=self.conversation_id,
|
||||
channel_id=self.channel_id,
|
||||
keycloak_user_id=user_info.keycloak_user_id,
|
||||
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,
|
||||
@@ -399,10 +401,10 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
if not agent_state or agent_state == AgentState.LOADING:
|
||||
raise StartingConvoException('Conversation is still starting')
|
||||
|
||||
user_msg, _ = self._get_instructions(jinja)
|
||||
user_msg_action = MessageAction(content=user_msg)
|
||||
instructions, _ = self._get_instructions(jinja)
|
||||
user_msg = MessageAction(content=instructions)
|
||||
await conversation_manager.send_event_to_conversation(
|
||||
self.conversation_id, event_to_dict(user_msg_action)
|
||||
self.conversation_id, event_to_dict(user_msg)
|
||||
)
|
||||
|
||||
async def send_message_to_v1_conversation(self, jinja: Environment):
|
||||
|
||||
53
enterprise/integrations/store_repo_utils.py
Normal file
53
enterprise/integrations/store_repo_utils.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from storage.repository_store import RepositoryStore
|
||||
from storage.stored_repository import StoredRepository
|
||||
from storage.user_repo_map import UserRepositoryMap
|
||||
from storage.user_repo_map_store import UserRepositoryMapStore
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import Repository
|
||||
|
||||
|
||||
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
|
||||
"""
|
||||
Store repositories in DB and create user-repository mappings
|
||||
|
||||
Args:
|
||||
repos: List of Repository objects to store
|
||||
user_id: User ID associated with these repositories
|
||||
"""
|
||||
|
||||
# Convert Repository objects to StoredRepository objects
|
||||
# Convert Repository objects to UserRepositoryMap objects
|
||||
stored_repos = []
|
||||
user_repos = []
|
||||
for repo in repos:
|
||||
repo_id = f'{repo.git_provider.value}##{str(repo.id)}'
|
||||
stored_repo = StoredRepository(
|
||||
repo_name=repo.full_name,
|
||||
repo_id=repo_id,
|
||||
is_public=repo.is_public,
|
||||
# Optional fields set to None by default
|
||||
has_microagent=None,
|
||||
has_setup_script=None,
|
||||
)
|
||||
stored_repos.append(stored_repo)
|
||||
user_repo_map = UserRepositoryMap(user_id=user_id, repo_id=repo_id, admin=None)
|
||||
|
||||
user_repos.append(user_repo_map)
|
||||
|
||||
# Get config instance
|
||||
config = OpenHandsConfig()
|
||||
|
||||
try:
|
||||
# Store repositories in the repos table
|
||||
repo_store = RepositoryStore.get_instance(config)
|
||||
repo_store.store_projects(stored_repos)
|
||||
|
||||
# Store user-repository mappings in the user-repos table
|
||||
user_repo_store = UserRepositoryMapStore.get_instance(config)
|
||||
user_repo_store.store_user_repo_mappings(user_repos)
|
||||
|
||||
logger.info(f'Saved repos for user {user_id}')
|
||||
except Exception:
|
||||
logger.warning('Failed to save repos', exc_info=True)
|
||||
@@ -1,19 +1,24 @@
|
||||
from uuid import UUID
|
||||
|
||||
import stripe
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import STRIPE_API_KEY
|
||||
from server.logger import logger
|
||||
from sqlalchemy.orm import Session
|
||||
from storage.database import session_maker
|
||||
from storage.org import Org
|
||||
from storage.org_store import OrgStore
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
|
||||
|
||||
async def find_customer_id_by_user_id(user_id: str) -> str | None:
|
||||
# First search our own DB...
|
||||
async def find_customer_id_by_org_id(org_id: UUID) -> str | None:
|
||||
with session_maker() as session:
|
||||
stripe_customer = (
|
||||
session.query(StripeCustomer)
|
||||
.filter(StripeCustomer.keycloak_user_id == user_id)
|
||||
.filter(StripeCustomer.org_id == org_id)
|
||||
.first()
|
||||
)
|
||||
if stripe_customer:
|
||||
@@ -21,46 +26,76 @@ async def find_customer_id_by_user_id(user_id: str) -> str | None:
|
||||
|
||||
# If that fails, fallback to stripe
|
||||
search_result = await stripe.Customer.search_async(
|
||||
query=f"metadata['user_id']:'{user_id}'",
|
||||
query=f"metadata['org_id']:'{str(org_id)}'",
|
||||
)
|
||||
data = search_result.data
|
||||
if not data:
|
||||
logger.info('no_customer_for_user_id', extra={'user_id': user_id})
|
||||
logger.info(
|
||||
'no_customer_for_org_id',
|
||||
extra={'org_id': str(org_id)},
|
||||
)
|
||||
return None
|
||||
return data[0].id # type: ignore [attr-defined]
|
||||
|
||||
|
||||
async def find_or_create_customer(user_id: str) -> str:
|
||||
customer_id = await find_customer_id_by_user_id(user_id)
|
||||
if customer_id:
|
||||
return customer_id
|
||||
logger.info('creating_customer', extra={'user_id': user_id})
|
||||
async def find_customer_id_by_user_id(user_id: str) -> str | None:
|
||||
# First search our own DB...
|
||||
org = await call_sync_from_async(
|
||||
OrgStore.get_current_org_from_keycloak_user_id, user_id
|
||||
)
|
||||
if not org:
|
||||
logger.warning(f'Org not found for user {user_id}')
|
||||
return None
|
||||
customer_id = await find_customer_id_by_org_id(org.id)
|
||||
return customer_id
|
||||
|
||||
# Get the user info from keycloak
|
||||
token_manager = TokenManager()
|
||||
user_info = await token_manager.get_user_info_from_user_id(user_id) or {}
|
||||
|
||||
async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
|
||||
# Get the current org for the user
|
||||
org = await call_sync_from_async(
|
||||
OrgStore.get_current_org_from_keycloak_user_id, user_id
|
||||
)
|
||||
if not org:
|
||||
logger.warning(f'Org not found for user {user_id}')
|
||||
return None
|
||||
|
||||
customer_id = await find_customer_id_by_org_id(org.id)
|
||||
if customer_id:
|
||||
return {'customer_id': customer_id, 'org_id': str(org.id)}
|
||||
logger.info(
|
||||
'creating_customer',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
# Create the customer in stripe
|
||||
customer = await stripe.Customer.create_async(
|
||||
email=str(user_info.get('email', '')),
|
||||
metadata={'user_id': user_id},
|
||||
email=org.contact_email,
|
||||
metadata={'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
# Save the stripe customer in the local db
|
||||
with session_maker() as session:
|
||||
session.add(
|
||||
StripeCustomer(keycloak_user_id=user_id, stripe_customer_id=customer.id)
|
||||
StripeCustomer(
|
||||
keycloak_user_id=user_id,
|
||||
org_id=org.id,
|
||||
stripe_customer_id=customer.id,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'created_customer',
|
||||
extra={'user_id': user_id, 'stripe_customer_id': customer.id},
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org.id),
|
||||
'stripe_customer_id': customer.id,
|
||||
},
|
||||
)
|
||||
return customer.id
|
||||
return {'customer_id': customer.id, 'org_id': str(org.id)}
|
||||
|
||||
|
||||
async def has_payment_method(user_id: str) -> bool:
|
||||
async def has_payment_method_by_user_id(user_id: str) -> bool:
|
||||
customer_id = await find_customer_id_by_user_id(user_id)
|
||||
if customer_id is None:
|
||||
return False
|
||||
@@ -71,3 +106,28 @@ async def has_payment_method(user_id: str) -> bool:
|
||||
f'has_payment_method:{user_id}:{customer_id}:{bool(payment_methods.data)}'
|
||||
)
|
||||
return bool(payment_methods.data)
|
||||
|
||||
|
||||
async def migrate_customer(session: Session, user_id: str, org: Org):
|
||||
stripe_customer = (
|
||||
session.query(StripeCustomer)
|
||||
.filter(StripeCustomer.keycloak_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
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)},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org.id),
|
||||
'stripe_customer_id': customer.id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,15 +6,9 @@ import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.config import get_config
|
||||
from server.constants import WEB_HOST
|
||||
from storage.database import session_maker
|
||||
from storage.repository_store import RepositoryStore
|
||||
from storage.stored_repository import StoredRepository
|
||||
from storage.user_repo_map import UserRepositoryMap
|
||||
from storage.user_repo_map_store import UserRepositoryMapStore
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events import Event, EventSource
|
||||
@@ -125,26 +119,17 @@ async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
Returns:
|
||||
True if V1 conversations are enabled for this user, False otherwise
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
org = await call_sync_from_async(
|
||||
OrgStore.get_current_org_from_keycloak_user_id, user_id
|
||||
)
|
||||
|
||||
settings = await call_sync_from_async(
|
||||
settings_store.get_user_settings_by_keycloak_id, user_id
|
||||
)
|
||||
|
||||
if not settings or settings.v1_enabled is None:
|
||||
if not org or org.v1_enabled is None:
|
||||
return False
|
||||
|
||||
return settings.v1_enabled
|
||||
return org.v1_enabled
|
||||
|
||||
|
||||
def has_exact_mention(text: str, mention: str) -> bool:
|
||||
@@ -409,102 +394,46 @@ def append_conversation_footer(message: str, conversation_id: str) -> str:
|
||||
return message + footer
|
||||
|
||||
|
||||
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
|
||||
"""
|
||||
Store repositories in DB and create user-repository mappings
|
||||
|
||||
Args:
|
||||
repos: List of Repository objects to store
|
||||
user_id: User ID associated with these repositories
|
||||
"""
|
||||
|
||||
# Convert Repository objects to StoredRepository objects
|
||||
# Convert Repository objects to UserRepositoryMap objects
|
||||
stored_repos = []
|
||||
user_repos = []
|
||||
for repo in repos:
|
||||
repo_id = f'{repo.git_provider.value}##{str(repo.id)}'
|
||||
stored_repo = StoredRepository(
|
||||
repo_name=repo.full_name,
|
||||
repo_id=repo_id,
|
||||
is_public=repo.is_public,
|
||||
# Optional fields set to None by default
|
||||
has_microagent=None,
|
||||
has_setup_script=None,
|
||||
)
|
||||
stored_repos.append(stored_repo)
|
||||
user_repo_map = UserRepositoryMap(user_id=user_id, repo_id=repo_id, admin=None)
|
||||
|
||||
user_repos.append(user_repo_map)
|
||||
|
||||
# Get config instance
|
||||
config = OpenHandsConfig()
|
||||
|
||||
try:
|
||||
# Store repositories in the repos table
|
||||
repo_store = RepositoryStore.get_instance(config)
|
||||
repo_store.store_projects(stored_repos)
|
||||
|
||||
# Store user-repository mappings in the user-repos table
|
||||
user_repo_store = UserRepositoryMapStore.get_instance(config)
|
||||
user_repo_store.store_user_repo_mappings(user_repos)
|
||||
|
||||
logger.info(f'Saved repos for user {user_id}')
|
||||
except Exception:
|
||||
logger.warning('Failed to save repos', exc_info=True)
|
||||
|
||||
|
||||
def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
"""
|
||||
Extract all repository names in the format 'owner/repo' from various Git provider URLs
|
||||
and direct mentions in text. Supports GitHub, GitLab, and BitBucket.
|
||||
Args:
|
||||
user_msg: Input message that may contain repository references
|
||||
Returns:
|
||||
List of repository names in 'owner/repo' format, empty list if none found
|
||||
"""
|
||||
# Normalize the message by removing extra whitespace and newlines
|
||||
normalized_msg = re.sub(r'\s+', ' ', user_msg.strip())
|
||||
|
||||
# Pattern to match Git URLs from GitHub, GitLab, and BitBucket
|
||||
# Captures: protocol, domain, owner, repo (with optional .git extension)
|
||||
git_url_pattern = r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?(?:[/?#].*?)?(?=\s|$|[^\w.-])'
|
||||
|
||||
# Pattern to match direct owner/repo mentions (e.g., "OpenHands/OpenHands")
|
||||
# Must be surrounded by word boundaries or specific characters to avoid false positives
|
||||
direct_pattern = (
|
||||
r'(?:^|\s|[\[\(\'"])([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)(?=\s|$|[\]\)\'",.])'
|
||||
git_url_pattern = (
|
||||
r'https?://(?:github\.com|gitlab\.com|bitbucket\.org)/'
|
||||
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+?)(?:\.git)?'
|
||||
r'(?:[/?#].*?)?(?=\s|$|[^\w.-])'
|
||||
)
|
||||
|
||||
matches = []
|
||||
# UPDATED: allow {{ owner/repo }} in addition to existing boundaries
|
||||
direct_pattern = (
|
||||
r'(?:^|\s|{{|[\[\(\'":`])' # left boundary
|
||||
r'([a-zA-Z0-9_.-]+)/([a-zA-Z0-9_.-]+)'
|
||||
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
|
||||
)
|
||||
|
||||
# First, find all Git URLs (highest priority)
|
||||
git_matches = re.findall(git_url_pattern, normalized_msg)
|
||||
for owner, repo in git_matches:
|
||||
# Remove .git extension if present
|
||||
matches: list[str] = []
|
||||
|
||||
# Git URLs first (highest priority)
|
||||
for owner, repo in re.findall(git_url_pattern, normalized_msg):
|
||||
repo = re.sub(r'\.git$', '', repo)
|
||||
matches.append(f'{owner}/{repo}')
|
||||
|
||||
# Second, find all direct owner/repo mentions
|
||||
direct_matches = re.findall(direct_pattern, normalized_msg)
|
||||
for owner, repo in direct_matches:
|
||||
# Direct mentions
|
||||
for owner, repo in re.findall(direct_pattern, normalized_msg):
|
||||
full_match = f'{owner}/{repo}'
|
||||
|
||||
# Skip if it looks like a version number, date, or file path
|
||||
if (
|
||||
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match) # version numbers
|
||||
or re.match(r'^\d{1,2}/\d{1,2}$', full_match) # dates
|
||||
or re.match(r'^[A-Z]/[A-Z]$', full_match) # single letters
|
||||
or repo.endswith('.txt')
|
||||
or repo.endswith('.md') # file extensions
|
||||
or repo.endswith('.py')
|
||||
or repo.endswith('.js')
|
||||
or '.' in repo
|
||||
and len(repo.split('.')) > 2
|
||||
): # complex file paths
|
||||
re.match(r'^\d+\.\d+/\d+\.\d+$', full_match)
|
||||
or re.match(r'^\d{1,2}/\d{1,2}$', full_match)
|
||||
or re.match(r'^[A-Z]/[A-Z]$', full_match)
|
||||
or repo.endswith(('.txt', '.md', '.py', '.js'))
|
||||
or ('.' in repo and len(repo.split('.')) > 2)
|
||||
):
|
||||
continue
|
||||
|
||||
# Avoid duplicates from Git URLs already found
|
||||
if full_match not in matches:
|
||||
matches.append(full_match)
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# LiteLLM Configuration for Local Development
|
||||
# Documentation: https://docs.litellm.ai/docs/proxy/configs
|
||||
|
||||
general_settings:
|
||||
master_key: sk-local-dev-master-key
|
||||
database_url: postgresql://postgres:postgres@postgres:5432/litellm
|
||||
|
||||
model_list:
|
||||
# OpenAI Models
|
||||
- model_name: gpt-4o
|
||||
litellm_params:
|
||||
model: openai/gpt-4o
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
- model_name: gpt-4o-mini
|
||||
litellm_params:
|
||||
model: openai/gpt-4o-mini
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
- model_name: gpt-4-turbo
|
||||
litellm_params:
|
||||
model: openai/gpt-4-turbo
|
||||
api_key: os.environ/OPENAI_API_KEY
|
||||
|
||||
# Anthropic Claude Models
|
||||
- model_name: claude-3-5-sonnet-20241022
|
||||
litellm_params:
|
||||
model: anthropic/claude-3-5-sonnet-20241022
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-3-7-sonnet-20250219
|
||||
litellm_params:
|
||||
model: anthropic/claude-3-7-sonnet-20250219
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-sonnet-4-20250514
|
||||
litellm_params:
|
||||
model: anthropic/claude-sonnet-4-20250514
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-opus-4-5-20251101
|
||||
litellm_params:
|
||||
model: anthropic/claude-opus-4-5-20251101
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
- model_name: claude-3-opus-20240229
|
||||
litellm_params:
|
||||
model: anthropic/claude-3-opus-20240229
|
||||
api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
# Google Gemini Models (optional)
|
||||
- model_name: gemini-1.5-pro
|
||||
litellm_params:
|
||||
model: gemini/gemini-1.5-pro
|
||||
api_key: os.environ/GOOGLE_API_KEY
|
||||
|
||||
- model_name: gemini-2.0-flash
|
||||
litellm_params:
|
||||
model: gemini/gemini-2.0-flash
|
||||
api_key: os.environ/GOOGLE_API_KEY
|
||||
|
||||
litellm_settings:
|
||||
# Enable request/response logging
|
||||
set_verbose: true
|
||||
# Fallback behavior
|
||||
drop_params: true
|
||||
# Default max tokens if not specified
|
||||
max_tokens: 4096
|
||||
|
||||
router_settings:
|
||||
# Enable retries on failure
|
||||
num_retries: 2
|
||||
# Timeout for each request
|
||||
timeout: 600
|
||||
# Routing strategy
|
||||
routing_strategy: simple-shuffle
|
||||
@@ -20,6 +20,8 @@ down_revision = '059'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# TODO: decide whether to modify this for orgs or users
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
@@ -28,8 +30,10 @@ def upgrade():
|
||||
|
||||
This replaces the functionality of the removed admin maintenance endpoint.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from server.constants import CURRENT_USER_SETTINGS_VERSION
|
||||
|
||||
# Hardcoded value to prevent migration failures when constant is removed from codebase
|
||||
# This migration has already run in production, so we use the value that was current at the time
|
||||
CURRENT_USER_SETTINGS_VERSION = 4
|
||||
|
||||
# Create a connection and bind it to a session
|
||||
connection = op.get_bind()
|
||||
|
||||
272
enterprise/migrations/versions/089_create_org_tables.py
Normal file
272
enterprise/migrations/versions/089_create_org_tables.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""create org tables from pgerd schema
|
||||
|
||||
Revision ID: 089
|
||||
Revises: 088
|
||||
Create Date: 2025-01-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '089'
|
||||
down_revision: Union[str, None] = '088'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Remove current settings table
|
||||
op.execute('DROP TABLE IF EXISTS settings')
|
||||
|
||||
# Add already_migrated column to user_settings table
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column(
|
||||
'already_migrated',
|
||||
sa.Boolean,
|
||||
nullable=True,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
)
|
||||
|
||||
# Create role table
|
||||
op.create_table(
|
||||
'role',
|
||||
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
|
||||
sa.Column('name', sa.String, nullable=False),
|
||||
sa.Column('rank', sa.Integer, nullable=False),
|
||||
sa.UniqueConstraint('name', name='role_name_unique'),
|
||||
)
|
||||
|
||||
# 1. Create default roles
|
||||
op.execute(
|
||||
sa.text("""
|
||||
INSERT INTO role (name, rank) VALUES ('owner', 10), ('admin', 20), ('user', 1000)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
""")
|
||||
)
|
||||
|
||||
# Create org table with settings fields
|
||||
op.create_table(
|
||||
'org',
|
||||
sa.Column(
|
||||
'id',
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column('name', sa.String, nullable=False),
|
||||
sa.Column('contact_name', sa.String, nullable=True),
|
||||
sa.Column('contact_email', sa.String, nullable=True),
|
||||
sa.Column('conversation_expiration', sa.Integer, nullable=True),
|
||||
# Settings fields moved to org table
|
||||
sa.Column('agent', sa.String, nullable=True),
|
||||
sa.Column('default_max_iterations', sa.Integer, nullable=True),
|
||||
sa.Column('security_analyzer', sa.String, nullable=True),
|
||||
sa.Column(
|
||||
'confirmation_mode',
|
||||
sa.Boolean,
|
||||
nullable=True,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
sa.Column('default_llm_model', sa.String, nullable=True),
|
||||
sa.Column('default_llm_base_url', sa.String, nullable=True),
|
||||
sa.Column('remote_runtime_resource_factor', sa.Integer, nullable=True),
|
||||
sa.Column(
|
||||
'enable_default_condenser',
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text('true'),
|
||||
),
|
||||
sa.Column('billing_margin', sa.Float, nullable=True),
|
||||
sa.Column(
|
||||
'enable_proactive_conversation_starters',
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text('true'),
|
||||
),
|
||||
sa.Column('sandbox_base_container_image', sa.String, nullable=True),
|
||||
sa.Column('sandbox_runtime_container_image', sa.String, nullable=True),
|
||||
sa.Column(
|
||||
'org_version', sa.Integer, nullable=False, server_default=sa.text('0')
|
||||
),
|
||||
sa.Column('mcp_config', sa.JSON, nullable=True),
|
||||
sa.Column('_search_api_key', sa.String, nullable=True),
|
||||
sa.Column('_sandbox_api_key', sa.String, nullable=True),
|
||||
sa.Column('max_budget_per_task', sa.Float, nullable=True),
|
||||
sa.Column(
|
||||
'enable_solvability_analysis',
|
||||
sa.Boolean,
|
||||
nullable=True,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
sa.Column('v1_enabled', sa.Boolean, nullable=True),
|
||||
sa.Column('condenser_max_size', sa.Integer, nullable=True),
|
||||
sa.UniqueConstraint('name', name='org_name_unique'),
|
||||
)
|
||||
|
||||
# Create user table with user-specific settings fields
|
||||
op.create_table(
|
||||
'user',
|
||||
sa.Column(
|
||||
'id',
|
||||
postgresql.UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column('current_org_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('role_id', sa.Integer, nullable=True),
|
||||
sa.Column('accepted_tos', sa.DateTime, nullable=True),
|
||||
sa.Column(
|
||||
'enable_sound_notifications',
|
||||
sa.Boolean,
|
||||
nullable=True,
|
||||
server_default=sa.text('false'),
|
||||
),
|
||||
sa.Column('language', sa.String, nullable=True),
|
||||
sa.Column('user_consents_to_analytics', sa.Boolean, nullable=True),
|
||||
sa.Column('email', sa.String, nullable=True),
|
||||
sa.Column('email_verified', sa.Boolean, nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['current_org_id'], ['org.id'], name='current_org_fkey'
|
||||
),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['role.id'], name='user_role_fkey'),
|
||||
)
|
||||
|
||||
# Create org_member table (junction table for many-to-many relationship)
|
||||
op.create_table(
|
||||
'org_member',
|
||||
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('role_id', sa.Integer, nullable=False),
|
||||
sa.Column('_llm_api_key', sa.String, nullable=False),
|
||||
sa.Column('max_iterations', sa.Integer, nullable=True),
|
||||
sa.Column('llm_model', sa.String, nullable=True),
|
||||
sa.Column('_llm_api_key_for_byor', sa.String, nullable=True),
|
||||
sa.Column('llm_base_url', sa.String, nullable=True),
|
||||
sa.Column('status', sa.String, nullable=True),
|
||||
sa.ForeignKeyConstraint(['org_id'], ['org.id'], name='om_org_fkey'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], name='om_user_fkey'),
|
||||
sa.ForeignKeyConstraint(['role_id'], ['role.id'], name='om_role_fkey'),
|
||||
sa.PrimaryKeyConstraint('org_id', 'user_id'),
|
||||
)
|
||||
|
||||
# Add org_id column to existing tables
|
||||
# billing_sessions
|
||||
op.add_column(
|
||||
'billing_sessions',
|
||||
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'billing_sessions_org_fkey', 'billing_sessions', 'org', ['org_id'], ['id']
|
||||
)
|
||||
|
||||
# Create conversation_metadata_saas table
|
||||
op.create_table(
|
||||
'conversation_metadata_saas',
|
||||
sa.Column('conversation_id', sa.String(), nullable=False),
|
||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
['user_id'], ['user.id'], name='conversation_metadata_saas_user_fkey'
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
['org_id'], ['org.id'], name='conversation_metadata_saas_org_fkey'
|
||||
),
|
||||
sa.PrimaryKeyConstraint('conversation_id'),
|
||||
)
|
||||
|
||||
# custom_secrets
|
||||
op.add_column(
|
||||
'custom_secrets',
|
||||
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'custom_secrets_org_fkey', 'custom_secrets', 'org', ['org_id'], ['id']
|
||||
)
|
||||
|
||||
# api_keys
|
||||
op.add_column(
|
||||
'api_keys', sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True)
|
||||
)
|
||||
op.create_foreign_key('api_keys_org_fkey', 'api_keys', 'org', ['org_id'], ['id'])
|
||||
|
||||
# slack_conversation
|
||||
op.add_column(
|
||||
'slack_conversation',
|
||||
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'slack_conversation_org_fkey', 'slack_conversation', 'org', ['org_id'], ['id']
|
||||
)
|
||||
|
||||
# slack_users
|
||||
op.add_column(
|
||||
'slack_users', sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'slack_users_org_fkey', 'slack_users', 'org', ['org_id'], ['id']
|
||||
)
|
||||
|
||||
# stripe_customers
|
||||
op.alter_column(
|
||||
'stripe_customers',
|
||||
'keycloak_user_id',
|
||||
existing_type=sa.String(),
|
||||
nullable=True,
|
||||
)
|
||||
op.add_column(
|
||||
'stripe_customers',
|
||||
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
'stripe_customers_org_fkey', 'stripe_customers', 'org', ['org_id'], ['id']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop already_migrated column from user_settings table
|
||||
op.drop_column('user_settings', 'already_migrated')
|
||||
|
||||
# Drop foreign keys and columns added to existing tables
|
||||
op.drop_constraint(
|
||||
'stripe_customers_org_fkey', 'stripe_customers', type_='foreignkey'
|
||||
)
|
||||
op.drop_column('stripe_customers', 'org_id')
|
||||
op.alter_column(
|
||||
'stripe_customers',
|
||||
'keycloak_user_id',
|
||||
existing_type=sa.String(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
op.drop_constraint('slack_users_org_fkey', 'slack_users', type_='foreignkey')
|
||||
op.drop_column('slack_users', 'org_id')
|
||||
|
||||
op.drop_constraint(
|
||||
'slack_conversation_org_fkey', 'slack_conversation', type_='foreignkey'
|
||||
)
|
||||
op.drop_column('slack_conversation', 'org_id')
|
||||
|
||||
op.drop_constraint('api_keys_org_fkey', 'api_keys', type_='foreignkey')
|
||||
op.drop_column('api_keys', 'org_id')
|
||||
|
||||
op.drop_constraint('custom_secrets_org_fkey', 'custom_secrets', type_='foreignkey')
|
||||
op.drop_column('custom_secrets', 'org_id')
|
||||
|
||||
# Drop conversation_metadata_saas table
|
||||
op.drop_table('conversation_metadata_saas')
|
||||
|
||||
op.drop_constraint(
|
||||
'billing_sessions_org_fkey', 'billing_sessions', type_='foreignkey'
|
||||
)
|
||||
op.drop_column('billing_sessions', 'org_id')
|
||||
|
||||
# Drop tables in reverse order due to foreign key constraints
|
||||
op.drop_table('org_member')
|
||||
op.drop_table('user')
|
||||
op.drop_table('org')
|
||||
op.drop_table('role')
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add git_user_name and git_user_email columns to user table.
|
||||
|
||||
Revision ID: 090
|
||||
Revises: 089
|
||||
Create Date: 2025-01-22
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '090'
|
||||
down_revision = '089'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('git_user_name', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('git_user_email', sa.String, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'git_user_email')
|
||||
op.drop_column('user', 'git_user_name')
|
||||
11723
enterprise/poetry.lock
generated
11723
enterprise/poetry.lock
generated
File diff suppressed because one or more lines are too long
@@ -4,6 +4,10 @@ from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Ensure SAAS configuration is used
|
||||
if not os.getenv('OPENHANDS_CONFIG_CLS'):
|
||||
os.environ['OPENHANDS_CONFIG_CLS'] = 'server.config.SaaSServerConfig'
|
||||
|
||||
import socketio # noqa: E402
|
||||
from fastapi import Request, status # noqa: E402
|
||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
@@ -34,6 +38,7 @@ from server.routes.integration.linear import linear_integration_router # noqa:
|
||||
from server.routes.integration.slack import slack_router # noqa: E402
|
||||
from server.routes.mcp_patch import patch_mcp_server # noqa: E402
|
||||
from server.routes.oauth_device import oauth_device_router # noqa: E402
|
||||
from server.routes.orgs import org_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
@@ -86,6 +91,7 @@ if GITLAB_APP_CLIENT_ID:
|
||||
base_app.include_router(gitlab_integration_router)
|
||||
|
||||
base_app.include_router(api_keys_router) # Add routes for API key management
|
||||
base_app.include_router(org_router) # Add routes for organization management
|
||||
add_github_proxy_routes(base_app)
|
||||
add_debugging_routes(
|
||||
base_app
|
||||
|
||||
@@ -39,6 +39,8 @@ ROLE_CHECK_ENABLED = os.getenv('ROLE_CHECK_ENABLED', 'false').lower() in (
|
||||
'on',
|
||||
)
|
||||
|
||||
DUPLICATE_EMAIL_CHECK = os.getenv('DUPLICATE_EMAIL_CHECK', 'true') in ('1', 'true')
|
||||
|
||||
# reCAPTCHA Enterprise
|
||||
RECAPTCHA_PROJECT_ID = os.getenv('RECAPTCHA_PROJECT_ID', '').strip()
|
||||
RECAPTCHA_SITE_KEY = os.getenv('RECAPTCHA_SITE_KEY', '').strip()
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import asyncio
|
||||
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy import select
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
async def _user_has_gitlab_provider(user_id: str) -> bool:
|
||||
"""Check if the user has authenticated with GitLab.
|
||||
|
||||
Args:
|
||||
user_id: The Keycloak user ID
|
||||
|
||||
Returns:
|
||||
True if the user has a GitLab provider token, False otherwise
|
||||
"""
|
||||
# Lazy import to avoid circular dependency issues at module load time
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.database import a_session_maker
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(AuthTokens).where(
|
||||
AuthTokens.keycloak_user_id == user_id,
|
||||
AuthTokens.identity_provider == ProviderType.GITLAB.value,
|
||||
)
|
||||
)
|
||||
return result.scalars().first() is not None
|
||||
|
||||
|
||||
def schedule_gitlab_repo_sync(
|
||||
user_id: str, keycloak_access_token: SecretStr | None = None
|
||||
) -> None:
|
||||
@@ -14,10 +39,20 @@ def schedule_gitlab_repo_sync(
|
||||
Because the outer call is already a background task, we instruct the service
|
||||
to store repository data synchronously (store_in_background=False) to avoid
|
||||
nested background tasks while still keeping the overall operation async.
|
||||
|
||||
The sync is only performed if the user has authenticated with GitLab.
|
||||
"""
|
||||
|
||||
async def _run():
|
||||
try:
|
||||
# Check if the user has a GitLab provider token before syncing
|
||||
if not await _user_has_gitlab_provider(user_id):
|
||||
logger.debug(
|
||||
'gitlab_repo_sync_skipped: user has no GitLab provider',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return
|
||||
|
||||
# Lazy import to avoid circular dependency:
|
||||
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
|
||||
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
|
||||
|
||||
@@ -18,6 +18,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
class AssessmentResult:
|
||||
"""Result of a reCAPTCHA Enterprise assessment."""
|
||||
|
||||
name: str
|
||||
score: float
|
||||
valid: bool
|
||||
action_valid: bool
|
||||
@@ -63,6 +64,7 @@ class RecaptchaService:
|
||||
user_ip: str,
|
||||
user_agent: str,
|
||||
email: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> AssessmentResult:
|
||||
"""Create a reCAPTCHA Enterprise assessment.
|
||||
|
||||
@@ -72,6 +74,7 @@ class RecaptchaService:
|
||||
user_ip: The user's IP address.
|
||||
user_agent: The user's browser user agent.
|
||||
email: Optional email for Account Defender hashing.
|
||||
user_id: Optional Keycloak user ID for logging correlation.
|
||||
|
||||
Returns:
|
||||
AssessmentResult with score, validity, and allowed status.
|
||||
@@ -100,6 +103,10 @@ class RecaptchaService:
|
||||
|
||||
response = self.client.create_assessment(request)
|
||||
|
||||
# Capture assessment name for potential annotation later
|
||||
# Format: projects/{project_id}/assessments/{assessment_id}
|
||||
assessment_name = response.name
|
||||
|
||||
token_properties = response.token_properties
|
||||
risk_analysis = response.risk_analysis
|
||||
|
||||
@@ -129,6 +136,7 @@ class RecaptchaService:
|
||||
logger.info(
|
||||
'recaptcha_assessment',
|
||||
extra={
|
||||
'assessment_name': assessment_name,
|
||||
'score': score,
|
||||
'valid': valid,
|
||||
'action_valid': action_valid,
|
||||
@@ -137,10 +145,13 @@ class RecaptchaService:
|
||||
'has_suspicious_labels': has_suspicious_labels,
|
||||
'allowed': allowed,
|
||||
'user_ip': user_ip,
|
||||
'user_id': user_id,
|
||||
'email': email,
|
||||
},
|
||||
)
|
||||
|
||||
return AssessmentResult(
|
||||
name=assessment_name,
|
||||
score=score,
|
||||
valid=valid,
|
||||
action_valid=action_valid,
|
||||
|
||||
@@ -77,6 +77,15 @@ class SaasUserAuth(UserAuth):
|
||||
self.access_token = SecretStr(tokens['access_token'])
|
||||
self.refresh_token = SecretStr(tokens['refresh_token'])
|
||||
self.refreshed = True
|
||||
if not self.email or not self.email_verified or not self.user_id:
|
||||
# We don't need to verify the signature here because we just refreshed
|
||||
# this token from the IDP via token_manager.refresh()
|
||||
access_token_payload = jwt.decode(
|
||||
tokens['access_token'], options={'verify_signature': False}
|
||||
)
|
||||
self.user_id = access_token_payload['sub']
|
||||
self.email = access_token_payload['email']
|
||||
self.email_verified = access_token_payload['email_verified']
|
||||
|
||||
def _is_token_expired(self, token: SecretStr):
|
||||
logger.debug('saas_user_auth_is_token_expired')
|
||||
@@ -103,7 +112,6 @@ class SaasUserAuth(UserAuth):
|
||||
return settings
|
||||
settings_store = await self.get_user_settings_store()
|
||||
settings = await settings_store.load()
|
||||
# If load() returned None, should settings be created?
|
||||
if settings:
|
||||
settings.email = self.email
|
||||
settings.email_verified = self.email_verified
|
||||
@@ -208,9 +216,9 @@ class SaasUserAuth(UserAuth):
|
||||
|
||||
async def get_mcp_api_key(self) -> str:
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
mcp_api_key = api_key_store.retrieve_mcp_api_key(self.user_id)
|
||||
mcp_api_key = await api_key_store.retrieve_mcp_api_key(self.user_id)
|
||||
if not mcp_api_key:
|
||||
mcp_api_key = api_key_store.create_api_key(
|
||||
mcp_api_key = await api_key_store.create_api_key(
|
||||
self.user_id, 'MCP_API_KEY', None
|
||||
)
|
||||
return mcp_api_key
|
||||
@@ -274,11 +282,13 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
if not user_id:
|
||||
return None
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
return SaasUserAuth(
|
||||
saas_user_auth = SaasUserAuth(
|
||||
user_id=user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
auth_type=AuthType.BEARER,
|
||||
)
|
||||
await saas_user_auth.refresh()
|
||||
return saas_user_auth
|
||||
except Exception as exc:
|
||||
raise BearerTokenError from exc
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ from keycloak.exceptions import (
|
||||
KeycloakError,
|
||||
KeycloakPostError,
|
||||
)
|
||||
from server.auth.auth_error import ExpiredError
|
||||
from server.auth.constants import (
|
||||
BITBUCKET_APP_CLIENT_ID,
|
||||
BITBUCKET_APP_CLIENT_SECRET,
|
||||
DUPLICATE_EMAIL_CHECK,
|
||||
GITHUB_APP_CLIENT_ID,
|
||||
GITHUB_APP_CLIENT_SECRET,
|
||||
GITLAB_APP_CLIENT_ID,
|
||||
@@ -425,6 +427,8 @@ class TokenManager:
|
||||
access_token = data.get('access_token')
|
||||
refresh_token = data.get('refresh_token')
|
||||
if not access_token or not refresh_token:
|
||||
if data.get('error') == 'bad_refresh_token':
|
||||
raise ExpiredError()
|
||||
raise ValueError(
|
||||
'Failed to refresh token: missing access_token or refresh_token in response.'
|
||||
)
|
||||
@@ -646,6 +650,10 @@ class TokenManager:
|
||||
if not email:
|
||||
return False
|
||||
|
||||
# We have the option to skip the duplicate email check in test environments
|
||||
if not DUPLICATE_EMAIL_CHECK:
|
||||
return False
|
||||
|
||||
base_email = extract_base_email(email)
|
||||
if not base_email:
|
||||
logger.warning(f'Could not extract base email from: {email}')
|
||||
|
||||
@@ -8,7 +8,7 @@ import socketio
|
||||
from server.logger import logger
|
||||
from server.utils.conversation_callback_utils import invoke_conversation_callbacks
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
@@ -524,16 +524,18 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
)
|
||||
# Look up the user_id from the database
|
||||
with session_maker() as session:
|
||||
conversation_metadata = (
|
||||
session.query(StoredConversationMetadata)
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadata.conversation_id
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
== conversation_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
user_id = (
|
||||
conversation_metadata.user_id if conversation_metadata else None
|
||||
str(conversation_metadata_saas.user_id)
|
||||
if conversation_metadata_saas
|
||||
else None
|
||||
)
|
||||
# Handle the stopped conversation asynchronously
|
||||
asyncio.create_task(
|
||||
|
||||
@@ -19,8 +19,8 @@ IS_LOCAL_ENV = bool(HOST == 'localhost')
|
||||
DEFAULT_BILLING_MARGIN = float(os.environ.get('DEFAULT_BILLING_MARGIN', '1.0'))
|
||||
|
||||
# Map of user settings versions to their corresponding default LLM models
|
||||
# This ensures that CURRENT_USER_SETTINGS_VERSION and LITELLM_DEFAULT_MODEL stay in sync
|
||||
USER_SETTINGS_VERSION_TO_MODEL = {
|
||||
# This ensures that PERSONAL_WORKSPACE_VERSION_TO_MODEL and LITELLM_DEFAULT_MODEL stay in sync
|
||||
PERSONAL_WORKSPACE_VERSION_TO_MODEL = {
|
||||
1: 'claude-3-5-sonnet-20241022',
|
||||
2: 'claude-3-7-sonnet-20250219',
|
||||
3: 'claude-sonnet-4-20250514',
|
||||
@@ -31,7 +31,8 @@ USER_SETTINGS_VERSION_TO_MODEL = {
|
||||
LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL')
|
||||
|
||||
# Current user settings version - this should be the latest key in USER_SETTINGS_VERSION_TO_MODEL
|
||||
CURRENT_USER_SETTINGS_VERSION = max(USER_SETTINGS_VERSION_TO_MODEL.keys())
|
||||
ORG_SETTINGS_VERSION = max(PERSONAL_WORKSPACE_VERSION_TO_MODEL.keys())
|
||||
PERSONAL_WORKSPACE_VERSION = max(PERSONAL_WORKSPACE_VERSION_TO_MODEL.keys())
|
||||
|
||||
LITE_LLM_API_URL = os.environ.get(
|
||||
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
|
||||
@@ -55,7 +56,6 @@ SUBSCRIPTION_PRICE_DATA = {
|
||||
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
|
||||
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
|
||||
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
|
||||
|
||||
SLACK_CLIENT_ID = os.environ.get('SLACK_CLIENT_ID', None)
|
||||
@@ -91,5 +91,5 @@ def get_default_litellm_model():
|
||||
"""Construct proxy for litellm model based on user settings if not set explicitly."""
|
||||
if LITELLM_DEFAULT_MODEL:
|
||||
return LITELLM_DEFAULT_MODEL
|
||||
model = USER_SETTINGS_VERSION_TO_MODEL[CURRENT_USER_SETTINGS_VERSION]
|
||||
model = PERSONAL_WORKSPACE_VERSION_TO_MODEL[PERSONAL_WORKSPACE_VERSION]
|
||||
return build_litellm_proxy_model_path(model)
|
||||
|
||||
@@ -14,7 +14,6 @@ from storage.conversation_callback import (
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
from storage.database import session_maker
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
@@ -108,13 +107,10 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
f'[GitHub] Sent summary instruction to conversation {conversation_id} {summary_event}'
|
||||
)
|
||||
|
||||
# Update the processor state
|
||||
# Update the processor state - the outer session will commit this
|
||||
self.send_summary_instruction = False
|
||||
callback.set_processor(self)
|
||||
callback.updated_at = datetime.now()
|
||||
with session_maker() as session:
|
||||
session.merge(callback)
|
||||
session.commit()
|
||||
return
|
||||
|
||||
# Extract the summary from the event store
|
||||
@@ -130,14 +126,15 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
|
||||
logger.info(f'[GitHub] Summary sent for conversation {conversation_id}')
|
||||
|
||||
# Mark callback as completed status
|
||||
# Mark callback as completed status - the outer session will commit this
|
||||
callback.status = CallbackStatus.COMPLETED
|
||||
callback.updated_at = datetime.now()
|
||||
with session_maker() as session:
|
||||
session.merge(callback)
|
||||
session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f'[GitHub] Error processing conversation callback: {str(e)}'
|
||||
)
|
||||
# Mark callback as error to prevent infinite re-invocation
|
||||
# The outer session will commit this
|
||||
callback.status = CallbackStatus.ERROR
|
||||
callback.updated_at = datetime.now()
|
||||
|
||||
68
enterprise/server/email_validation.py
Normal file
68
enterprise/server/email_validation.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Email domain validation utilities for enterprise endpoints.
|
||||
"""
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
|
||||
|
||||
async def get_admin_user_id(
|
||||
request: Request, user_id: str | None = Depends(get_user_id)
|
||||
) -> str:
|
||||
"""
|
||||
Dependency that validates user has @openhands.dev email domain.
|
||||
|
||||
This dependency can be used in place of get_user_id for endpoints that
|
||||
should only be accessible to admin users. Currently, this is implemented
|
||||
by checking for @openhands.dev email domain.
|
||||
|
||||
TODO: In the future, this should be replaced with an explicit is_admin flag
|
||||
in user/org settings instead of relying on email domain validation.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
user_id: User ID from get_user_id dependency
|
||||
|
||||
Returns:
|
||||
str: User ID if email domain is valid
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if email domain is not @openhands.dev
|
||||
HTTPException: 401 if user is not authenticated
|
||||
|
||||
Example:
|
||||
@router.post('/endpoint')
|
||||
async def create_resource(
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
):
|
||||
# Only admin users can access this endpoint
|
||||
pass
|
||||
"""
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User not authenticated',
|
||||
)
|
||||
|
||||
user_auth = await get_user_auth(request)
|
||||
user_email = await user_auth.get_user_email()
|
||||
|
||||
if not user_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail='User email not available',
|
||||
)
|
||||
|
||||
if not user_email.endswith('@openhands.dev'):
|
||||
logger.warning(
|
||||
'Access denied - invalid email domain',
|
||||
extra={'user_id': user_id, 'email_domain': user_email.split('@')[-1]},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access restricted to @openhands.dev users',
|
||||
)
|
||||
|
||||
return user_id
|
||||
@@ -44,11 +44,13 @@ class MyProcessor(MaintenanceTaskProcessor):
|
||||
### UserVersionUpgradeProcessor
|
||||
|
||||
Located in `user_version_upgrade_processor.py`, this processor:
|
||||
|
||||
- Handles up to 100 user IDs per task
|
||||
- Upgrades users with `user_version < CURRENT_USER_SETTINGS_VERSION`
|
||||
- Upgrades users with `user_version < ORG_SETTINGS_VERSION`
|
||||
- Uses `SaasSettingsStore.create_default_settings()` for upgrades
|
||||
|
||||
**Usage:**
|
||||
|
||||
```python
|
||||
from server.maintenance_task_processor.user_version_upgrade_processor import UserVersionUpgradeProcessor
|
||||
|
||||
@@ -144,22 +146,26 @@ task = create_maintenance_task(
|
||||
## Best Practices
|
||||
|
||||
### Processor Design
|
||||
|
||||
- Keep tasks short-running (under 1 minute)
|
||||
- Handle errors gracefully and return meaningful error information
|
||||
- Use batch processing for large datasets
|
||||
- Include progress information in the return dict
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Always wrap your processor logic in try-catch blocks
|
||||
- Return structured error information
|
||||
- Log important events for debugging
|
||||
|
||||
### Performance
|
||||
|
||||
- Limit batch sizes to avoid long-running tasks
|
||||
- Use database sessions efficiently
|
||||
- Consider memory usage for large datasets
|
||||
|
||||
### Testing
|
||||
|
||||
- Create unit tests for your processors
|
||||
- Test error conditions
|
||||
- Verify the processor serialization/deserialization works correctly
|
||||
@@ -167,6 +173,7 @@ task = create_maintenance_task(
|
||||
## Database Patterns
|
||||
|
||||
The maintenance task system follows the repository's established patterns:
|
||||
|
||||
- Uses `session_maker()` for database operations
|
||||
- Wraps sync database operations in `call_sync_from_async` for async routes
|
||||
- Follows proper SQLAlchemy query patterns
|
||||
@@ -174,15 +181,18 @@ The maintenance task system follows the repository's established patterns:
|
||||
## Integration with Existing Systems
|
||||
|
||||
### User Management
|
||||
|
||||
- Integrates with the existing `UserSettings` model
|
||||
- Uses the current user versioning system (`CURRENT_USER_SETTINGS_VERSION`)
|
||||
- Uses the current user versioning system (`ORG_SETTINGS_VERSION`)
|
||||
- Maintains compatibility with existing user management workflows
|
||||
|
||||
### Authentication
|
||||
|
||||
- Admin endpoints use the existing SaaS authentication system
|
||||
- Requires users to have `admin = True` in their UserSettings
|
||||
|
||||
### Monitoring
|
||||
|
||||
- Tasks are logged with structured information
|
||||
- Status updates are tracked in the database
|
||||
- Error information is preserved for debugging
|
||||
@@ -206,6 +216,7 @@ The maintenance task system follows the repository's established patterns:
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements that could be added:
|
||||
|
||||
- Task dependencies and scheduling
|
||||
- Retry mechanisms for failed tasks
|
||||
- Real-time progress updates
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from server.constants import CURRENT_USER_SETTINGS_VERSION
|
||||
from server.logger import logger
|
||||
from storage.database import session_maker
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskProcessor
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.core.config import load_openhands_config
|
||||
|
||||
|
||||
class UserVersionUpgradeProcessor(MaintenanceTaskProcessor):
|
||||
"""
|
||||
Processor for upgrading user settings to the current version.
|
||||
|
||||
This processor takes a list of user IDs and upgrades any users
|
||||
whose user_version is less than CURRENT_USER_SETTINGS_VERSION.
|
||||
"""
|
||||
|
||||
user_ids: List[str]
|
||||
|
||||
async def __call__(self, task: MaintenanceTask) -> dict:
|
||||
"""
|
||||
Process user version upgrades for the specified user IDs.
|
||||
|
||||
Args:
|
||||
task: The maintenance task being processed
|
||||
|
||||
Returns:
|
||||
dict: Results containing successful and failed user IDs
|
||||
"""
|
||||
logger.info(
|
||||
'user_version_upgrade_processor:start',
|
||||
extra={
|
||||
'task_id': task.id,
|
||||
'user_count': len(self.user_ids),
|
||||
'current_version': CURRENT_USER_SETTINGS_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
if len(self.user_ids) > 100:
|
||||
raise ValueError(
|
||||
f'Too many user IDs: {len(self.user_ids)}. Maximum is 100.'
|
||||
)
|
||||
|
||||
config = load_openhands_config()
|
||||
|
||||
# Track results
|
||||
successful_upgrades = []
|
||||
failed_upgrades = []
|
||||
users_already_current = []
|
||||
|
||||
# Find users that need upgrading
|
||||
with session_maker() as session:
|
||||
users_to_upgrade = (
|
||||
session.query(UserSettings)
|
||||
.filter(
|
||||
UserSettings.keycloak_user_id.in_(self.user_ids),
|
||||
UserSettings.user_version < CURRENT_USER_SETTINGS_VERSION,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Track users that are already current
|
||||
users_needing_upgrade_ids = {u.keycloak_user_id for u in users_to_upgrade}
|
||||
users_already_current = [
|
||||
uid for uid in self.user_ids if uid not in users_needing_upgrade_ids
|
||||
]
|
||||
|
||||
logger.info(
|
||||
'user_version_upgrade_processor:found_users',
|
||||
extra={
|
||||
'task_id': task.id,
|
||||
'users_to_upgrade': len(users_to_upgrade),
|
||||
'users_already_current': len(users_already_current),
|
||||
'total_requested': len(self.user_ids),
|
||||
},
|
||||
)
|
||||
|
||||
# Process each user that needs upgrading
|
||||
for user_settings in users_to_upgrade:
|
||||
user_id = user_settings.keycloak_user_id
|
||||
old_version = user_settings.user_version
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
'user_version_upgrade_processor:upgrading_user',
|
||||
extra={
|
||||
'task_id': task.id,
|
||||
'user_id': user_id,
|
||||
'old_version': old_version,
|
||||
'new_version': CURRENT_USER_SETTINGS_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
# Create SaasSettingsStore instance and upgrade
|
||||
settings_store = await SaasSettingsStore.get_instance(config, user_id)
|
||||
await settings_store.create_default_settings(user_settings)
|
||||
|
||||
successful_upgrades.append(
|
||||
{
|
||||
'user_id': user_id,
|
||||
'old_version': old_version,
|
||||
'new_version': CURRENT_USER_SETTINGS_VERSION,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'user_version_upgrade_processor:user_upgraded',
|
||||
extra={
|
||||
'task_id': task.id,
|
||||
'user_id': user_id,
|
||||
'old_version': old_version,
|
||||
'new_version': CURRENT_USER_SETTINGS_VERSION,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
failed_upgrades.append(
|
||||
{'user_id': user_id, 'old_version': old_version, 'error': str(e)}
|
||||
)
|
||||
|
||||
logger.error(
|
||||
'user_version_upgrade_processor:user_upgrade_failed',
|
||||
extra={
|
||||
'task_id': task.id,
|
||||
'user_id': user_id,
|
||||
'old_version': old_version,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
# Create result summary
|
||||
result = {
|
||||
'total_users': len(self.user_ids),
|
||||
'users_already_current': users_already_current,
|
||||
'successful_upgrades': successful_upgrades,
|
||||
'failed_upgrades': failed_upgrades,
|
||||
'summary': (
|
||||
f'Processed {len(self.user_ids)} users: '
|
||||
f'{len(successful_upgrades)} upgraded, '
|
||||
f'{len(users_already_current)} already current, '
|
||||
f'{len(failed_upgrades)} errors'
|
||||
),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
'user_version_upgrade_processor:completed',
|
||||
extra={'task_id': task.id, 'result': result},
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
|
||||
@@ -24,7 +22,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
# NOTE: these details are specific to the MCP protocol
|
||||
class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
@staticmethod
|
||||
def create_default_mcp_server_config(
|
||||
async def create_default_mcp_server_config(
|
||||
host: str, config: 'OpenHandsConfig', user_id: str | None = None
|
||||
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
|
||||
"""
|
||||
@@ -36,13 +34,16 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
Returns:
|
||||
A tuple containing the default SSE server configuration and a list of MCP stdio server configurations
|
||||
"""
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
if user_id:
|
||||
api_key = api_key_store.retrieve_mcp_api_key(user_id)
|
||||
api_key = await api_key_store.retrieve_mcp_api_key(user_id)
|
||||
|
||||
if not api_key:
|
||||
api_key = api_key_store.create_api_key(user_id, 'MCP_API_KEY', None)
|
||||
api_key = await api_key_store.create_api_key(
|
||||
user_id, 'MCP_API_KEY', None
|
||||
)
|
||||
|
||||
if not api_key:
|
||||
logger.error(f'Could not provision MCP API Key for user: {user_id}')
|
||||
|
||||
@@ -103,7 +103,7 @@ class SetAuthCookieMiddleware:
|
||||
keycloak_auth_cookie = request.cookies.get('keycloak_auth')
|
||||
auth_header = request.headers.get('Authorization')
|
||||
mcp_auth_header = request.headers.get('X-Session-API-Key')
|
||||
accepted_tos = False
|
||||
accepted_tos: bool | None = False
|
||||
if (
|
||||
keycloak_auth_cookie is None
|
||||
and (auth_header is None or not auth_header.startswith('Bearer '))
|
||||
@@ -144,7 +144,7 @@ class SetAuthCookieMiddleware:
|
||||
# "if accepted_tos is not None" as there should not be any users with
|
||||
# accepted_tos equal to "None"
|
||||
if accepted_tos is False and request.url.path != '/api/accept_tos':
|
||||
logger.error('User has not accepted the terms of service')
|
||||
logger.warning('User has not accepted the terms of service')
|
||||
raise TosNotAcceptedError
|
||||
|
||||
def _should_attach(self, request: Request) -> bool:
|
||||
@@ -162,6 +162,8 @@ class SetAuthCookieMiddleware:
|
||||
'/api/email/resend',
|
||||
'/oauth/device/authorize',
|
||||
'/oauth/device/token',
|
||||
'/api/v1/web-client/config',
|
||||
'/api/v1/webhooks/secrets',
|
||||
)
|
||||
if path in ignore_paths:
|
||||
return False
|
||||
|
||||
@@ -1,113 +1,86 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from server.config import get_config
|
||||
from server.constants import (
|
||||
BYOR_KEY_VERIFICATION_TIMEOUT,
|
||||
LITE_LLM_API_KEY,
|
||||
LITE_LLM_API_URL,
|
||||
)
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
# Helper functions for BYOR API key management
|
||||
async def get_byor_key_from_db(user_id: str) -> str | None:
|
||||
"""Get the BYOR key from the database for a user."""
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
user_db_settings = await call_sync_from_async(
|
||||
settings_store.get_user_settings_by_keycloak_id, user_id
|
||||
)
|
||||
if user_db_settings and user_db_settings.llm_api_key_for_byor:
|
||||
return user_db_settings.llm_api_key_for_byor
|
||||
current_org_id = user.current_org_id
|
||||
current_org_member: OrgMember = None
|
||||
for org_member in user.org_members:
|
||||
if org_member.org_id == current_org_id:
|
||||
current_org_member = org_member
|
||||
break
|
||||
if not current_org_member:
|
||||
return None
|
||||
if current_org_member.llm_api_key_for_byor:
|
||||
return current_org_member.llm_api_key_for_byor.get_secret_value()
|
||||
return None
|
||||
|
||||
|
||||
async def store_byor_key_in_db(user_id: str, key: str) -> None:
|
||||
"""Store the BYOR key in the database for a user."""
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
def _update_user_settings():
|
||||
with session_maker() as session:
|
||||
user_db_settings = settings_store.get_user_settings_by_keycloak_id(
|
||||
user_id, session
|
||||
)
|
||||
if user_db_settings:
|
||||
user_db_settings.llm_api_key_for_byor = key
|
||||
session.commit()
|
||||
logger.info(
|
||||
'Successfully stored BYOR key in user settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'User settings not found when trying to store BYOR key',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
await call_sync_from_async(_update_user_settings)
|
||||
current_org_id = user.current_org_id
|
||||
current_org_member: OrgMember = None
|
||||
for org_member in user.org_members:
|
||||
if org_member.org_id == current_org_id:
|
||||
current_org_member = org_member
|
||||
break
|
||||
if not current_org_member:
|
||||
return None
|
||||
current_org_member.llm_api_key_for_byor = key
|
||||
OrgMemberStore.update_org_member(current_org_member)
|
||||
|
||||
|
||||
async def generate_byor_key(user_id: str) -> str | None:
|
||||
"""Generate a new BYOR key for a user."""
|
||||
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
|
||||
logger.warning(
|
||||
'LiteLLM API configuration not found', extra={'user_id': user_id}
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
headers={
|
||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||
},
|
||||
) as client:
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/key/generate',
|
||||
json={
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
return None
|
||||
current_org_id = str(user.current_org_id)
|
||||
key = await LiteLlmManager.generate_key(
|
||||
user_id,
|
||||
current_org_id,
|
||||
f'BYOR Key - user {user_id}, org {current_org_id}',
|
||||
{'type': 'byor'},
|
||||
)
|
||||
|
||||
if key:
|
||||
logger.info(
|
||||
'Successfully generated new BYOR key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'metadata': {'type': 'byor'},
|
||||
'key_alias': f'BYOR Key - user {user_id}',
|
||||
'key_length': len(key) if key else 0,
|
||||
'key_prefix': key[:10] + '...' if key and len(key) > 10 else key,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
key = response_json.get('key')
|
||||
|
||||
if key:
|
||||
logger.info(
|
||||
'Successfully generated new BYOR key',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'key_length': len(key) if key else 0,
|
||||
'key_prefix': key[:10] + '...'
|
||||
if key and len(key) > 10
|
||||
else key,
|
||||
},
|
||||
)
|
||||
return key
|
||||
else:
|
||||
logger.error(
|
||||
'Failed to generate BYOR LLM API key - no key in response',
|
||||
extra={'user_id': user_id, 'response_json': response_json},
|
||||
)
|
||||
return None
|
||||
return key
|
||||
else:
|
||||
logger.error(
|
||||
'Failed to generate BYOR LLM API key - no key in response',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error generating BYOR key',
|
||||
@@ -116,96 +89,25 @@ async def generate_byor_key(user_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
async def verify_byor_key_in_litellm(byor_key: str, user_id: str) -> bool:
|
||||
"""Verify that a BYOR key is valid in LiteLLM by making a lightweight API call.
|
||||
|
||||
Args:
|
||||
byor_key: The BYOR key to verify
|
||||
user_id: The user ID for logging purposes
|
||||
|
||||
Returns:
|
||||
True if the key is verified as valid, False if verification fails or key is invalid.
|
||||
Returns False on network errors/timeouts to ensure we don't return potentially invalid keys.
|
||||
"""
|
||||
if not (LITE_LLM_API_URL and byor_key):
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
timeout=BYOR_KEY_VERIFICATION_TIMEOUT,
|
||||
) as client:
|
||||
# Make a lightweight request to verify the key
|
||||
# Using /v1/models endpoint as it's lightweight and requires authentication
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/v1/models',
|
||||
headers={
|
||||
'Authorization': f'Bearer {byor_key}',
|
||||
},
|
||||
)
|
||||
|
||||
# Only 200 status code indicates valid key
|
||||
if response.status_code == 200:
|
||||
logger.debug(
|
||||
'BYOR key verification successful',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return True
|
||||
|
||||
# All other status codes (401, 403, 500, etc.) are treated as invalid
|
||||
# This includes authentication errors and server errors
|
||||
logger.warning(
|
||||
'BYOR key verification failed - treating as invalid',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'status_code': response.status_code,
|
||||
'key_prefix': byor_key[:10] + '...'
|
||||
if len(byor_key) > 10
|
||||
else byor_key,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
except (httpx.TimeoutException, Exception) as e:
|
||||
# Any exception (timeout, network error, etc.) means we can't verify
|
||||
# Return False to trigger regeneration rather than returning potentially invalid key
|
||||
logger.warning(
|
||||
'BYOR key verification error - treating as invalid to ensure key validity',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
'error_type': type(e).__name__,
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def delete_byor_key_from_litellm(user_id: str, byor_key: str) -> bool:
|
||||
"""Delete the BYOR key from LiteLLM using the key directly."""
|
||||
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
|
||||
logger.warning(
|
||||
'LiteLLM API configuration not found', extra={'user_id': user_id}
|
||||
)
|
||||
return False
|
||||
"""Delete the BYOR key from LiteLLM using the key directly.
|
||||
|
||||
Also attempts to delete by key alias if the key is not found,
|
||||
to clean up orphaned aliases that could block key regeneration.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
headers={
|
||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||
},
|
||||
) as client:
|
||||
# Delete the key directly using the key value
|
||||
delete_url = f'{LITE_LLM_API_URL}/key/delete'
|
||||
delete_payload = {'keys': [byor_key]}
|
||||
# Get user to construct the key alias
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
key_alias = None
|
||||
if user and user.current_org_id:
|
||||
key_alias = f'BYOR Key - user {user_id}, org {user.current_org_id}'
|
||||
|
||||
delete_response = await client.post(delete_url, json=delete_payload)
|
||||
delete_response.raise_for_status()
|
||||
logger.info(
|
||||
'Successfully deleted BYOR key from LiteLLM',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return True
|
||||
await LiteLlmManager.delete_key(byor_key, key_alias=key_alias)
|
||||
logger.info(
|
||||
'Successfully deleted BYOR key from LiteLLM',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error deleting BYOR key from LiteLLM',
|
||||
@@ -250,11 +152,11 @@ class LlmApiKeyResponse(BaseModel):
|
||||
async def create_api_key(key_data: ApiKeyCreate, user_id: str = Depends(get_user_id)):
|
||||
"""Create a new API key for the authenticated user."""
|
||||
try:
|
||||
api_key = api_key_store.create_api_key(
|
||||
api_key = await api_key_store.create_api_key(
|
||||
user_id, key_data.name, key_data.expires_at
|
||||
)
|
||||
# Get the created key details
|
||||
keys = api_key_store.list_api_keys(user_id)
|
||||
keys = await api_key_store.list_api_keys(user_id)
|
||||
for key in keys:
|
||||
if key['name'] == key_data.name:
|
||||
return {
|
||||
@@ -282,7 +184,7 @@ async def create_api_key(key_data: ApiKeyCreate, user_id: str = Depends(get_user
|
||||
async def list_api_keys(user_id: str = Depends(get_user_id)):
|
||||
"""List all API keys for the authenticated user."""
|
||||
try:
|
||||
keys = api_key_store.list_api_keys(user_id)
|
||||
keys = await api_key_store.list_api_keys(user_id)
|
||||
return [
|
||||
{
|
||||
**key,
|
||||
@@ -311,7 +213,7 @@ async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)):
|
||||
"""Delete an API key."""
|
||||
try:
|
||||
# First, verify the key belongs to the user
|
||||
keys = api_key_store.list_api_keys(user_id)
|
||||
keys = await api_key_store.list_api_keys(user_id)
|
||||
key_to_delete = None
|
||||
|
||||
for key in keys:
|
||||
@@ -357,7 +259,7 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
byor_key = await get_byor_key_from_db(user_id)
|
||||
if byor_key:
|
||||
# Validate that the key is actually registered in LiteLLM
|
||||
is_valid = await verify_byor_key_in_litellm(byor_key, user_id)
|
||||
is_valid = await LiteLlmManager.verify_key(byor_key, user_id)
|
||||
if is_valid:
|
||||
return {'key': byor_key}
|
||||
else:
|
||||
@@ -412,15 +314,6 @@ async def refresh_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
|
||||
logger.info('Starting BYOR LLM API key refresh', extra={'user_id': user_id})
|
||||
|
||||
try:
|
||||
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
|
||||
logger.warning(
|
||||
'LiteLLM API configuration not found', extra={'user_id': user_id}
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='LiteLLM API configuration not found',
|
||||
)
|
||||
|
||||
# Get the existing BYOR key from the database
|
||||
existing_byor_key = await get_byor_key_from_db(user_id)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import base64
|
||||
import json
|
||||
import uuid
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal, Optional
|
||||
@@ -22,12 +23,12 @@ from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.recaptcha_service import recaptcha_service
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config, sign_token
|
||||
from server.config import sign_token
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from server.routes.event_webhook import _get_session_api_key, _get_user_id
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
@@ -87,7 +88,8 @@ def get_cookie_domain(request: Request) -> str | None:
|
||||
# for now just use the full hostname except for staging stacks.
|
||||
return (
|
||||
None
|
||||
if (request.url.hostname or '').endswith('staging.all-hand.dev')
|
||||
if not request.url.hostname
|
||||
or request.url.hostname.endswith('staging.all-hands.dev')
|
||||
else request.url.hostname
|
||||
)
|
||||
|
||||
@@ -174,6 +176,23 @@ async def keycloak_callback(
|
||||
|
||||
email = user_info.get('email')
|
||||
user_id = user_info['sub']
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
if not user:
|
||||
user = await UserStore.create_user(user_id, user_info)
|
||||
else:
|
||||
# Existing user — gradually backfill contact_name if it still has a username-style value
|
||||
await UserStore.backfill_contact_name(user_id, user_info)
|
||||
|
||||
if not user:
|
||||
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={
|
||||
'error': f'Failed to authenticate user {user_info["preferred_username"]}'
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(f'Logging in user {str(user.id)} in org {user.current_org_id}')
|
||||
|
||||
# reCAPTCHA verification with Account Defender
|
||||
if RECAPTCHA_SITE_KEY:
|
||||
@@ -203,6 +222,7 @@ async def keycloak_callback(
|
||||
user_ip=user_ip,
|
||||
user_agent=user_agent,
|
||||
email=email,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
if not result.allowed:
|
||||
@@ -360,15 +380,7 @@ async def keycloak_callback(
|
||||
f'&state={state}'
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
user_settings = settings_store.get_user_settings_by_keycloak_id(user_id)
|
||||
has_accepted_tos = (
|
||||
user_settings is not None and user_settings.accepted_tos is not None
|
||||
)
|
||||
|
||||
has_accepted_tos = user.accepted_tos is not None
|
||||
# If the user hasn't accepted the TOS, redirect to the TOS page
|
||||
if not has_accepted_tos:
|
||||
encoded_redirect_url = quote(redirect_url, safe='')
|
||||
@@ -486,28 +498,20 @@ async def accept_tos(request: Request):
|
||||
redirect_url = body.get('redirect_url', str(request.base_url))
|
||||
|
||||
# Update user settings with TOS acceptance
|
||||
accepted_tos: datetime = datetime.now(timezone.utc)
|
||||
with session_maker() as session:
|
||||
user_settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if user_settings:
|
||||
user_settings.accepted_tos = datetime.now(timezone.utc)
|
||||
session.merge(user_settings)
|
||||
else:
|
||||
# Create user settings if they don't exist
|
||||
user_settings = UserSettings(
|
||||
keycloak_user_id=user_id,
|
||||
accepted_tos=datetime.now(timezone.utc),
|
||||
user_version=0, # This will trigger a migration to the latest version on next load
|
||||
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
|
||||
if not user:
|
||||
session.rollback()
|
||||
logger.error('User for {user_id} not found.')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'User does not exist'},
|
||||
)
|
||||
session.add(user_settings)
|
||||
|
||||
user.accepted_tos = accepted_tos
|
||||
session.commit()
|
||||
|
||||
logger.info(f'User {user_id} accepted TOS')
|
||||
logger.info(f'User {user_id} accepted TOS')
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
|
||||
|
||||
@@ -4,63 +4,42 @@ from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
|
||||
import httpx
|
||||
import stripe
|
||||
from dateutil.relativedelta import relativedelta # type: ignore
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.responses import RedirectResponse
|
||||
from integrations import stripe_service
|
||||
from pydantic import BaseModel
|
||||
from server.config import get_config
|
||||
from server.constants import (
|
||||
LITE_LLM_API_KEY,
|
||||
LITE_LLM_API_URL,
|
||||
STRIPE_API_KEY,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
SUBSCRIPTION_PRICE_DATA,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.logger import logger
|
||||
from starlette.datastructures import URL
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.config import get_global_config
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
billing_router = APIRouter(prefix='/api/billing')
|
||||
|
||||
|
||||
# TODO: Add a new app_mode named "ON_PREM" to support self-hosted customers instead of doing this
|
||||
# and members should comment out the "validate_saas_environment" function if they are developing and testing locally.
|
||||
def is_all_hands_saas_environment(request: Request) -> bool:
|
||||
"""Check if the current domain is an All Hands SaaS environment.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Returns:
|
||||
True if the current domain contains "all-hands.dev" or "openhands.dev" postfix
|
||||
async def validate_billing_enabled() -> None:
|
||||
"""
|
||||
hostname = request.url.hostname or ''
|
||||
return hostname.endswith('all-hands.dev') or hostname.endswith('openhands.dev')
|
||||
|
||||
|
||||
def validate_saas_environment(request: Request) -> None:
|
||||
"""Validate that the request is coming from an All Hands SaaS environment.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Raises:
|
||||
HTTPException: If the request is not from an All Hands SaaS environment
|
||||
Validate that the billing feature flag is enabled
|
||||
"""
|
||||
if not is_all_hands_saas_environment(request):
|
||||
config = get_global_config()
|
||||
web_client_config = await config.web_client.get_web_client_config()
|
||||
if not web_client_config.feature_flags.enable_billing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Checkout sessions are only available for All Hands SaaS environments',
|
||||
detail=(
|
||||
'Billing is disabled in this environment. '
|
||||
'Please set OH_WEB_CLIENT_FEATURE_FLAGS_ENABLE_BILLING to enable billing.'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -106,31 +85,20 @@ def calculate_credits(user_info: LiteLlmUserInfo) -> float:
|
||||
return max(max_budget - spend, 0.0)
|
||||
|
||||
|
||||
# Endpoint to retrieve user's current credit balance
|
||||
# Endpoint to retrieve the current organization's credit balance
|
||||
@billing_router.get('/credits')
|
||||
async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse:
|
||||
if not stripe_service.STRIPE_API_KEY:
|
||||
return GetCreditsResponse()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(), timeout=15.0
|
||||
) as client:
|
||||
user_json = await _get_litellm_user(client, user_id)
|
||||
credits = calculate_credits(user_json['user_info'])
|
||||
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f'litellm_get_user_failed: {type(e).__name__}: {e}',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'status_code': e.response.status_code,
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve credit balance from billing service',
|
||||
)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
user_team_info = await LiteLlmManager.get_user_team_info(
|
||||
user_id, str(user.current_org_id)
|
||||
)
|
||||
# Update to use calculate_credits
|
||||
spend = user_team_info.get('spend', 0)
|
||||
max_budget = (user_team_info.get('litellm_budget_table') or {}).get('max_budget', 0)
|
||||
credits = max(max_budget - spend, 0)
|
||||
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
|
||||
|
||||
|
||||
# Endpoint to retrieve user's current subscription access
|
||||
@@ -165,79 +133,7 @@ async def get_subscription_access(
|
||||
async def has_payment_method(user_id: str = Depends(get_user_id)) -> bool:
|
||||
if not user_id:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
||||
return await stripe_service.has_payment_method(user_id)
|
||||
|
||||
|
||||
# Endpoint to cancel user's subscription
|
||||
@billing_router.post('/cancel-subscription')
|
||||
async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONResponse:
|
||||
"""Cancel user's active subscription at the end of the current billing period."""
|
||||
if not user_id:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
with session_maker() as session:
|
||||
# Find the user's active subscription
|
||||
now = datetime.now(UTC)
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.filter(SubscriptionAccess.user_id == user_id)
|
||||
.filter(SubscriptionAccess.start_at <= now)
|
||||
.filter(SubscriptionAccess.end_at >= now)
|
||||
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not already cancelled
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No active subscription found',
|
||||
)
|
||||
|
||||
if not subscription_access.stripe_subscription_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot cancel subscription: missing Stripe subscription ID',
|
||||
)
|
||||
|
||||
try:
|
||||
# Cancel the subscription in Stripe at period end
|
||||
await stripe.Subscription.modify_async(
|
||||
subscription_access.stripe_subscription_id, cancel_at_period_end=True
|
||||
)
|
||||
|
||||
# Update local database
|
||||
subscription_access.cancelled_at = datetime.now(UTC)
|
||||
session.merge(subscription_access)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'subscription_cancelled',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'stripe_subscription_id': subscription_access.stripe_subscription_id,
|
||||
'subscription_access_id': subscription_access.id,
|
||||
'end_at': subscription_access.end_at,
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'success', 'message': 'Subscription cancelled successfully'}
|
||||
)
|
||||
|
||||
except stripe.StripeError as e:
|
||||
logger.error(
|
||||
'stripe_cancellation_failed',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'stripe_subscription_id': subscription_access.stripe_subscription_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to cancel subscription: {str(e)}',
|
||||
)
|
||||
return await stripe_service.has_payment_method_by_user_id(user_id)
|
||||
|
||||
|
||||
# Endpoint to create a new setup intent in stripe
|
||||
@@ -245,17 +141,17 @@ async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONRespon
|
||||
async def create_customer_setup_session(
|
||||
request: Request, user_id: str = Depends(get_user_id)
|
||||
) -> CreateBillingSessionResponse:
|
||||
validate_saas_environment(request)
|
||||
|
||||
customer_id = await stripe_service.find_or_create_customer(user_id)
|
||||
await validate_billing_enabled()
|
||||
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
|
||||
base_url = _get_base_url(request)
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_id,
|
||||
customer=customer_info['customer_id'],
|
||||
mode='setup',
|
||||
payment_method_types=['card'],
|
||||
success_url=f'{request.base_url}?free_credits=success',
|
||||
cancel_url=f'{request.base_url}',
|
||||
success_url=f'{base_url}?free_credits=success',
|
||||
cancel_url=f'{base_url}',
|
||||
)
|
||||
return CreateBillingSessionResponse(redirect_url=checkout_session.url) # type: ignore[arg-type]
|
||||
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
|
||||
|
||||
|
||||
# Endpoint to create a new Stripe checkout session for credit purchase
|
||||
@@ -265,11 +161,11 @@ async def create_checkout_session(
|
||||
request: Request,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CreateBillingSessionResponse:
|
||||
validate_saas_environment(request)
|
||||
|
||||
customer_id = await stripe_service.find_or_create_customer(user_id)
|
||||
await validate_billing_enabled()
|
||||
base_url = _get_base_url(request)
|
||||
customer_info = await stripe_service.find_or_create_customer_by_user_id(user_id)
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_id,
|
||||
customer=customer_info['customer_id'],
|
||||
line_items=[
|
||||
{
|
||||
'price_data': {
|
||||
@@ -282,21 +178,22 @@ async def create_checkout_session(
|
||||
'tax_behavior': 'exclusive',
|
||||
},
|
||||
'quantity': 1,
|
||||
}
|
||||
},
|
||||
],
|
||||
mode='payment',
|
||||
payment_method_types=['card'],
|
||||
saved_payment_method_options={
|
||||
'payment_method_save': 'enabled',
|
||||
},
|
||||
success_url=f'{request.base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{request.base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
success_url=f'{base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
)
|
||||
logger.info(
|
||||
'created_stripe_checkout_session',
|
||||
extra={
|
||||
'stripe_customer_id': customer_id,
|
||||
'stripe_customer_id': customer_info['customer_id'],
|
||||
'user_id': user_id,
|
||||
'org_id': customer_info['org_id'],
|
||||
'amount': body.amount,
|
||||
'checkout_session_id': checkout_session.id,
|
||||
},
|
||||
@@ -305,105 +202,14 @@ async def create_checkout_session(
|
||||
billing_session = BillingSession(
|
||||
id=checkout_session.id,
|
||||
user_id=user_id,
|
||||
org_id=customer_info['org_id'],
|
||||
price=body.amount,
|
||||
price_code='NA',
|
||||
billing_session_type=BillingSessionType.DIRECT_PAYMENT.value,
|
||||
)
|
||||
session.add(billing_session)
|
||||
session.commit()
|
||||
|
||||
return CreateBillingSessionResponse(redirect_url=checkout_session.url) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@billing_router.post('/subscription-checkout-session')
|
||||
async def create_subscription_checkout_session(
|
||||
request: Request,
|
||||
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CreateBillingSessionResponse:
|
||||
validate_saas_environment(request)
|
||||
|
||||
# Prevent duplicate subscriptions for the same user
|
||||
with session_maker() as session:
|
||||
now = datetime.now(UTC)
|
||||
existing_active_subscription = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.filter(SubscriptionAccess.user_id == user_id)
|
||||
.filter(SubscriptionAccess.start_at <= now)
|
||||
.filter(SubscriptionAccess.end_at >= now)
|
||||
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not cancelled
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_active_subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot create subscription: User already has an active subscription that has not been cancelled',
|
||||
)
|
||||
|
||||
customer_id = await stripe_service.find_or_create_customer(user_id)
|
||||
subscription_price_data = SUBSCRIPTION_PRICE_DATA[billing_session_type.value]
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_id,
|
||||
line_items=[
|
||||
{
|
||||
'price_data': subscription_price_data,
|
||||
'quantity': 1,
|
||||
}
|
||||
],
|
||||
mode='subscription',
|
||||
payment_method_types=['card'],
|
||||
saved_payment_method_options={
|
||||
'payment_method_save': 'enabled',
|
||||
},
|
||||
success_url=f'{request.base_url}api/billing/success?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
cancel_url=f'{request.base_url}api/billing/cancel?session_id={{CHECKOUT_SESSION_ID}}',
|
||||
subscription_data={
|
||||
'metadata': {
|
||||
'user_id': user_id,
|
||||
'billing_session_type': billing_session_type.value,
|
||||
}
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
'created_stripe_subscription_checkout_session',
|
||||
extra={
|
||||
'stripe_customer_id': customer_id,
|
||||
'user_id': user_id,
|
||||
'checkout_session_id': checkout_session.id,
|
||||
'billing_session_type': billing_session_type.value,
|
||||
},
|
||||
)
|
||||
with session_maker() as session:
|
||||
billing_session = BillingSession(
|
||||
id=checkout_session.id,
|
||||
user_id=user_id,
|
||||
price=subscription_price_data['unit_amount'],
|
||||
price_code='NA',
|
||||
billing_session_type=billing_session_type.value,
|
||||
)
|
||||
session.add(billing_session)
|
||||
session.commit()
|
||||
|
||||
return CreateBillingSessionResponse(
|
||||
redirect_url=typing.cast(str, checkout_session.url)
|
||||
)
|
||||
|
||||
|
||||
@billing_router.get('/create-subscription-checkout-session')
|
||||
async def create_subscription_checkout_session_via_get(
|
||||
request: Request,
|
||||
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> RedirectResponse:
|
||||
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
|
||||
validate_saas_environment(request)
|
||||
|
||||
response = await create_subscription_checkout_session(
|
||||
request, billing_session_type, user_id
|
||||
)
|
||||
return RedirectResponse(response.redirect_url)
|
||||
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
|
||||
|
||||
|
||||
# Callback endpoint for successful Stripe payments - updates user credits and billing session status
|
||||
@@ -425,15 +231,6 @@ async def success_callback(session_id: str, request: Request):
|
||||
)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Any non direct payment (Subscription) is processed in the invoice_payment.paid by the webhook
|
||||
if (
|
||||
billing_session.billing_session_type
|
||||
!= BillingSessionType.DIRECT_PAYMENT.value
|
||||
):
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
stripe_session = stripe.checkout.Session.retrieve(session_id)
|
||||
if stripe_session.status != 'complete':
|
||||
# Hopefully this never happens - we get a redirect from stripe where the payment is not yet complete
|
||||
@@ -447,34 +244,40 @@ async def success_callback(session_id: str, request: Request):
|
||||
)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
# Update max budget in litellm
|
||||
user_json = await _get_litellm_user(client, billing_session.user_id)
|
||||
amount_subtotal = stripe_session.amount_subtotal or 0
|
||||
add_credits = amount_subtotal / 100
|
||||
new_max_budget = (
|
||||
(user_json.get('user_info') or {}).get('max_budget') or 0
|
||||
) + add_credits
|
||||
await _upsert_litellm_user(client, billing_session.user_id, new_max_budget)
|
||||
user = await UserStore.get_user_by_id_async(billing_session.user_id)
|
||||
user_team_info = await LiteLlmManager.get_user_team_info(
|
||||
billing_session.user_id, str(user.current_org_id)
|
||||
)
|
||||
amount_subtotal = stripe_session.amount_subtotal or 0
|
||||
add_credits = amount_subtotal / 100
|
||||
max_budget = (user_team_info.get('litellm_budget_table') or {}).get(
|
||||
'max_budget', 0
|
||||
)
|
||||
new_max_budget = max_budget + add_credits
|
||||
|
||||
# Store transaction status
|
||||
billing_session.status = 'completed'
|
||||
billing_session.price = amount_subtotal
|
||||
billing_session.updated_at = datetime.now(UTC)
|
||||
session.merge(billing_session)
|
||||
logger.info(
|
||||
'stripe_checkout_success',
|
||||
extra={
|
||||
'amount_subtotal': stripe_session.amount_subtotal,
|
||||
'user_id': billing_session.user_id,
|
||||
'checkout_session_id': billing_session.id,
|
||||
'stripe_customer_id': stripe_session.customer,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
await LiteLlmManager.update_team_and_users_budget(
|
||||
str(user.current_org_id), new_max_budget
|
||||
)
|
||||
|
||||
# Store transaction status
|
||||
billing_session.status = 'completed'
|
||||
billing_session.price = add_credits
|
||||
billing_session.updated_at = datetime.now(UTC)
|
||||
session.merge(billing_session)
|
||||
logger.info(
|
||||
'stripe_checkout_success',
|
||||
extra={
|
||||
'amount_subtotal': stripe_session.amount_subtotal,
|
||||
'user_id': billing_session.user_id,
|
||||
'org_id': str(user.current_org_id),
|
||||
'checkout_session_id': billing_session.id,
|
||||
'stripe_customer_id': stripe_session.customer,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings/billing?checkout=success', status_code=302
|
||||
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
|
||||
@@ -501,206 +304,14 @@ async def cancel_callback(session_id: str, request: Request):
|
||||
session.merge(billing_session)
|
||||
session.commit()
|
||||
|
||||
# Redirect credit purchases to billing screen, subscriptions to LLM settings
|
||||
if (
|
||||
billing_session.billing_session_type
|
||||
== BillingSessionType.DIRECT_PAYMENT.value
|
||||
):
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings/billing?checkout=cancel',
|
||||
status_code=302,
|
||||
)
|
||||
else:
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings?checkout=cancel', status_code=302
|
||||
)
|
||||
|
||||
# If no billing session found, default to LLM settings (subscription flow)
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings?checkout=cancel', status_code=302
|
||||
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
|
||||
)
|
||||
|
||||
|
||||
@billing_router.post('/stripe-webhook')
|
||||
async def stripe_webhook(request: Request) -> JSONResponse:
|
||||
"""Endpoint for stripe webhooks."""
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get('stripe-signature')
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError as e:
|
||||
# Invalid payload
|
||||
raise HTTPException(status_code=400, detail=f'Invalid payload: {e}')
|
||||
except stripe.SignatureVerificationError as e:
|
||||
# Invalid signature
|
||||
raise HTTPException(status_code=400, detail=f'Invalid signature: {e}')
|
||||
|
||||
# Handle the event
|
||||
logger.info('stripe_webhook_event', extra={'event': event})
|
||||
event_type = event['type']
|
||||
if event_type == 'invoice.paid':
|
||||
invoice = event['data']['object']
|
||||
amount_paid = invoice.amount_paid
|
||||
metadata = invoice.parent.subscription_details.metadata # type: ignore
|
||||
billing_session_type = metadata.billing_session_type
|
||||
assert (
|
||||
amount_paid == SUBSCRIPTION_PRICE_DATA[billing_session_type]['unit_amount']
|
||||
)
|
||||
user_id = metadata.user_id
|
||||
|
||||
start_at = datetime.now(UTC)
|
||||
if billing_session_type == BillingSessionType.MONTHLY_SUBSCRIPTION.value:
|
||||
end_at = start_at + relativedelta(months=1)
|
||||
else:
|
||||
raise ValueError(f'unknown_billing_session_type:{billing_session_type}')
|
||||
|
||||
with session_maker() as session:
|
||||
subscription_access = SubscriptionAccess(
|
||||
status='ACTIVE',
|
||||
user_id=user_id,
|
||||
start_at=start_at,
|
||||
end_at=end_at,
|
||||
amount_paid=amount_paid,
|
||||
stripe_invoice_payment_id=invoice.payment_intent,
|
||||
stripe_subscription_id=invoice.subscription, # Store Stripe subscription ID
|
||||
)
|
||||
session.add(subscription_access)
|
||||
session.commit()
|
||||
elif event_type == 'customer.subscription.updated':
|
||||
subscription = event['data']['object']
|
||||
subscription_id = subscription['id']
|
||||
|
||||
# Handle subscription cancellation
|
||||
if subscription.get('cancel_at_period_end') is True:
|
||||
with session_maker() as session:
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(
|
||||
SubscriptionAccess.stripe_subscription_id == subscription_id
|
||||
)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.first()
|
||||
)
|
||||
|
||||
if subscription_access and not subscription_access.cancelled_at:
|
||||
subscription_access.cancelled_at = datetime.now(UTC)
|
||||
session.merge(subscription_access)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'subscription_cancelled_via_webhook',
|
||||
extra={
|
||||
'stripe_subscription_id': subscription_id,
|
||||
'user_id': subscription_access.user_id,
|
||||
'subscription_access_id': subscription_access.id,
|
||||
},
|
||||
)
|
||||
elif event_type == 'customer.subscription.deleted':
|
||||
subscription = event['data']['object']
|
||||
subscription_id = subscription['id']
|
||||
|
||||
with session_maker() as session:
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.stripe_subscription_id == subscription_id)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.first()
|
||||
)
|
||||
|
||||
if subscription_access:
|
||||
subscription_access.status = 'DISABLED'
|
||||
subscription_access.updated_at = datetime.now(UTC)
|
||||
session.merge(subscription_access)
|
||||
session.commit()
|
||||
|
||||
# Reset user settings to free tier defaults
|
||||
reset_user_to_free_tier_settings(subscription_access.user_id)
|
||||
|
||||
logger.info(
|
||||
'subscription_expired_reset_to_free_tier',
|
||||
extra={
|
||||
'stripe_subscription_id': subscription_id,
|
||||
'user_id': subscription_access.user_id,
|
||||
'subscription_access_id': subscription_access.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info('stripe_webhook_unhandled_event_type', extra={'type': event_type})
|
||||
|
||||
return JSONResponse({'status': 'success'})
|
||||
|
||||
|
||||
def reset_user_to_free_tier_settings(user_id: str) -> None:
|
||||
"""Reset user settings to free tier defaults when subscription ends."""
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
user_settings = settings_store.get_user_settings_by_keycloak_id(
|
||||
user_id, session
|
||||
)
|
||||
|
||||
if user_settings:
|
||||
user_settings.llm_model = get_default_litellm_model()
|
||||
user_settings.llm_api_key = None
|
||||
user_settings.llm_api_key_for_byor = None
|
||||
user_settings.llm_base_url = LITE_LLM_API_URL
|
||||
user_settings.max_budget_per_task = None
|
||||
user_settings.confirmation_mode = False
|
||||
user_settings.enable_solvability_analysis = False
|
||||
user_settings.security_analyzer = 'llm'
|
||||
user_settings.agent = 'CodeActAgent'
|
||||
user_settings.language = 'en'
|
||||
user_settings.enable_default_condenser = True
|
||||
user_settings.enable_sound_notifications = False
|
||||
user_settings.enable_proactive_conversation_starters = True
|
||||
user_settings.user_consents_to_analytics = False
|
||||
|
||||
session.merge(user_settings)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'user_settings_reset_to_free_tier',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'reset_timestamp': datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _get_litellm_user(client: httpx.AsyncClient, user_id: str) -> dict:
|
||||
"""Get a user from litellm with the id matching that given.
|
||||
|
||||
If no such user exists, returns a dummy user in the format:
|
||||
`{'user_id': '<USER_ID>', 'user_info': {'spend': 0}, 'keys': [], 'teams': []}`
|
||||
"""
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/user/info?user_id={user_id}',
|
||||
headers={
|
||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
async def _upsert_litellm_user(
|
||||
client: httpx.AsyncClient, user_id: str, max_budget: float
|
||||
):
|
||||
"""Insert / Update a user in litellm."""
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/user/update',
|
||||
headers={
|
||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||
},
|
||||
json={
|
||||
'user_id': user_id,
|
||||
'max_budget': max_budget,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
def _get_base_url(request: Request) -> URL:
|
||||
# Never send any part of the credit card process over a non secure connection
|
||||
base_url = request.base_url
|
||||
if base_url.hostname != 'localhost':
|
||||
base_url = base_url.replace(scheme='https')
|
||||
return base_url
|
||||
|
||||
@@ -5,8 +5,8 @@ from threading import Thread
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from sqlalchemy import func, select
|
||||
from storage.database import a_session_maker, engine, session_maker
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.database import a_session_maker, get_engine, session_maker
|
||||
from storage.user import User
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.async_utils import wait_all
|
||||
@@ -47,6 +47,7 @@ def add_debugging_routes(api: FastAPI):
|
||||
- checked_out: Number of connections currently in use
|
||||
- overflow: Number of overflow connections created beyond pool_size
|
||||
"""
|
||||
engine = get_engine()
|
||||
return {
|
||||
'checked_in': engine.pool.checkedin(),
|
||||
'checked_out': engine.pool.checkedout(),
|
||||
@@ -127,8 +128,9 @@ def _db_check(delay: int):
|
||||
delay: Number of seconds to hold the database connection
|
||||
"""
|
||||
with session_maker() as session:
|
||||
num_users = session.query(UserSettings).count()
|
||||
num_users = session.query(User).count()
|
||||
time.sleep(delay)
|
||||
engine = get_engine()
|
||||
logger.info(
|
||||
'check',
|
||||
extra={
|
||||
@@ -155,7 +157,7 @@ async def _a_db_check(delay: int):
|
||||
delay: Number of seconds to hold the database connection
|
||||
"""
|
||||
async with a_session_maker() as a_session:
|
||||
stmt = select(func.count(UserSettings.id))
|
||||
stmt = select(func.count(User.id))
|
||||
num_users = await a_session.execute(stmt)
|
||||
await asyncio.sleep(delay)
|
||||
logger.info(f'a_num_users:{num_users.scalar_one()}')
|
||||
|
||||
@@ -21,7 +21,7 @@ from server.utils.conversation_callback_utils import (
|
||||
update_conversation_stats,
|
||||
)
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
@@ -226,12 +226,12 @@ def _parse_conversation_id_and_subpath(path: str) -> Tuple[str, str]:
|
||||
|
||||
def _get_user_id(conversation_id: str) -> str:
|
||||
with session_maker() as session:
|
||||
conversation_metadata = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
return conversation_metadata.user_id
|
||||
return str(conversation_metadata_saas.user_id)
|
||||
|
||||
|
||||
async def _get_session_api_key(user_id: str, conversation_id: str) -> str | None:
|
||||
|
||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.future import select
|
||||
from storage.database import session_maker
|
||||
from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.server.shared import file_store
|
||||
@@ -33,10 +33,10 @@ async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
|
||||
def _verify_conversation():
|
||||
with session_maker() as session:
|
||||
metadata = (
|
||||
session.query(StoredConversationMetadata)
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadata.conversation_id == conversation_id,
|
||||
StoredConversationMetadata.user_id == user_id,
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id,
|
||||
StoredConversationMetadataSaas.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import zlib
|
||||
from base64 import b64decode, b64encode
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
@@ -51,7 +52,11 @@ def add_github_proxy_routes(app: FastAPI):
|
||||
state_payload = json.dumps(
|
||||
[query_params['state'][0], query_params['redirect_uri'][0]]
|
||||
)
|
||||
state = b64encode(_fernet().encrypt(state_payload.encode())).decode()
|
||||
# Compress before encrypting to reduce URL length
|
||||
# This is critical for feature deployments where reCAPTCHA tokens in state
|
||||
# can cause "URL too long" errors from GitHub
|
||||
compressed_payload = zlib.compress(state_payload.encode())
|
||||
state = b64encode(_fernet().encrypt(compressed_payload)).decode()
|
||||
query_params['state'] = [state]
|
||||
query_params['redirect_uri'] = [
|
||||
f'https://{request.url.netloc}/github-proxy/callback'
|
||||
@@ -67,7 +72,9 @@ def add_github_proxy_routes(app: FastAPI):
|
||||
parsed_url = urlparse(str(request.url))
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
state = query_params['state'][0]
|
||||
decrypted_state = _fernet().decrypt(b64decode(state.encode())).decode()
|
||||
# Decrypt and decompress (reverse of github_proxy_start)
|
||||
decrypted_payload = _fernet().decrypt(b64decode(state.encode()))
|
||||
decrypted_state = zlib.decompress(decrypted_payload).decode()
|
||||
|
||||
# Build query Params
|
||||
state, redirect_uri = json.loads(decrypted_state)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -5,15 +7,16 @@ import uuid
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from integrations.jira.jira_manager import JiraManager
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.utils import HOST_URL
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from server.auth.constants import JIRA_CLIENT_ID, JIRA_CLIENT_SECRET
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import WEB_HOST
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
from storage.redis import create_redis_client
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -24,7 +27,7 @@ JIRA_WEBHOOKS_ENABLED = os.environ.get('JIRA_WEBHOOKS_ENABLED', '0') in (
|
||||
'1',
|
||||
'true',
|
||||
)
|
||||
JIRA_REDIRECT_URI = f'https://{WEB_HOST}/integration/jira/callback'
|
||||
JIRA_REDIRECT_URI = f'{HOST_URL}/integration/jira/callback'
|
||||
JIRA_SCOPES = 'read:me read:jira-user read:jira-work'
|
||||
JIRA_AUTH_URL = 'https://auth.atlassian.com/authorize'
|
||||
JIRA_TOKEN_URL = 'https://auth.atlassian.com/oauth/token'
|
||||
@@ -122,6 +125,63 @@ jira_manager = JiraManager(token_manager)
|
||||
redis_client = create_redis_client()
|
||||
|
||||
|
||||
async def verify_jira_signature(body: bytes, signature: str, payload: dict):
|
||||
"""
|
||||
Verify Jira webhook signature.
|
||||
|
||||
Args:
|
||||
body: Raw request body bytes
|
||||
signature: Signature from x-hub-signature header (format: "sha256=<hash>")
|
||||
payload: Parsed JSON payload from webhook
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if signature verification fails or workspace is invalid
|
||||
|
||||
Returns:
|
||||
None (raises exception on failure)
|
||||
"""
|
||||
|
||||
if not signature:
|
||||
raise HTTPException(
|
||||
status_code=403, detail='x-hub-signature header is missing!'
|
||||
)
|
||||
|
||||
workspace_name = jira_manager.get_workspace_name_from_payload(payload)
|
||||
if workspace_name is None:
|
||||
logger.warning('[Jira] No workspace name found in webhook payload')
|
||||
raise HTTPException(
|
||||
status_code=403, detail='Workspace name not found in payload'
|
||||
)
|
||||
|
||||
workspace: (
|
||||
JiraWorkspace | None
|
||||
) = await jira_manager.integration_store.get_workspace_by_name(workspace_name)
|
||||
|
||||
if workspace is None:
|
||||
logger.warning(f'[Jira] Could not identify workspace {workspace_name}')
|
||||
raise HTTPException(status_code=403, detail='Unidentified workspace')
|
||||
|
||||
if workspace.status != 'active':
|
||||
logger.warning(
|
||||
'[Jira] Workspace is inactive',
|
||||
extra={
|
||||
'jira_workspace_id': workspace.id,
|
||||
'parsed_workspace_name': workspace.name,
|
||||
'status': workspace.status,
|
||||
},
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=403, detail='Workspace is inactive')
|
||||
|
||||
webhook_secret = token_manager.decrypt_text(workspace.webhook_secret)
|
||||
expected_signature = hmac.new(
|
||||
webhook_secret.encode(), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
if not hmac.compare_digest(expected_signature, signature):
|
||||
raise HTTPException(status_code=403, detail="Request signatures didn't match!")
|
||||
|
||||
|
||||
async def _handle_workspace_link_creation(
|
||||
user_id: str, jira_user_id: str, target_workspace: str
|
||||
):
|
||||
@@ -216,6 +276,7 @@ async def _validate_workspace_update_permissions(user_id: str, target_workspace:
|
||||
async def jira_events(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
x_hub_signature: str = Header(None),
|
||||
):
|
||||
"""Handle Jira webhook events."""
|
||||
# Check if Jira webhooks are enabled
|
||||
@@ -227,13 +288,15 @@ async def jira_events(
|
||||
)
|
||||
|
||||
try:
|
||||
signature_valid, signature, payload = await jira_manager.validate_request(
|
||||
request
|
||||
)
|
||||
parts = x_hub_signature.split('=', 1)
|
||||
if not (len(parts) == 2 and parts[1]):
|
||||
raise HTTPException(status_code=403, detail='Malformed x-hub-signature!')
|
||||
|
||||
if not signature_valid:
|
||||
logger.warning('[Jira] Invalid webhook signature')
|
||||
raise HTTPException(status_code=403, detail='Invalid webhook signature!')
|
||||
signature = parts[1]
|
||||
body = await request.body()
|
||||
payload = await request.json()
|
||||
|
||||
await verify_jira_signature(body, signature, payload)
|
||||
|
||||
# Check for duplicate requests using Redis
|
||||
key = f'jira:{signature}'
|
||||
|
||||
@@ -15,7 +15,6 @@ from integrations.slack.slack_manager import SlackManager
|
||||
from integrations.utils import (
|
||||
HOST_URL,
|
||||
)
|
||||
from pydantic import SecretStr
|
||||
from server.auth.constants import (
|
||||
KEYCLOAK_CLIENT_ID,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
@@ -35,6 +34,7 @@ from slack_sdk.web.async_client import AsyncWebClient
|
||||
from storage.database import session_maker
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
from storage.slack_user import SlackUser
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.shared import config, sio
|
||||
@@ -79,6 +79,14 @@ async def install_callback(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not config.jwt_secret:
|
||||
logger.error('slack_install_callback_error JWT not configured.')
|
||||
return _html_response(
|
||||
title='Error',
|
||||
description=html.escape('JWT not configured'),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
try:
|
||||
client = AsyncWebClient() # no prepared token needed for this
|
||||
# Complete the installation by calling oauth.v2.access API method
|
||||
@@ -94,16 +102,17 @@ async def install_callback(
|
||||
|
||||
# Create a state variable for keycloak oauth
|
||||
payload = {}
|
||||
jwt_secret: SecretStr = config.jwt_secret # type: ignore[assignment]
|
||||
if state:
|
||||
payload = jwt.decode(
|
||||
state, jwt_secret.get_secret_value(), algorithms=['HS256']
|
||||
state, config.jwt_secret.get_secret_value(), algorithms=['HS256']
|
||||
)
|
||||
payload['slack_user_id'] = authed_user.get('id')
|
||||
payload['bot_access_token'] = bot_access_token
|
||||
payload['team_id'] = team_id
|
||||
|
||||
state = jwt.encode(payload, jwt_secret.get_secret_value(), algorithm='HS256')
|
||||
state = jwt.encode(
|
||||
payload, config.jwt_secret.get_secret_value(), algorithm='HS256'
|
||||
)
|
||||
|
||||
# Redirect into keycloak
|
||||
scope = quote('openid email profile offline_access')
|
||||
@@ -149,9 +158,16 @@ async def keycloak_callback(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
jwt_secret: SecretStr = config.jwt_secret # type: ignore[assignment]
|
||||
if not config.jwt_secret:
|
||||
logger.error('problem_retrieving_keycloak_tokens JWT not configured.')
|
||||
return _html_response(
|
||||
title='Error',
|
||||
description=html.escape('JWT not configured'),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
payload: dict[str, str] = jwt.decode(
|
||||
state, jwt_secret.get_secret_value(), algorithms=['HS256']
|
||||
state, config.jwt_secret.get_secret_value(), algorithms=['HS256']
|
||||
)
|
||||
slack_user_id = payload['slack_user_id']
|
||||
bot_access_token = payload['bot_access_token']
|
||||
@@ -180,6 +196,13 @@ async def keycloak_callback(
|
||||
|
||||
user_info = await token_manager.get_user_info(keycloak_access_token)
|
||||
keycloak_user_id = user_info['sub']
|
||||
user = await UserStore.get_user_by_id_async(keycloak_user_id)
|
||||
if not user:
|
||||
return _html_response(
|
||||
title='Failed to authenticate.',
|
||||
description=f'Please re-login into <a href="{HOST_URL}" style="color:#ecedee;text-decoration:underline;">OpenHands Cloud</a>. Then try <a href="https://docs.all-hands.dev/usage/cloud/slack-installation" style="color:#ecedee;text-decoration:underline;">installing the OpenHands Slack App</a> again',
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# These tokens are offline access tokens - store them!
|
||||
await token_manager.store_offline_token(keycloak_user_id, keycloak_refresh_token)
|
||||
@@ -211,6 +234,7 @@ async def keycloak_callback(
|
||||
slack_display_name = slack_user_info.data['user']['profile']['display_name']
|
||||
slack_user = SlackUser(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
org_id=user.current_org_id,
|
||||
slack_user_id=slack_user_id,
|
||||
slack_display_name=slack_display_name,
|
||||
)
|
||||
@@ -305,7 +329,7 @@ async def on_form_interaction(request: Request, background_tasks: BackgroundTask
|
||||
|
||||
body = await request.body()
|
||||
form = await request.form()
|
||||
payload = json.loads(form.get('payload')) # type: ignore[arg-type]
|
||||
payload = json.loads(form.get('payload'))
|
||||
|
||||
logger.info('slack_on_form_interaction', extra={'payload': payload})
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ DEVICE_CODE_EXPIRES_IN = 600 # 10 minutes
|
||||
DEVICE_TOKEN_POLL_INTERVAL = 5 # seconds
|
||||
|
||||
API_KEY_NAME = 'Device Link Access Key'
|
||||
KEY_EXPIRATION_TIME = timedelta(days=1) # Key expires in 24 hours
|
||||
KEY_EXPIRATION_TIME = timedelta(days=7) # Key expires in a week
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
@@ -272,7 +272,7 @@ async def device_verification_authenticated(
|
||||
try:
|
||||
# Create a unique API key for this device using user_code in the name
|
||||
device_key_name = f'{API_KEY_NAME} ({user_code})'
|
||||
api_key_store.create_api_key(
|
||||
await api_key_store.create_api_key(
|
||||
user_id,
|
||||
name=device_key_name,
|
||||
expires_at=datetime.now(UTC) + KEY_EXPIRATION_TIME,
|
||||
|
||||
175
enterprise/server/routes/org_models.py
Normal file
175
enterprise/server/routes/org_models.py
Normal file
@@ -0,0 +1,175 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, StringConstraints
|
||||
from storage.org import Org
|
||||
|
||||
|
||||
class OrgCreationError(Exception):
|
||||
"""Base exception for organization creation errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OrgNameExistsError(OrgCreationError):
|
||||
"""Raised when an organization name already exists."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
super().__init__(f'Organization with name "{name}" already exists')
|
||||
|
||||
|
||||
class LiteLLMIntegrationError(OrgCreationError):
|
||||
"""Raised when LiteLLM integration fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OrgDatabaseError(OrgCreationError):
|
||||
"""Raised when database operations fail."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OrgDeletionError(Exception):
|
||||
"""Base exception for organization deletion errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OrgAuthorizationError(OrgDeletionError):
|
||||
"""Raised when user is not authorized to delete organization."""
|
||||
|
||||
def __init__(self, message: str = 'Not authorized to delete organization'):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class OrgNotFoundError(Exception):
|
||||
"""Raised when organization is not found or user doesn't have access."""
|
||||
|
||||
def __init__(self, org_id: str):
|
||||
self.org_id = org_id
|
||||
super().__init__(f'Organization with id "{org_id}" not found')
|
||||
|
||||
|
||||
class OrgCreate(BaseModel):
|
||||
"""Request model for creating a new organization."""
|
||||
|
||||
# Required fields
|
||||
name: Annotated[
|
||||
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)
|
||||
]
|
||||
contact_name: str
|
||||
contact_email: EmailStr
|
||||
|
||||
|
||||
class OrgResponse(BaseModel):
|
||||
"""Response model for organization."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
contact_name: str
|
||||
contact_email: str
|
||||
conversation_expiration: int | None = None
|
||||
agent: str | None = None
|
||||
default_max_iterations: int | None = None
|
||||
security_analyzer: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
default_llm_model: str | None = None
|
||||
default_llm_api_key_for_byor: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
remote_runtime_resource_factor: int | None = None
|
||||
enable_default_condenser: bool = True
|
||||
billing_margin: float | None = None
|
||||
enable_proactive_conversation_starters: bool = True
|
||||
sandbox_base_container_image: str | None = None
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
org_version: int = 0
|
||||
mcp_config: dict | None = None
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org, credits: float | None = None) -> 'OrgResponse':
|
||||
"""Create an OrgResponse from an Org entity.
|
||||
|
||||
Args:
|
||||
org: The organization entity to convert
|
||||
credits: Optional credits value (defaults to None)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The response model instance
|
||||
"""
|
||||
return cls(
|
||||
id=str(org.id),
|
||||
name=org.name,
|
||||
contact_name=org.contact_name,
|
||||
contact_email=org.contact_email,
|
||||
conversation_expiration=org.conversation_expiration,
|
||||
agent=org.agent,
|
||||
default_max_iterations=org.default_max_iterations,
|
||||
security_analyzer=org.security_analyzer,
|
||||
confirmation_mode=org.confirmation_mode,
|
||||
default_llm_model=org.default_llm_model,
|
||||
default_llm_api_key_for_byor=None,
|
||||
default_llm_base_url=org.default_llm_base_url,
|
||||
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
|
||||
enable_default_condenser=org.enable_default_condenser
|
||||
if org.enable_default_condenser is not None
|
||||
else True,
|
||||
billing_margin=org.billing_margin,
|
||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
|
||||
if org.enable_proactive_conversation_starters is not None
|
||||
else True,
|
||||
sandbox_base_container_image=org.sandbox_base_container_image,
|
||||
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
|
||||
org_version=org.org_version if org.org_version is not None else 0,
|
||||
mcp_config=org.mcp_config,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class OrgPage(BaseModel):
|
||||
"""Paginated response model for organization list."""
|
||||
|
||||
items: list[OrgResponse]
|
||||
next_page_id: str | None = None
|
||||
|
||||
|
||||
class OrgUpdate(BaseModel):
|
||||
"""Request model for updating an organization."""
|
||||
|
||||
# Basic organization information (any authenticated user can update)
|
||||
contact_name: str | None = None
|
||||
contact_email: EmailStr | None = None
|
||||
conversation_expiration: int | None = None
|
||||
default_max_iterations: int | None = Field(default=None, gt=0)
|
||||
remote_runtime_resource_factor: int | None = Field(default=None, gt=0)
|
||||
billing_margin: float | None = Field(default=None, ge=0, le=1)
|
||||
enable_proactive_conversation_starters: bool | None = None
|
||||
sandbox_base_container_image: str | None = None
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
mcp_config: dict | 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
|
||||
|
||||
# LLM settings (require admin/owner role)
|
||||
default_llm_model: str | None = None
|
||||
default_llm_api_key_for_byor: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
search_api_key: str | None = None
|
||||
security_analyzer: str | None = None
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
enable_default_condenser: bool | None = None
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
402
enterprise/server/routes/orgs.py
Normal file
402
enterprise/server/routes/orgs.py
Normal file
@@ -0,0 +1,402 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.routes.org_models import (
|
||||
LiteLLMIntegrationError,
|
||||
OrgAuthorizationError,
|
||||
OrgCreate,
|
||||
OrgDatabaseError,
|
||||
OrgNameExistsError,
|
||||
OrgNotFoundError,
|
||||
OrgPage,
|
||||
OrgResponse,
|
||||
OrgUpdate,
|
||||
)
|
||||
from storage.org_service import OrgService
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# Initialize API router
|
||||
org_router = APIRouter(prefix='/api/organizations')
|
||||
|
||||
|
||||
@org_router.get('', response_model=OrgPage)
|
||||
async def list_user_orgs(
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(title='The max number of results in the page', gt=0, lte=100),
|
||||
] = 100,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> OrgPage:
|
||||
"""List organizations for the authenticated user.
|
||||
|
||||
This endpoint returns a paginated list of all organizations that the
|
||||
authenticated user is a member of.
|
||||
|
||||
Args:
|
||||
page_id: Optional page ID (offset) for pagination
|
||||
limit: Maximum number of organizations to return (1-100, default 100)
|
||||
user_id: Authenticated user ID (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgPage: Paginated list of organizations
|
||||
|
||||
Raises:
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
logger.info(
|
||||
'Listing organizations for user',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'page_id': page_id,
|
||||
'limit': limit,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch organizations from service layer
|
||||
orgs, next_page_id = OrgService.get_user_orgs_paginated(
|
||||
user_id=user_id,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Convert Org entities to OrgResponse objects
|
||||
org_responses = [OrgResponse.from_org(org, credits=None) for org in orgs]
|
||||
|
||||
logger.info(
|
||||
'Successfully retrieved organizations',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_count': len(org_responses),
|
||||
'has_more': next_page_id is not None,
|
||||
},
|
||||
)
|
||||
|
||||
return OrgPage(items=org_responses, next_page_id=next_page_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error listing organizations',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve organizations',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post('', response_model=OrgResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_org(
|
||||
org_data: OrgCreate,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
) -> OrgResponse:
|
||||
"""Create a new organization.
|
||||
|
||||
This endpoint allows authenticated users with @openhands.dev email to create
|
||||
a new organization. The user who creates the organization automatically becomes
|
||||
its owner.
|
||||
|
||||
Args:
|
||||
org_data: Organization creation data
|
||||
user_id: Authenticated user ID (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The created organization details
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user email domain is not @openhands.dev
|
||||
HTTPException: 409 if organization name already exists
|
||||
HTTPException: 500 if creation fails
|
||||
"""
|
||||
logger.info(
|
||||
'Creating new organization',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_name': org_data.name,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Use service layer to create organization
|
||||
org = await OrgService.create_org_with_owner(
|
||||
name=org_data.name,
|
||||
contact_name=org_data.contact_name,
|
||||
contact_email=org_data.contact_email,
|
||||
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)
|
||||
except OrgNameExistsError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
)
|
||||
except LiteLLMIntegrationError as e:
|
||||
logger.error(
|
||||
'LiteLLM integration failed',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create LiteLLM integration',
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database operation failed',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create organization',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error creating organization',
|
||||
extra={'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_org(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> OrgResponse:
|
||||
"""Get organization details by ID.
|
||||
|
||||
This endpoint allows authenticated users who are members of an organization
|
||||
to retrieve its details. Only members of the organization can access this endpoint.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID (UUID)
|
||||
user_id: Authenticated user ID (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The organization details
|
||||
|
||||
Raises:
|
||||
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
|
||||
HTTPException: 404 if organization not found or user is not a member
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
logger.info(
|
||||
'Retrieving organization details',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
|
||||
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)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error retrieving organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.delete('/{org_id}', status_code=status.HTTP_200_OK)
|
||||
async def delete_org(
|
||||
org_id: UUID,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
) -> dict:
|
||||
"""Delete an organization.
|
||||
|
||||
This endpoint allows authenticated organization owners to delete their organization.
|
||||
All associated data including organization members, conversations, billing data,
|
||||
and external LiteLLM team resources will be permanently removed.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID to delete
|
||||
user_id: Authenticated user ID (injected by dependency)
|
||||
|
||||
Returns:
|
||||
dict: Confirmation message with deleted organization details
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user is not the organization owner
|
||||
HTTPException: 404 if organization not found
|
||||
HTTPException: 500 if deletion fails
|
||||
"""
|
||||
logger.info(
|
||||
'Organization deletion requested',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Use service layer to delete organization with cleanup
|
||||
deleted_org = await OrgService.delete_org_with_cleanup(
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Organization deletion completed successfully',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'org_name': deleted_org.name,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
'message': 'Organization deleted successfully',
|
||||
'organization': {
|
||||
'id': str(deleted_org.id),
|
||||
'name': deleted_org.name,
|
||||
'contact_name': deleted_org.contact_name,
|
||||
'contact_email': deleted_org.contact_email,
|
||||
},
|
||||
}
|
||||
|
||||
except OrgNotFoundError as e:
|
||||
logger.warning(
|
||||
'Organization not found for deletion',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgAuthorizationError as e:
|
||||
logger.warning(
|
||||
'User not authorized to delete organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database error during organization deletion',
|
||||
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 delete organization',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error during organization deletion',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.patch('/{org_id}', response_model=OrgResponse)
|
||||
async def update_org(
|
||||
org_id: UUID,
|
||||
update_data: OrgUpdate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> OrgResponse:
|
||||
"""Update an existing organization.
|
||||
|
||||
This endpoint allows authenticated users to update organization settings.
|
||||
LLM-related settings require admin or owner role in the organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID to update (UUID validated by FastAPI)
|
||||
update_data: Organization update data
|
||||
user_id: Authenticated user ID (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The updated organization details
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if org_id is invalid UUID format (handled by FastAPI)
|
||||
HTTPException: 403 if user lacks permission for LLM settings
|
||||
HTTPException: 404 if organization not found
|
||||
HTTPException: 422 if validation errors occur (handled by FastAPI)
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
logger.info(
|
||||
'Updating organization',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Use service layer to update organization with permission checks
|
||||
updated_org = await OrgService.update_org_with_permissions(
|
||||
org_id=org_id,
|
||||
update_data=update_data,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Retrieve credits from LiteLLM (following same pattern as create endpoint)
|
||||
credits = await OrgService.get_org_credits(user_id, updated_org.id)
|
||||
|
||||
return OrgResponse.from_org(updated_org, credits=credits)
|
||||
|
||||
except ValueError as e:
|
||||
# Organization not found
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except PermissionError as e:
|
||||
# User lacks permission for LLM settings
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database operation failed',
|
||||
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 organization',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error updating organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
@@ -121,6 +122,8 @@ async def saas_get_user(
|
||||
login=(user_info.get('preferred_username') if user_info else '') or '',
|
||||
avatar_url='',
|
||||
email=user_info.get('email') if user_info else None,
|
||||
name=resolve_display_name(user_info) if user_info else None,
|
||||
company=user_info.get('company') if user_info else None,
|
||||
),
|
||||
user_info=user_info,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ from sqlalchemy import orm
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import LLMConfig, OpenHandsConfig
|
||||
@@ -515,11 +516,13 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
)
|
||||
raise
|
||||
|
||||
def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
|
||||
async def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
mcp_api_key = api_key_store.retrieve_mcp_api_key(user_id)
|
||||
mcp_api_key = await api_key_store.retrieve_mcp_api_key(user_id)
|
||||
if not mcp_api_key:
|
||||
mcp_api_key = api_key_store.create_api_key(user_id, 'MCP_API_KEY', None)
|
||||
mcp_api_key = await api_key_store.create_api_key(
|
||||
user_id, 'MCP_API_KEY', None
|
||||
)
|
||||
if not mcp_api_key:
|
||||
return None
|
||||
web_host = os.environ.get('WEB_HOST', 'app.all-hands.dev')
|
||||
@@ -546,7 +549,7 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
'conversation_id': sid,
|
||||
}
|
||||
|
||||
mcp_config = self._get_mcp_config(user_id)
|
||||
mcp_config = await self._get_mcp_config(user_id)
|
||||
if mcp_config:
|
||||
# Merge with any MCP config from settings
|
||||
if settings.mcp_config:
|
||||
@@ -646,16 +649,18 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
"""
|
||||
|
||||
with session_maker() as session:
|
||||
conversation_metadata = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not conversation_metadata:
|
||||
if not conversation_metadata_saas:
|
||||
raise ValueError(f'No conversation found {conversation_id}')
|
||||
|
||||
return conversation_metadata.user_id
|
||||
return str(conversation_metadata_saas.user_id)
|
||||
|
||||
async def _get_runtime_status_from_nested_runtime(
|
||||
self, session_api_key: Any | None, nested_url: str, conversation_id: str
|
||||
@@ -994,9 +999,17 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
with session_maker() as session:
|
||||
# Only include conversations updated in the past week
|
||||
one_week_ago = datetime.now(UTC) - timedelta(days=7)
|
||||
query = session.query(StoredConversationMetadata.conversation_id).filter(
|
||||
StoredConversationMetadata.user_id == user_id,
|
||||
StoredConversationMetadata.last_updated_at >= one_week_ago,
|
||||
query = (
|
||||
session.query(StoredConversationMetadata.conversation_id)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.user_id == user_id,
|
||||
StoredConversationMetadata.last_updated_at >= one_week_ago,
|
||||
)
|
||||
)
|
||||
user_conversation_ids = set(query)
|
||||
return user_conversation_ids
|
||||
@@ -1070,11 +1083,16 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
if conversation_metadata is None:
|
||||
conversation_metadata_saas = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(StoredConversationMetadataSaas.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
if conversation_metadata is None or conversation_metadata_saas is None:
|
||||
# Conversation is running in different server
|
||||
return
|
||||
|
||||
user_id = conversation_metadata.user_id
|
||||
user_id = conversation_metadata_saas.user_id
|
||||
|
||||
# Get the id of the next event which is not present
|
||||
events_dir = get_conversation_events_dir(
|
||||
|
||||
@@ -26,6 +26,7 @@ from server.sharing.shared_conversation_models import (
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
StoredConversationMetadata,
|
||||
@@ -57,7 +58,7 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
include_sub_conversations: bool = False,
|
||||
) -> SharedConversationPage:
|
||||
"""Search for shared conversations."""
|
||||
query = self._public_select()
|
||||
query = self._public_select_with_saas_metadata()
|
||||
|
||||
# Conditionally exclude sub-conversations based on the parameter
|
||||
if not include_sub_conversations:
|
||||
@@ -104,14 +105,17 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
query = query.limit(limit + 1)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
rows = result.scalars().all()
|
||||
rows = result.all()
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
items = [self._to_shared_conversation(row) for row in rows]
|
||||
items = [
|
||||
self._to_shared_conversation(stored, saas_metadata=saas_metadata)
|
||||
for stored, saas_metadata in rows
|
||||
]
|
||||
|
||||
# Calculate next page ID
|
||||
next_page_id = None
|
||||
@@ -152,17 +156,18 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
self, conversation_id: UUID
|
||||
) -> SharedConversation | None:
|
||||
"""Get a single public conversation info, returning None if missing or not shared."""
|
||||
query = self._public_select().where(
|
||||
query = self._public_select_with_saas_metadata().where(
|
||||
StoredConversationMetadata.conversation_id == str(conversation_id)
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
stored = result.scalar_one_or_none()
|
||||
row = result.first()
|
||||
|
||||
if stored is None:
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
return self._to_shared_conversation(stored)
|
||||
stored, saas_metadata = row
|
||||
return self._to_shared_conversation(stored, saas_metadata=saas_metadata)
|
||||
|
||||
def _public_select(self):
|
||||
"""Create a select query that only returns public conversations."""
|
||||
@@ -173,6 +178,25 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
return query
|
||||
|
||||
def _public_select_with_saas_metadata(self):
|
||||
"""Create a select query that returns public conversations with SAAS metadata.
|
||||
|
||||
This joins with conversation_metadata_saas to retrieve the user_id needed
|
||||
for constructing the correct event storage path. Uses LEFT OUTER JOIN to
|
||||
support conversations that may not have SAAS metadata (e.g., in tests).
|
||||
"""
|
||||
query = (
|
||||
select(StoredConversationMetadata, StoredConversationMetadataSaas)
|
||||
.outerjoin(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.where(StoredConversationMetadata.conversation_version == 'V1')
|
||||
.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
)
|
||||
return query
|
||||
|
||||
def _apply_filters(
|
||||
self,
|
||||
query,
|
||||
@@ -211,9 +235,16 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
def _to_shared_conversation(
|
||||
self,
|
||||
stored: StoredConversationMetadata,
|
||||
saas_metadata: StoredConversationMetadataSaas | None = None,
|
||||
sub_conversation_ids: list[UUID] | None = None,
|
||||
) -> SharedConversation:
|
||||
"""Convert StoredConversationMetadata to SharedConversation."""
|
||||
"""Convert StoredConversationMetadata to SharedConversation.
|
||||
|
||||
Args:
|
||||
stored: The base conversation metadata from conversation_metadata table.
|
||||
saas_metadata: Optional SAAS metadata containing user_id and org_id.
|
||||
sub_conversation_ids: Optional list of sub-conversation IDs.
|
||||
"""
|
||||
# V1 conversations should always have a sandbox_id
|
||||
sandbox_id = stored.sandbox_id
|
||||
assert sandbox_id is not None
|
||||
@@ -239,9 +270,16 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
created_at = self._fix_timezone(stored.created_at)
|
||||
updated_at = self._fix_timezone(stored.last_updated_at)
|
||||
|
||||
# Get user_id from SAAS metadata if available
|
||||
created_by_user_id = (
|
||||
str(saas_metadata.user_id)
|
||||
if saas_metadata and saas_metadata.user_id
|
||||
else None
|
||||
)
|
||||
|
||||
return SharedConversation(
|
||||
id=UUID(stored.conversation_id),
|
||||
created_by_user_id=stored.user_id if stored.user_id else None,
|
||||
created_by_user_id=created_by_user_id,
|
||||
sandbox_id=stored.sandbox_id,
|
||||
selected_repository=stored.selected_repository,
|
||||
selected_branch=stored.selected_branch,
|
||||
|
||||
350
enterprise/server/utils/saas_app_conversation_info_injector.py
Normal file
350
enterprise/server/utils/saas_app_conversation_info_injector.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Enterprise injector for SQLAppConversationInfoService with SAAS filtering."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import func, select
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_info_service import (
|
||||
AppConversationInfoService,
|
||||
AppConversationInfoServiceInjector,
|
||||
)
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationInfo,
|
||||
AppConversationInfoPage,
|
||||
AppConversationSortOrder,
|
||||
)
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
SQLAppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
|
||||
|
||||
class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
"""Extended SQLAppConversationInfoService with user-based filtering and SAAS metadata handling."""
|
||||
|
||||
async def _secure_select(self):
|
||||
query = (
|
||||
select(StoredConversationMetadata)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.where(StoredConversationMetadata.conversation_version == 'V1')
|
||||
)
|
||||
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if user_id_str:
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
|
||||
|
||||
return query
|
||||
|
||||
async def _secure_select_with_saas_metadata(self):
|
||||
"""Select query that includes SAAS metadata for retrieving user_id."""
|
||||
query = (
|
||||
select(StoredConversationMetadata, StoredConversationMetadataSaas)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
.where(StoredConversationMetadata.conversation_version == 'V1')
|
||||
)
|
||||
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if user_id_str:
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
|
||||
|
||||
return query
|
||||
|
||||
async def search_app_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sort_order: AppConversationSortOrder = AppConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
include_sub_conversations: bool = False,
|
||||
) -> AppConversationInfoPage:
|
||||
"""Search for conversations with user_id from SAAS metadata."""
|
||||
query = await self._secure_select_with_saas_metadata()
|
||||
|
||||
# Conditionally exclude sub-conversations based on the parameter
|
||||
if not include_sub_conversations:
|
||||
# Exclude sub-conversations (only include top-level conversations)
|
||||
query = query.where(
|
||||
StoredConversationMetadata.parent_conversation_id.is_(None)
|
||||
)
|
||||
|
||||
query = self._apply_filters_with_saas_metadata(
|
||||
query=query,
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
|
||||
# Add sort order
|
||||
if sort_order == AppConversationSortOrder.CREATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.created_at)
|
||||
elif sort_order == AppConversationSortOrder.CREATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.created_at.desc())
|
||||
elif sort_order == AppConversationSortOrder.UPDATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at)
|
||||
elif sort_order == AppConversationSortOrder.UPDATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at.desc())
|
||||
elif sort_order == AppConversationSortOrder.TITLE:
|
||||
query = query.order_by(StoredConversationMetadata.title)
|
||||
elif sort_order == AppConversationSortOrder.TITLE_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.title.desc())
|
||||
|
||||
# Apply pagination
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
query = query.offset(offset)
|
||||
except ValueError:
|
||||
# If page_id is not a valid integer, start from beginning
|
||||
offset = 0
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
# Apply limit and get one extra to check if there are more results
|
||||
query = query.limit(limit + 1)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
items = [
|
||||
self._to_info_with_user_id(stored_metadata, saas_metadata)
|
||||
for stored_metadata, saas_metadata in rows
|
||||
]
|
||||
|
||||
# Calculate next page ID
|
||||
next_page_id = None
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
|
||||
return AppConversationInfoPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
async def count_app_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count conversations matching the given filters with SAAS metadata."""
|
||||
query = (
|
||||
select(func.count(StoredConversationMetadata.conversation_id))
|
||||
.select_from(
|
||||
StoredConversationMetadata.join(
|
||||
StoredConversationMetadataSaas,
|
||||
StoredConversationMetadata.conversation_id
|
||||
== StoredConversationMetadataSaas.conversation_id,
|
||||
)
|
||||
)
|
||||
.where(StoredConversationMetadata.conversation_version == 'V1')
|
||||
)
|
||||
|
||||
# Apply user filtering
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if user_id_str:
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
|
||||
|
||||
query = self._apply_filters_with_saas_metadata(
|
||||
query=query,
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
count = result.scalar()
|
||||
return count or 0
|
||||
|
||||
def _apply_filters_with_saas_metadata(
|
||||
self,
|
||||
query,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
):
|
||||
"""Apply filters to query that includes SAAS metadata."""
|
||||
# Apply the same filters as the base class
|
||||
conditions = []
|
||||
if title__contains is not None:
|
||||
conditions.append(
|
||||
StoredConversationMetadata.title.like(f'%{title__contains}%')
|
||||
)
|
||||
|
||||
if created_at__gte is not None:
|
||||
conditions.append(StoredConversationMetadata.created_at >= created_at__gte)
|
||||
|
||||
if created_at__lt is not None:
|
||||
conditions.append(StoredConversationMetadata.created_at < created_at__lt)
|
||||
|
||||
if updated_at__gte is not None:
|
||||
conditions.append(
|
||||
StoredConversationMetadata.last_updated_at >= updated_at__gte
|
||||
)
|
||||
|
||||
if updated_at__lt is not None:
|
||||
conditions.append(
|
||||
StoredConversationMetadata.last_updated_at < updated_at__lt
|
||||
)
|
||||
|
||||
if conditions:
|
||||
query = query.where(*conditions)
|
||||
return query
|
||||
|
||||
async def get_app_conversation_info(
|
||||
self, conversation_id: UUID
|
||||
) -> AppConversationInfo | None:
|
||||
"""Get conversation info with user_id from SAAS metadata."""
|
||||
query = await self._secure_select_with_saas_metadata()
|
||||
query = query.where(
|
||||
StoredConversationMetadata.conversation_id == str(conversation_id)
|
||||
)
|
||||
result_set = await self.db_session.execute(query)
|
||||
result = result_set.first()
|
||||
if result:
|
||||
stored_metadata, saas_metadata = result
|
||||
return self._to_info_with_user_id(stored_metadata, saas_metadata)
|
||||
return None
|
||||
|
||||
async def batch_get_app_conversation_info(
|
||||
self, conversation_ids: list[UUID]
|
||||
) -> list[AppConversationInfo | None]:
|
||||
"""Batch get conversation info with user_id from SAAS metadata."""
|
||||
conversation_id_strs = [
|
||||
str(conversation_id) for conversation_id in conversation_ids
|
||||
]
|
||||
query = await self._secure_select_with_saas_metadata()
|
||||
query = query.where(
|
||||
StoredConversationMetadata.conversation_id.in_(conversation_id_strs)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Create a mapping of conversation_id to (metadata, saas_metadata)
|
||||
info_by_id = {}
|
||||
for stored_metadata, saas_metadata in rows:
|
||||
info_by_id[stored_metadata.conversation_id] = (
|
||||
stored_metadata,
|
||||
saas_metadata,
|
||||
)
|
||||
|
||||
results: list[AppConversationInfo | None] = []
|
||||
for conversation_id in conversation_id_strs:
|
||||
if conversation_id in info_by_id:
|
||||
stored_metadata, saas_metadata = info_by_id[conversation_id]
|
||||
results.append(
|
||||
self._to_info_with_user_id(stored_metadata, saas_metadata)
|
||||
)
|
||||
else:
|
||||
results.append(None)
|
||||
|
||||
return results
|
||||
|
||||
async def save_app_conversation_info(
|
||||
self, info: AppConversationInfo
|
||||
) -> AppConversationInfo:
|
||||
"""Save conversation info and create/update SAAS metadata with user_id and org_id."""
|
||||
# Save the base conversation metadata
|
||||
await super().save_app_conversation_info(info)
|
||||
|
||||
# Get current user_id for SAAS metadata
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if user_id_str:
|
||||
# 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()
|
||||
assert user
|
||||
|
||||
# Check if SAAS metadata already exists
|
||||
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()
|
||||
assert existing_saas_metadata is None or (
|
||||
existing_saas_metadata.user_id == user_id_uuid
|
||||
and existing_saas_metadata.org_id == user.current_org_id
|
||||
)
|
||||
|
||||
if not existing_saas_metadata:
|
||||
# Create new SAAS metadata
|
||||
# Set org_id to user_id as specified in requirements
|
||||
saas_metadata = StoredConversationMetadataSaas(
|
||||
conversation_id=str(info.id),
|
||||
user_id=user_id_uuid,
|
||||
org_id=user.current_org_id,
|
||||
)
|
||||
self.db_session.add(saas_metadata)
|
||||
|
||||
await self.db_session.commit()
|
||||
|
||||
return info
|
||||
|
||||
def _to_info_with_user_id(
|
||||
self,
|
||||
stored: StoredConversationMetadata,
|
||||
saas_metadata: StoredConversationMetadataSaas,
|
||||
) -> AppConversationInfo:
|
||||
"""Convert stored metadata to AppConversationInfo with user_id from SAAS metadata."""
|
||||
# Use the base _to_info method to get the basic info
|
||||
info = self._to_info(stored)
|
||||
|
||||
# Override the created_by_user_id with the user_id from SAAS metadata
|
||||
info.created_by_user_id = (
|
||||
str(saas_metadata.user_id) if saas_metadata.user_id else None
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
class SaasAppConversationInfoServiceInjector(AppConversationInfoServiceInjector):
|
||||
"""Enterprise injector for SQLAppConversationInfoService with SAAS filtering."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[AppConversationInfoService, None]:
|
||||
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,
|
||||
):
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=db_session, user_context=user_context
|
||||
)
|
||||
yield service
|
||||
@@ -1,270 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# OpenHands Enterprise Local Development Setup Script
|
||||
# This script helps set up the local development environment
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENTERPRISE_DIR="$SCRIPT_DIR"
|
||||
OPENHANDS_DIR="$(dirname "$ENTERPRISE_DIR")"
|
||||
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║ OpenHands Enterprise Local Development Setup ║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
# Function to check if a command exists
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Function to check if a Docker container is running
|
||||
container_running() {
|
||||
docker ps --format '{{.Names}}' | grep -q "^$1$"
|
||||
}
|
||||
|
||||
# Function to wait for a service
|
||||
wait_for_service() {
|
||||
local host=$1
|
||||
local port=$2
|
||||
local name=$3
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
echo -ne " Waiting for $name to be ready..."
|
||||
while ! nc -z "$host" "$port" 2>/dev/null; do
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo -e " ${RED}FAILED${NC}"
|
||||
echo -e " ${RED}$name did not become ready in time${NC}"
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
echo -ne "."
|
||||
done
|
||||
echo -e " ${GREEN}READY${NC}"
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
echo -e "${YELLOW}Checking prerequisites...${NC}"
|
||||
|
||||
if ! command_exists docker; then
|
||||
echo -e " ${RED}✗ Docker is not installed${NC}"
|
||||
echo " Please install Docker: https://docs.docker.com/get-docker/"
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓ Docker${NC}"
|
||||
|
||||
if ! command_exists docker-compose && ! docker compose version >/dev/null 2>&1; then
|
||||
echo -e " ${RED}✗ Docker Compose is not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓ Docker Compose${NC}"
|
||||
|
||||
if ! command_exists poetry; then
|
||||
echo -e " ${RED}✗ Poetry is not installed${NC}"
|
||||
echo " Install with: curl -sSL https://install.python-poetry.org | python3 -"
|
||||
exit 1
|
||||
fi
|
||||
echo -e " ${GREEN}✓ Poetry${NC}"
|
||||
|
||||
echo ""
|
||||
|
||||
# Parse arguments
|
||||
MINIMAL=false
|
||||
FULL=false
|
||||
START_ONLY=false
|
||||
STOP=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--minimal)
|
||||
MINIMAL=true
|
||||
shift
|
||||
;;
|
||||
--full)
|
||||
FULL=true
|
||||
shift
|
||||
;;
|
||||
--start)
|
||||
START_ONLY=true
|
||||
shift
|
||||
;;
|
||||
--stop)
|
||||
STOP=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --minimal Start only PostgreSQL and Redis (no Keycloak/LiteLLM)"
|
||||
echo " --full Start all services including Keycloak and LiteLLM"
|
||||
echo " --start Only start services, skip installation"
|
||||
echo " --stop Stop all services"
|
||||
echo " --help Show this help message"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Stop services
|
||||
if [ "$STOP" = true ]; then
|
||||
echo -e "${YELLOW}Stopping services...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
if [ -f docker-compose.local.yml ]; then
|
||||
docker compose -f docker-compose.local.yml down 2>/dev/null || true
|
||||
fi
|
||||
docker rm -f openhands-postgres openhands-redis openhands-keycloak openhands-litellm 2>/dev/null || true
|
||||
echo -e "${GREEN}Services stopped${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Determine mode
|
||||
if [ "$MINIMAL" = false ] && [ "$FULL" = false ]; then
|
||||
echo -e "${YELLOW}Select setup mode:${NC}"
|
||||
echo " 1) Minimal (PostgreSQL + Redis only)"
|
||||
echo " 2) Full (PostgreSQL + Redis + Keycloak + LiteLLM)"
|
||||
read -p "Enter choice [1]: " choice
|
||||
choice=${choice:-1}
|
||||
|
||||
if [ "$choice" = "1" ]; then
|
||||
MINIMAL=true
|
||||
else
|
||||
FULL=true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Start services
|
||||
if [ "$MINIMAL" = true ]; then
|
||||
echo -e "${YELLOW}Starting minimal services (PostgreSQL + Redis)...${NC}"
|
||||
|
||||
# PostgreSQL
|
||||
if ! container_running openhands-postgres; then
|
||||
echo -e " Starting PostgreSQL..."
|
||||
docker run -d \
|
||||
--name openhands-postgres \
|
||||
-p 5432:5432 \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=openhands \
|
||||
postgres:15 >/dev/null
|
||||
else
|
||||
echo -e " ${GREEN}PostgreSQL already running${NC}"
|
||||
fi
|
||||
|
||||
# Redis
|
||||
if ! container_running openhands-redis; then
|
||||
echo -e " Starting Redis..."
|
||||
docker run -d \
|
||||
--name openhands-redis \
|
||||
-p 6379:6379 \
|
||||
redis:7-alpine >/dev/null
|
||||
else
|
||||
echo -e " ${GREEN}Redis already running${NC}"
|
||||
fi
|
||||
|
||||
wait_for_service localhost 5432 "PostgreSQL"
|
||||
wait_for_service localhost 6379 "Redis"
|
||||
|
||||
else
|
||||
echo -e "${YELLOW}Starting full services (PostgreSQL + Redis + Keycloak + LiteLLM)...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
|
||||
# Use docker compose
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
else
|
||||
docker-compose -f docker-compose.local.yml up -d
|
||||
fi
|
||||
|
||||
wait_for_service localhost 5432 "PostgreSQL"
|
||||
wait_for_service localhost 6379 "Redis"
|
||||
wait_for_service localhost 8080 "Keycloak"
|
||||
wait_for_service localhost 4000 "LiteLLM"
|
||||
fi
|
||||
|
||||
if [ "$START_ONLY" = true ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}Services started!${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
echo -e "${YELLOW}Configuring environment...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
echo -e " ${GREEN}Created .env file from template${NC}"
|
||||
echo -e " ${YELLOW}Please edit .env to add your LLM API keys!${NC}"
|
||||
else
|
||||
echo -e " ${GREEN}.env file already exists${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Install Python dependencies
|
||||
echo -e "${YELLOW}Installing Python dependencies...${NC}"
|
||||
cd "$ENTERPRISE_DIR"
|
||||
poetry install
|
||||
|
||||
echo ""
|
||||
|
||||
# Run database migrations
|
||||
echo -e "${YELLOW}Running database migrations...${NC}"
|
||||
poetry run alembic upgrade head
|
||||
|
||||
echo ""
|
||||
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Setup Complete! ║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
echo -e "Services running:"
|
||||
echo -e " • PostgreSQL: ${BLUE}localhost:5432${NC}"
|
||||
echo -e " • Redis: ${BLUE}localhost:6379${NC}"
|
||||
if [ "$FULL" = true ]; then
|
||||
echo -e " • Keycloak: ${BLUE}http://localhost:8080${NC} (admin/admin)"
|
||||
echo -e " • LiteLLM: ${BLUE}http://localhost:4000${NC}"
|
||||
fi
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo ""
|
||||
echo "1. Edit .env and add your LLM API keys (OPENAI_API_KEY or ANTHROPIC_API_KEY)"
|
||||
echo ""
|
||||
if [ "$FULL" = true ]; then
|
||||
echo "2. Configure LiteLLM with your API keys:"
|
||||
echo " docker exec -it openhands-litellm /bin/sh"
|
||||
echo " # Then set environment variables"
|
||||
echo ""
|
||||
echo "3. (Optional) Configure Keycloak realm for OAuth"
|
||||
echo ""
|
||||
fi
|
||||
echo "3. Build the frontend (from repo root):"
|
||||
echo " cd .. && make build-frontend"
|
||||
echo ""
|
||||
echo "4. Start the backend server:"
|
||||
echo " cd enterprise"
|
||||
echo " OPENHANDS_PATH=../ make start-backend"
|
||||
echo ""
|
||||
echo "5. Access the application at: ${BLUE}http://localhost:3000${NC}"
|
||||
echo ""
|
||||
echo "To stop all services: $0 --stop"
|
||||
@@ -0,0 +1,85 @@
|
||||
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.experiment_assignment import ExperimentAssignment
|
||||
from storage.feedback import ConversationFeedback, Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.jira_conversation import JiraConversation
|
||||
from storage.jira_dc_conversation import JiraDcConversation
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
from storage.linear_conversation import LinearConversation
|
||||
from storage.linear_user import LinearUser
|
||||
from storage.linear_workspace import LinearWorkspace
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
from storage.openhands_pr import OpenhandsPR
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.proactive_convos import ProactiveConversation
|
||||
from storage.role import Role
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_team import SlackTeam
|
||||
from storage.slack_user import SlackUser
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.stored_custom_secrets import StoredCustomSecrets
|
||||
from storage.stored_offline_token import StoredOfflineToken
|
||||
from storage.stored_repository import StoredRepository
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.subscription_access_status import SubscriptionAccessStatus
|
||||
from storage.user import User
|
||||
from storage.user_repo_map import UserRepositoryMap
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
__all__ = [
|
||||
'ApiKey',
|
||||
'AuthTokens',
|
||||
'BillingSession',
|
||||
'BillingSessionType',
|
||||
'CallbackStatus',
|
||||
'ConversationCallback',
|
||||
'ConversationFeedback',
|
||||
'StoredConversationMetadataSaas',
|
||||
'ConversationWork',
|
||||
'ExperimentAssignment',
|
||||
'Feedback',
|
||||
'GithubAppInstallation',
|
||||
'GitlabWebhook',
|
||||
'JiraConversation',
|
||||
'JiraDcConversation',
|
||||
'JiraDcUser',
|
||||
'JiraDcWorkspace',
|
||||
'JiraUser',
|
||||
'JiraWorkspace',
|
||||
'LinearConversation',
|
||||
'LinearUser',
|
||||
'LinearWorkspace',
|
||||
'MaintenanceTask',
|
||||
'MaintenanceTaskStatus',
|
||||
'OpenhandsPR',
|
||||
'Org',
|
||||
'OrgMember',
|
||||
'ProactiveConversation',
|
||||
'Role',
|
||||
'SlackConversation',
|
||||
'SlackTeam',
|
||||
'SlackUser',
|
||||
'StoredConversationMetadata',
|
||||
'StoredOfflineToken',
|
||||
'StoredRepository',
|
||||
'StoredCustomSecrets',
|
||||
'StripeCustomer',
|
||||
'SubscriptionAccess',
|
||||
'SubscriptionAccessStatus',
|
||||
'User',
|
||||
'UserRepositoryMap',
|
||||
'UserSettings',
|
||||
'WebhookStatus',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Column, DateTime, Integer, String, text
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -11,9 +13,13 @@ class ApiKey(Base):
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(255), nullable=False, unique=True, index=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
|
||||
name = Column(String(255), nullable=True)
|
||||
created_at = Column(
|
||||
DateTime, server_default=text('CURRENT_TIMESTAMP'), nullable=False
|
||||
)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='api_keys')
|
||||
|
||||
@@ -9,8 +9,10 @@ from sqlalchemy import update
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.api_key import ApiKey
|
||||
from storage.database import session_maker
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,7 +27,7 @@ class ApiKeyStore:
|
||||
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
|
||||
return f'{self.API_KEY_PREFIX}{random_part}'
|
||||
|
||||
def create_api_key(
|
||||
async def create_api_key(
|
||||
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
|
||||
) -> str:
|
||||
"""Create a new API key for a user.
|
||||
@@ -39,16 +41,34 @@ class ApiKeyStore:
|
||||
The generated API key
|
||||
"""
|
||||
api_key = self.generate_api_key()
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
org_id = user.current_org_id
|
||||
await call_sync_from_async(
|
||||
self._store_api_key, user_id, org_id, api_key, name, expires_at
|
||||
)
|
||||
|
||||
return api_key
|
||||
|
||||
def _store_api_key(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: str,
|
||||
api_key: str,
|
||||
name: str | None,
|
||||
expires_at: datetime | None = None,
|
||||
) -> None:
|
||||
"""Store an existing API key in the database."""
|
||||
with self.session_maker() as session:
|
||||
key_record = ApiKey(
|
||||
key=api_key, user_id=user_id, name=name, expires_at=expires_at
|
||||
key=api_key,
|
||||
user_id=user_id,
|
||||
org_id=org_id,
|
||||
name=name,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(key_record)
|
||||
session.commit()
|
||||
|
||||
return api_key
|
||||
|
||||
def validate_api_key(self, api_key: str) -> str | None:
|
||||
"""Validate an API key and return the associated user_id if valid."""
|
||||
now = datetime.now(UTC)
|
||||
@@ -106,10 +126,20 @@ class ApiKeyStore:
|
||||
|
||||
return True
|
||||
|
||||
def list_api_keys(self, user_id: str) -> list[dict]:
|
||||
async def list_api_keys(self, user_id: str) -> list[dict]:
|
||||
"""List all API keys for a user."""
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
org_id = user.current_org_id
|
||||
return await call_sync_from_async(self._list_api_keys_from_db, user_id, org_id)
|
||||
|
||||
def _list_api_keys_from_db(self, user_id: str, org_id: str) -> list[ApiKey]:
|
||||
with self.session_maker() as session:
|
||||
keys = session.query(ApiKey).filter(ApiKey.user_id == user_id).all()
|
||||
keys = (
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id)
|
||||
.filter(ApiKey.org_id == org_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -123,10 +153,20 @@ class ApiKeyStore:
|
||||
if 'MCP_API_KEY' != key.name
|
||||
]
|
||||
|
||||
def retrieve_mcp_api_key(self, user_id: str) -> str | None:
|
||||
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
org_id = user.current_org_id
|
||||
return await call_sync_from_async(
|
||||
self._retrieve_mcp_api_key_from_db, user_id, org_id
|
||||
)
|
||||
|
||||
def _retrieve_mcp_api_key_from_db(self, user_id: str, org_id: str) -> str | None:
|
||||
with self.session_maker() as session:
|
||||
keys: list[ApiKey] = (
|
||||
session.query(ApiKey).filter(ApiKey.user_id == user_id).all()
|
||||
session.query(ApiKey)
|
||||
.filter(ApiKey.user_id == user_id)
|
||||
.filter(ApiKey.org_id == org_id)
|
||||
.all()
|
||||
)
|
||||
for key in keys:
|
||||
if key.name == 'MCP_API_KEY':
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DECIMAL, Column, DateTime, Enum, String
|
||||
from sqlalchemy import DECIMAL, Column, DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -11,9 +13,9 @@ class BillingSession(Base): # type: ignore
|
||||
"""
|
||||
|
||||
__tablename__ = 'billing_sessions'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
user_id = Column(String, nullable=False)
|
||||
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
|
||||
status = Column(
|
||||
Enum(
|
||||
'in_progress',
|
||||
@@ -24,15 +26,6 @@ class BillingSession(Base): # type: ignore
|
||||
),
|
||||
default='in_progress',
|
||||
)
|
||||
billing_session_type = Column(
|
||||
Enum(
|
||||
'DIRECT_PAYMENT',
|
||||
'MONTHLY_SUBSCRIPTION',
|
||||
name='billing_session_type_enum',
|
||||
),
|
||||
nullable=False,
|
||||
default='DIRECT_PAYMENT',
|
||||
)
|
||||
price = Column(DECIMAL(19, 4), nullable=False)
|
||||
price_code = Column(String, nullable=False)
|
||||
created_at = Column(
|
||||
@@ -43,3 +36,6 @@ class BillingSession(Base): # type: ignore
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='billing_sessions')
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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
|
||||
@@ -10,7 +15,6 @@ from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy import Enum as SQLEnum
|
||||
from storage.base import Base
|
||||
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
@@ -33,7 +37,7 @@ class ConversationCallbackProcessor(BaseModel, ABC):
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
observation: 'AgentStateChangedObservation',
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event.
|
||||
|
||||
@@ -1,122 +1,38 @@
|
||||
import asyncio
|
||||
import os
|
||||
"""
|
||||
Database connection module for enterprise storage.
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy.util import await_only
|
||||
This is for backwards compatibility with V0.
|
||||
|
||||
DB_HOST = os.environ.get('DB_HOST', 'localhost') # for non-GCP environments
|
||||
DB_PORT = os.environ.get('DB_PORT', '5432') # for non-GCP environments
|
||||
DB_USER = os.environ.get('DB_USER', 'postgres')
|
||||
DB_PASS = os.environ.get('DB_PASS', 'postgres').strip()
|
||||
DB_NAME = os.environ.get('DB_NAME', 'openhands')
|
||||
This module provides database engines and session makers by delegating to the
|
||||
centralized DbSessionInjector from app_server/config.py. This ensures a single
|
||||
source of truth for database connection configuration.
|
||||
"""
|
||||
|
||||
GCP_DB_INSTANCE = os.environ.get('GCP_DB_INSTANCE') # for GCP environments
|
||||
GCP_PROJECT = os.environ.get('GCP_PROJECT')
|
||||
GCP_REGION = os.environ.get('GCP_REGION')
|
||||
|
||||
POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', '25'))
|
||||
MAX_OVERFLOW = int(os.environ.get('DB_MAX_OVERFLOW', '10'))
|
||||
POOL_RECYCLE = int(os.environ.get('DB_POOL_RECYCLE', '1800'))
|
||||
|
||||
# Initialize Cloud SQL Connector once at module level for GCP environments.
|
||||
_connector = None
|
||||
import contextlib
|
||||
|
||||
|
||||
def _get_db_engine():
|
||||
if GCP_DB_INSTANCE: # GCP environments
|
||||
def _get_db_session_injector():
|
||||
from openhands.app_server.config import get_global_config
|
||||
|
||||
def get_db_connection():
|
||||
global _connector
|
||||
from google.cloud.sql.connector import Connector
|
||||
|
||||
if not _connector:
|
||||
_connector = Connector()
|
||||
instance_string = f'{GCP_PROJECT}:{GCP_REGION}:{GCP_DB_INSTANCE}'
|
||||
return _connector.connect(
|
||||
instance_string, 'pg8000', user=DB_USER, password=DB_PASS, db=DB_NAME
|
||||
)
|
||||
|
||||
return create_engine(
|
||||
'postgresql+pg8000://',
|
||||
creator=get_db_connection,
|
||||
pool_size=POOL_SIZE,
|
||||
max_overflow=MAX_OVERFLOW,
|
||||
pool_recycle=POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
else:
|
||||
host_string = (
|
||||
f'postgresql+pg8000://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
|
||||
)
|
||||
return create_engine(
|
||||
host_string,
|
||||
pool_size=POOL_SIZE,
|
||||
max_overflow=MAX_OVERFLOW,
|
||||
pool_recycle=POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
_config = get_global_config()
|
||||
return _config.db_session
|
||||
|
||||
|
||||
async def async_creator():
|
||||
from google.cloud.sql.connector import Connector
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
async with Connector(loop=loop) as connector:
|
||||
conn = await connector.connect_async(
|
||||
f'{GCP_PROJECT}:{GCP_REGION}:{GCP_DB_INSTANCE}', # Cloud SQL instance connection name"
|
||||
'asyncpg',
|
||||
user=DB_USER,
|
||||
password=DB_PASS,
|
||||
db=DB_NAME,
|
||||
)
|
||||
return conn
|
||||
def session_maker():
|
||||
db_session_injector = _get_db_session_injector()
|
||||
session_maker = db_session_injector.get_session_maker()
|
||||
return session_maker()
|
||||
|
||||
|
||||
def _get_async_db_engine():
|
||||
if GCP_DB_INSTANCE: # GCP environments
|
||||
|
||||
def adapted_creator():
|
||||
dbapi = engine.dialect.dbapi
|
||||
from sqlalchemy.dialects.postgresql.asyncpg import (
|
||||
AsyncAdapt_asyncpg_connection,
|
||||
)
|
||||
|
||||
return AsyncAdapt_asyncpg_connection(
|
||||
dbapi,
|
||||
await_only(async_creator()),
|
||||
prepared_statement_cache_size=100,
|
||||
)
|
||||
|
||||
# create async connection pool with wrapped creator
|
||||
return create_async_engine(
|
||||
'postgresql+asyncpg://',
|
||||
creator=adapted_creator,
|
||||
# Use NullPool to disable connection pooling and avoid event loop issues
|
||||
poolclass=NullPool,
|
||||
)
|
||||
else:
|
||||
host_string = (
|
||||
f'postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
|
||||
)
|
||||
return create_async_engine(
|
||||
host_string,
|
||||
# Use NullPool to disable connection pooling and avoid event loop issues
|
||||
poolclass=NullPool,
|
||||
)
|
||||
@contextlib.asynccontextmanager
|
||||
async def a_session_maker():
|
||||
db_session_injector = _get_db_session_injector()
|
||||
a_session_maker = await db_session_injector.get_async_session_maker()
|
||||
async with a_session_maker() as session:
|
||||
yield session
|
||||
|
||||
|
||||
engine = _get_db_engine()
|
||||
session_maker = sessionmaker(bind=engine)
|
||||
|
||||
a_engine = _get_async_db_engine()
|
||||
a_session_maker = sessionmaker(
|
||||
bind=a_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
# Configure the session to use the same connection for all operations in a transaction
|
||||
# This helps prevent the "Task got Future attached to a different loop" error
|
||||
future=True,
|
||||
)
|
||||
def get_engine():
|
||||
db_session_injector = _get_db_session_injector()
|
||||
engine = db_session_injector.get_db_engine()
|
||||
return engine
|
||||
|
||||
137
enterprise/storage/encrypt_utils.py
Normal file
137
enterprise/storage/encrypt_utils.py
Normal file
@@ -0,0 +1,137 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
from base64 import b64decode, b64encode
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pydantic import SecretStr
|
||||
from server.config import get_config
|
||||
|
||||
_jwt_service = None
|
||||
_fernet = None
|
||||
|
||||
|
||||
def encrypt_model(encrypt_keys: list, model_instance) -> dict:
|
||||
return encrypt_kwargs(encrypt_keys, model_to_kwargs(model_instance))
|
||||
|
||||
|
||||
def decrypt_model(decrypt_keys: list, model_instance) -> dict:
|
||||
return decrypt_kwargs(decrypt_keys, model_to_kwargs(model_instance))
|
||||
|
||||
|
||||
def encrypt_kwargs(encrypt_keys: list, kwargs: dict) -> dict:
|
||||
for key, value in kwargs.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
if isinstance(value, dict):
|
||||
encrypt_kwargs(encrypt_keys, value)
|
||||
continue
|
||||
|
||||
if key in encrypt_keys:
|
||||
value = encrypt_value(value)
|
||||
kwargs[key] = value
|
||||
return kwargs
|
||||
|
||||
|
||||
def decrypt_kwargs(encrypt_keys: list, kwargs: dict) -> dict:
|
||||
for key, value in kwargs.items():
|
||||
try:
|
||||
if value is None:
|
||||
continue
|
||||
if key in encrypt_keys:
|
||||
value = decrypt_value(value)
|
||||
kwargs[key] = value
|
||||
except binascii.Error:
|
||||
pass # Key is in legacy format...
|
||||
return kwargs
|
||||
|
||||
|
||||
def encrypt_value(value: str | SecretStr) -> str:
|
||||
return get_jwt_service().create_jwe_token(
|
||||
{'v': value.get_secret_value() if isinstance(value, SecretStr) else value}
|
||||
)
|
||||
|
||||
|
||||
def decrypt_value(value: str | SecretStr) -> str:
|
||||
token = get_jwt_service().decrypt_jwe_token(
|
||||
value.get_secret_value() if isinstance(value, SecretStr) else value
|
||||
)
|
||||
return token['v']
|
||||
|
||||
|
||||
def get_jwt_service():
|
||||
from openhands.app_server.config import get_global_config
|
||||
|
||||
global _jwt_service
|
||||
if _jwt_service is None:
|
||||
jwt_service_injector = get_global_config().jwt
|
||||
assert jwt_service_injector is not None
|
||||
_jwt_service = jwt_service_injector.get_jwt_service()
|
||||
return _jwt_service
|
||||
|
||||
|
||||
def decrypt_legacy_model(decrypt_keys: list, model_instance) -> dict:
|
||||
return decrypt_legacy_kwargs(decrypt_keys, model_to_kwargs(model_instance))
|
||||
|
||||
|
||||
def decrypt_legacy_kwargs(encrypt_keys: list, kwargs: dict) -> dict:
|
||||
for key, value in kwargs.items():
|
||||
try:
|
||||
if value is None:
|
||||
continue
|
||||
if key in encrypt_keys:
|
||||
value = decrypt_legacy_value(value)
|
||||
kwargs[key] = value
|
||||
except binascii.Error:
|
||||
pass # Key is in legacy format...
|
||||
except InvalidToken:
|
||||
pass # Key not encrypted...
|
||||
return kwargs
|
||||
|
||||
|
||||
def decrypt_legacy_value(value: str | SecretStr) -> str:
|
||||
if isinstance(value, SecretStr):
|
||||
return (
|
||||
get_fernet().decrypt(b64decode(value.get_secret_value().encode())).decode()
|
||||
)
|
||||
else:
|
||||
return get_fernet().decrypt(b64decode(value.encode())).decode()
|
||||
|
||||
|
||||
def encrypt_legacy_model(encrypt_keys: list, model_instance) -> dict:
|
||||
return encrypt_legacy_kwargs(encrypt_keys, model_to_kwargs(model_instance))
|
||||
|
||||
|
||||
def encrypt_legacy_kwargs(encrypt_keys: list, kwargs: dict) -> dict:
|
||||
for key, value in kwargs.items():
|
||||
if value is None:
|
||||
continue
|
||||
if key in encrypt_keys:
|
||||
value = encrypt_legacy_value(value)
|
||||
kwargs[key] = value
|
||||
return kwargs
|
||||
|
||||
|
||||
def encrypt_legacy_value(value: str | SecretStr) -> str:
|
||||
if isinstance(value, SecretStr):
|
||||
return b64encode(
|
||||
get_fernet().encrypt(value.get_secret_value().encode())
|
||||
).decode()
|
||||
else:
|
||||
return b64encode(get_fernet().encrypt(value.encode())).decode()
|
||||
|
||||
|
||||
def get_fernet():
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
jwt_secret = get_config().jwt_secret.get_secret_value()
|
||||
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
|
||||
_fernet = Fernet(fernet_key)
|
||||
return _fernet
|
||||
|
||||
|
||||
def model_to_kwargs(model_instance):
|
||||
return {
|
||||
column.name: getattr(model_instance, column.name)
|
||||
for column in model_instance.__table__.columns
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import sys
|
||||
from enum import IntEnum
|
||||
|
||||
from sqlalchemy import ARRAY, Boolean, Column, DateTime, Integer, String, Text, text
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
text,
|
||||
)
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
|
||||
@@ -118,9 +118,7 @@ class JiraIntegrationStore:
|
||||
.first()
|
||||
)
|
||||
|
||||
async def get_workspace_by_name(
|
||||
self, workspace_name: str
|
||||
) -> Optional[JiraWorkspace]:
|
||||
async def get_workspace_by_name(self, workspace_name: str) -> JiraWorkspace | None:
|
||||
"""Retrieve workspace by name."""
|
||||
with session_maker() as session:
|
||||
return (
|
||||
|
||||
1334
enterprise/storage/lite_llm_manager.py
Normal file
1334
enterprise/storage/lite_llm_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
100
enterprise/storage/org.py
Normal file
100
enterprise/storage/org.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
SQLAlchemy model for Organization.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import SecretStr
|
||||
from server.constants import DEFAULT_BILLING_MARGIN
|
||||
from sqlalchemy import JSON, UUID, Boolean, Column, Float, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
from storage.encrypt_utils import decrypt_value, encrypt_value
|
||||
|
||||
|
||||
class Org(Base): # type: ignore
|
||||
"""Organization model."""
|
||||
|
||||
__tablename__ = 'org'
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
contact_name = Column(String, nullable=True)
|
||||
contact_email = Column(String, nullable=True)
|
||||
agent = Column(String, nullable=True)
|
||||
default_max_iterations = Column(Integer, nullable=True)
|
||||
security_analyzer = Column(String, nullable=True)
|
||||
confirmation_mode = Column(Boolean, nullable=True, default=False)
|
||||
default_llm_model = Column(String, nullable=True)
|
||||
default_llm_base_url = Column(String, nullable=True)
|
||||
remote_runtime_resource_factor = Column(Integer, nullable=True)
|
||||
enable_default_condenser = Column(Boolean, nullable=False, default=True)
|
||||
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
|
||||
enable_proactive_conversation_starters = Column(
|
||||
Boolean, nullable=False, default=True
|
||||
)
|
||||
sandbox_base_container_image = Column(String, nullable=True)
|
||||
sandbox_runtime_container_image = Column(String, nullable=True)
|
||||
org_version = Column(Integer, nullable=False, default=0)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
# encrypted column, don't set directly, set without the underscore
|
||||
_search_api_key = Column(String, nullable=True)
|
||||
# encrypted column, don't set directly, set without the underscore
|
||||
_sandbox_api_key = Column(String, nullable=True)
|
||||
max_budget_per_task = Column(Float, nullable=True)
|
||||
enable_solvability_analysis = Column(Boolean, nullable=True, default=False)
|
||||
v1_enabled = Column(Boolean, nullable=True)
|
||||
conversation_expiration = Column(Integer, nullable=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
current_users = relationship('User', back_populates='current_org')
|
||||
billing_sessions = relationship('BillingSession', back_populates='org')
|
||||
stored_conversation_metadata_saas = relationship(
|
||||
'StoredConversationMetadataSaas', back_populates='org'
|
||||
)
|
||||
user_secrets = relationship('StoredCustomSecrets', back_populates='org')
|
||||
api_keys = relationship('ApiKey', back_populates='org')
|
||||
slack_conversations = relationship('SlackConversation', back_populates='org')
|
||||
slack_users = relationship('SlackUser', back_populates='org')
|
||||
stripe_customers = relationship('StripeCustomer', back_populates='org')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Handle known SQLAlchemy columns directly
|
||||
for key in list(kwargs):
|
||||
if hasattr(self.__class__, key):
|
||||
setattr(self, key, kwargs.pop(key))
|
||||
|
||||
# Handle custom property-style fields
|
||||
if 'search_api_key' in kwargs:
|
||||
self.search_api_key = kwargs.pop('search_api_key')
|
||||
if 'sandbox_api_key' in kwargs:
|
||||
self.sandbox_api_key = kwargs.pop('sandbox_api_key')
|
||||
|
||||
if kwargs:
|
||||
raise TypeError(f'Unexpected keyword arguments: {list(kwargs.keys())}')
|
||||
|
||||
@property
|
||||
def search_api_key(self) -> SecretStr | None:
|
||||
if self._search_api_key:
|
||||
decrypted = decrypt_value(self._search_api_key)
|
||||
return SecretStr(decrypted)
|
||||
return None
|
||||
|
||||
@search_api_key.setter
|
||||
def search_api_key(self, value: str | SecretStr | None):
|
||||
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
|
||||
self._search_api_key = encrypt_value(raw) if raw else None
|
||||
|
||||
@property
|
||||
def sandbox_api_key(self) -> SecretStr | None:
|
||||
if self._sandbox_api_key:
|
||||
decrypted = decrypt_value(self._sandbox_api_key)
|
||||
return SecretStr(decrypted)
|
||||
return None
|
||||
|
||||
@sandbox_api_key.setter
|
||||
def sandbox_api_key(self, value: str | SecretStr | None):
|
||||
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
|
||||
self._sandbox_api_key = encrypt_value(raw) if raw else None
|
||||
67
enterprise/storage/org_member.py
Normal file
67
enterprise/storage/org_member.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
SQLAlchemy model for Organization-Member relationship.
|
||||
"""
|
||||
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy import UUID, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
from storage.encrypt_utils import decrypt_value, encrypt_value
|
||||
|
||||
|
||||
class OrgMember(Base): # type: ignore
|
||||
"""Junction table for organization-member relationships with roles."""
|
||||
|
||||
__tablename__ = 'org_member'
|
||||
|
||||
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), primary_key=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey('user.id'), primary_key=True)
|
||||
role_id = Column(Integer, ForeignKey('role.id'), nullable=False)
|
||||
_llm_api_key = Column(String, nullable=False)
|
||||
max_iterations = Column(Integer, nullable=True)
|
||||
llm_model = Column(String, nullable=True)
|
||||
_llm_api_key_for_byor = Column(String, nullable=True)
|
||||
llm_base_url = Column(String, nullable=True)
|
||||
status = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='org_members')
|
||||
user = relationship('User', back_populates='org_members')
|
||||
role = relationship('Role', back_populates='org_members')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Handle known SQLAlchemy columns directly
|
||||
for key in list(kwargs):
|
||||
if hasattr(self.__class__, key):
|
||||
setattr(self, key, kwargs.pop(key))
|
||||
|
||||
# Handle custom property-style fields
|
||||
if 'llm_api_key' in kwargs:
|
||||
self.llm_api_key = kwargs.pop('llm_api_key')
|
||||
if 'llm_api_key_for_byor' in kwargs:
|
||||
self.llm_api_key_for_byor = kwargs.pop('llm_api_key_for_byor')
|
||||
|
||||
if kwargs:
|
||||
raise TypeError(f'Unexpected keyword arguments: {list(kwargs.keys())}')
|
||||
|
||||
@property
|
||||
def llm_api_key(self) -> SecretStr:
|
||||
decrypted = decrypt_value(self._llm_api_key)
|
||||
return SecretStr(decrypted)
|
||||
|
||||
@llm_api_key.setter
|
||||
def llm_api_key(self, value: str | SecretStr):
|
||||
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
|
||||
self._llm_api_key = encrypt_value(raw)
|
||||
|
||||
@property
|
||||
def llm_api_key_for_byor(self) -> SecretStr | None:
|
||||
if self._llm_api_key_for_byor:
|
||||
decrypted = decrypt_value(self._llm_api_key_for_byor)
|
||||
return SecretStr(decrypted)
|
||||
return None
|
||||
|
||||
@llm_api_key_for_byor.setter
|
||||
def llm_api_key_for_byor(self, value: str | SecretStr | None):
|
||||
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
|
||||
self._llm_api_key_for_byor = encrypt_value(raw) if raw else None
|
||||
137
enterprise/storage/org_member_store.py
Normal file
137
enterprise/storage/org_member_store.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Store class for managing organization-member relationships.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
class OrgMemberStore:
|
||||
"""Store for managing organization-member relationships."""
|
||||
|
||||
@staticmethod
|
||||
def add_user_to_org(
|
||||
org_id: UUID,
|
||||
user_id: UUID,
|
||||
role_id: int,
|
||||
llm_api_key: str,
|
||||
status: Optional[str] = None,
|
||||
) -> OrgMember:
|
||||
"""Add a user to an organization with a specific role."""
|
||||
with session_maker() as session:
|
||||
org_member = OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
role_id=role_id,
|
||||
llm_api_key=llm_api_key,
|
||||
status=status,
|
||||
)
|
||||
session.add(org_member)
|
||||
session.commit()
|
||||
session.refresh(org_member)
|
||||
return org_member
|
||||
|
||||
@staticmethod
|
||||
def get_org_member(org_id: UUID, user_id: UUID) -> Optional[OrgMember]:
|
||||
"""Get organization-user relationship."""
|
||||
with session_maker() as session:
|
||||
return (
|
||||
session.query(OrgMember)
|
||||
.filter(OrgMember.org_id == org_id, OrgMember.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_org_member_async(org_id: UUID, user_id: UUID) -> Optional[OrgMember]:
|
||||
"""Get organization-user relationship."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(
|
||||
OrgMember.org_id == org_id, OrgMember.user_id == user_id
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_orgs(user_id: UUID) -> list[OrgMember]:
|
||||
"""Get all organizations for a user."""
|
||||
with session_maker() as session:
|
||||
return session.query(OrgMember).filter(OrgMember.user_id == user_id).all()
|
||||
|
||||
@staticmethod
|
||||
def get_org_members(org_id: UUID) -> list[OrgMember]:
|
||||
"""Get all users in an organization."""
|
||||
with session_maker() as session:
|
||||
return session.query(OrgMember).filter(OrgMember.org_id == org_id).all()
|
||||
|
||||
@staticmethod
|
||||
def update_org_member(org_member: OrgMember) -> None:
|
||||
"""Update an organization-member relationship."""
|
||||
with session_maker() as session:
|
||||
session.merge(org_member)
|
||||
session.commit()
|
||||
|
||||
@staticmethod
|
||||
def update_user_role_in_org(
|
||||
org_id: UUID, user_id: UUID, role_id: int, status: Optional[str] = None
|
||||
) -> Optional[OrgMember]:
|
||||
"""Update user's role in an organization."""
|
||||
with session_maker() as session:
|
||||
org_member = (
|
||||
session.query(OrgMember)
|
||||
.filter(OrgMember.org_id == org_id, OrgMember.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not org_member:
|
||||
return None
|
||||
|
||||
org_member.role_id = role_id
|
||||
if status is not None:
|
||||
org_member.status = status
|
||||
|
||||
session.commit()
|
||||
session.refresh(org_member)
|
||||
return org_member
|
||||
|
||||
@staticmethod
|
||||
def remove_user_from_org(org_id: UUID, user_id: UUID) -> bool:
|
||||
"""Remove a user from an organization."""
|
||||
with session_maker() as session:
|
||||
org_member = (
|
||||
session.query(OrgMember)
|
||||
.filter(OrgMember.org_id == org_id, OrgMember.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not org_member:
|
||||
return False
|
||||
|
||||
session.delete(org_member)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_kwargs_from_settings(settings: Settings):
|
||||
kwargs = {
|
||||
normalized: getattr(settings, normalized)
|
||||
for c in OrgMember.__table__.columns
|
||||
if (normalized := c.name.lstrip('_')) and hasattr(settings, normalized)
|
||||
}
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def get_kwargs_from_user_settings(user_settings: UserSettings):
|
||||
kwargs = {
|
||||
normalized: getattr(user_settings, normalized)
|
||||
for c in OrgMember.__table__.columns
|
||||
if (normalized := c.name.lstrip('_')) and hasattr(user_settings, normalized)
|
||||
}
|
||||
return kwargs
|
||||
844
enterprise/storage/org_service.py
Normal file
844
enterprise/storage/org_service.py
Normal file
@@ -0,0 +1,844 @@
|
||||
"""
|
||||
Service class for managing organization operations.
|
||||
Separates business logic from route handlers.
|
||||
"""
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import UUID as parse_uuid
|
||||
|
||||
from server.constants import ORG_SETTINGS_VERSION, get_default_litellm_model
|
||||
from server.routes.org_models import (
|
||||
LiteLLMIntegrationError,
|
||||
OrgAuthorizationError,
|
||||
OrgDatabaseError,
|
||||
OrgNameExistsError,
|
||||
OrgNotFoundError,
|
||||
OrgUpdate,
|
||||
)
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class OrgService:
|
||||
"""Service for handling organization-related operations."""
|
||||
|
||||
@staticmethod
|
||||
def validate_name_uniqueness(name: str) -> None:
|
||||
"""
|
||||
Validate that organization name is unique.
|
||||
|
||||
Args:
|
||||
name: Organization name to validate
|
||||
|
||||
Raises:
|
||||
OrgNameExistsError: If organization name already exists
|
||||
"""
|
||||
existing_org = OrgStore.get_org_by_name(name)
|
||||
if existing_org is not None:
|
||||
raise OrgNameExistsError(name)
|
||||
|
||||
@staticmethod
|
||||
async def create_litellm_integration(org_id: UUID, user_id: str) -> dict:
|
||||
"""
|
||||
Create LiteLLM team integration for the organization.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
user_id: User ID who will own the organization
|
||||
|
||||
Returns:
|
||||
dict: LiteLLM settings object
|
||||
|
||||
Raises:
|
||||
LiteLLMIntegrationError: If LiteLLM integration fails
|
||||
"""
|
||||
try:
|
||||
settings = await UserStore.create_default_settings(
|
||||
org_id=str(org_id), user_id=user_id, create_user=False
|
||||
)
|
||||
|
||||
if not settings:
|
||||
logger.error(
|
||||
'Failed to create LiteLLM settings',
|
||||
extra={'org_id': str(org_id), 'user_id': user_id},
|
||||
)
|
||||
raise LiteLLMIntegrationError('Failed to create LiteLLM settings')
|
||||
|
||||
logger.debug(
|
||||
'LiteLLM integration created',
|
||||
extra={'org_id': str(org_id), 'user_id': user_id},
|
||||
)
|
||||
return settings
|
||||
|
||||
except LiteLLMIntegrationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error creating LiteLLM integration',
|
||||
extra={'org_id': str(org_id), 'user_id': user_id, 'error': str(e)},
|
||||
)
|
||||
raise LiteLLMIntegrationError(f'LiteLLM integration failed: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
def create_org_entity(
|
||||
org_id: UUID,
|
||||
name: str,
|
||||
contact_name: str,
|
||||
contact_email: str,
|
||||
) -> Org:
|
||||
"""
|
||||
Create an organization entity with basic information.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
name: Organization name
|
||||
contact_name: Contact person name
|
||||
contact_email: Contact email address
|
||||
|
||||
Returns:
|
||||
Org: New organization entity (not yet persisted)
|
||||
"""
|
||||
return Org(
|
||||
id=org_id,
|
||||
name=name,
|
||||
contact_name=contact_name,
|
||||
contact_email=contact_email,
|
||||
org_version=ORG_SETTINGS_VERSION,
|
||||
default_llm_model=get_default_litellm_model(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def apply_litellm_settings_to_org(org: Org, settings: dict) -> None:
|
||||
"""
|
||||
Apply LiteLLM settings to organization entity.
|
||||
|
||||
Args:
|
||||
org: Organization entity to update
|
||||
settings: LiteLLM settings object
|
||||
"""
|
||||
org_kwargs = OrgStore.get_kwargs_from_settings(settings)
|
||||
for key, value in org_kwargs.items():
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
@staticmethod
|
||||
def get_owner_role():
|
||||
"""
|
||||
Get the owner role from the database.
|
||||
|
||||
Returns:
|
||||
Role: The owner role object
|
||||
|
||||
Raises:
|
||||
Exception: If owner role not found
|
||||
"""
|
||||
owner_role = RoleStore.get_role_by_name('owner')
|
||||
if not owner_role:
|
||||
raise Exception('Owner role not found in database')
|
||||
return owner_role
|
||||
|
||||
@staticmethod
|
||||
def create_org_member_entity(
|
||||
org_id: UUID,
|
||||
user_id: str,
|
||||
role_id: int,
|
||||
settings: dict,
|
||||
) -> OrgMember:
|
||||
"""
|
||||
Create an organization member entity.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID
|
||||
user_id: User ID (string that will be converted to UUID)
|
||||
role_id: Role ID
|
||||
settings: LiteLLM settings object
|
||||
|
||||
Returns:
|
||||
OrgMember: New organization member entity (not yet persisted)
|
||||
"""
|
||||
org_member_kwargs = OrgMemberStore.get_kwargs_from_settings(settings)
|
||||
return OrgMember(
|
||||
org_id=org_id,
|
||||
user_id=parse_uuid(user_id),
|
||||
role_id=role_id,
|
||||
status='active',
|
||||
**org_member_kwargs,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def create_org_with_owner(
|
||||
name: str,
|
||||
contact_name: str,
|
||||
contact_email: str,
|
||||
user_id: str,
|
||||
) -> Org:
|
||||
"""
|
||||
Create a new organization with the specified user as owner.
|
||||
|
||||
This method orchestrates the complete organization creation workflow:
|
||||
1. Validates that the organization name doesn't already exist
|
||||
2. Generates a unique organization ID
|
||||
3. Creates LiteLLM team integration
|
||||
4. Creates the organization entity
|
||||
5. Applies LiteLLM settings
|
||||
6. Creates owner membership
|
||||
7. Persists everything in a transaction
|
||||
|
||||
If database persistence fails, LiteLLM resources are cleaned up (compensation).
|
||||
|
||||
Args:
|
||||
name: Organization name (must be unique)
|
||||
contact_name: Contact person name
|
||||
contact_email: Contact email address
|
||||
user_id: ID of the user who will be the owner
|
||||
|
||||
Returns:
|
||||
Org: The created organization object
|
||||
|
||||
Raises:
|
||||
OrgNameExistsError: If organization name already exists
|
||||
LiteLLMIntegrationError: If LiteLLM integration fails
|
||||
OrgDatabaseError: If database operations fail
|
||||
"""
|
||||
logger.info(
|
||||
'Starting organization creation',
|
||||
extra={'user_id': user_id, 'org_name': name},
|
||||
)
|
||||
|
||||
# Step 1: Validate name uniqueness (fails early, no cleanup needed)
|
||||
OrgService.validate_name_uniqueness(name)
|
||||
|
||||
# Step 2: Generate organization ID
|
||||
org_id = uuid4()
|
||||
|
||||
# Step 3: Create LiteLLM integration (external state created)
|
||||
settings = await OrgService.create_litellm_integration(org_id, user_id)
|
||||
|
||||
# Steps 4-7: Create entities and persist with compensation
|
||||
# If any of these fail, we need to clean up LiteLLM resources
|
||||
try:
|
||||
# Step 4: Create organization entity
|
||||
org = OrgService.create_org_entity(
|
||||
org_id=org_id,
|
||||
name=name,
|
||||
contact_name=contact_name,
|
||||
contact_email=contact_email,
|
||||
)
|
||||
|
||||
# Step 5: Apply LiteLLM settings
|
||||
OrgService.apply_litellm_settings_to_org(org, settings)
|
||||
|
||||
# Step 6: Get owner role and create member entity
|
||||
owner_role = OrgService.get_owner_role()
|
||||
org_member = OrgService.create_org_member_entity(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
role_id=owner_role.id,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
# Step 7: Persist in transaction (critical section)
|
||||
persisted_org = await OrgService._persist_with_compensation(
|
||||
org, org_member, org_id, user_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Successfully created organization',
|
||||
extra={
|
||||
'org_id': str(persisted_org.id),
|
||||
'org_name': persisted_org.name,
|
||||
'user_id': user_id,
|
||||
'role': 'owner',
|
||||
},
|
||||
)
|
||||
|
||||
return persisted_org
|
||||
|
||||
except OrgDatabaseError:
|
||||
# Already handled by _persist_with_compensation, just re-raise
|
||||
raise
|
||||
except Exception as e:
|
||||
# Unexpected error in steps 4-6, need to clean up LiteLLM
|
||||
logger.error(
|
||||
'Unexpected error during organization creation, initiating cleanup',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
await OrgService._handle_failure_with_cleanup(
|
||||
org_id, user_id, e, 'Failed to create organization'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _persist_with_compensation(
|
||||
org: Org,
|
||||
org_member: OrgMember,
|
||||
org_id: UUID,
|
||||
user_id: str,
|
||||
) -> Org:
|
||||
"""
|
||||
Persist organization with compensation on failure.
|
||||
|
||||
If database persistence fails, cleans up LiteLLM resources.
|
||||
|
||||
Args:
|
||||
org: Organization entity to persist
|
||||
org_member: Organization member entity to persist
|
||||
org_id: Organization ID (for cleanup)
|
||||
user_id: User ID (for cleanup)
|
||||
|
||||
Returns:
|
||||
Org: The persisted organization object
|
||||
|
||||
Raises:
|
||||
OrgDatabaseError: If database operations fail
|
||||
"""
|
||||
try:
|
||||
persisted_org = OrgStore.persist_org_with_owner(org, org_member)
|
||||
return persisted_org
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Database persistence failed, initiating LiteLLM cleanup',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
await OrgService._handle_failure_with_cleanup(
|
||||
org_id, user_id, e, 'Failed to create organization'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _handle_failure_with_cleanup(
|
||||
org_id: UUID,
|
||||
user_id: str,
|
||||
original_error: Exception,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
"""
|
||||
Handle failure by cleaning up LiteLLM resources and raising appropriate error.
|
||||
|
||||
This method performs compensating transaction and raises OrgDatabaseError.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
user_id: User ID
|
||||
original_error: The original exception that caused the failure
|
||||
error_message: Base error message for the exception
|
||||
|
||||
Raises:
|
||||
OrgDatabaseError: Always raises with details about the failure
|
||||
"""
|
||||
cleanup_error = await OrgService._cleanup_litellm_resources(org_id, user_id)
|
||||
|
||||
if cleanup_error:
|
||||
logger.error(
|
||||
'Both operation and cleanup failed',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'original_error': str(original_error),
|
||||
'cleanup_error': str(cleanup_error),
|
||||
},
|
||||
)
|
||||
raise OrgDatabaseError(
|
||||
f'{error_message}: {str(original_error)}. '
|
||||
f'Cleanup also failed: {str(cleanup_error)}'
|
||||
)
|
||||
|
||||
raise OrgDatabaseError(f'{error_message}: {str(original_error)}')
|
||||
|
||||
@staticmethod
|
||||
async def _cleanup_litellm_resources(
|
||||
org_id: UUID, user_id: str
|
||||
) -> Exception | None:
|
||||
"""
|
||||
Compensating transaction: Clean up LiteLLM resources.
|
||||
|
||||
Deletes the team which should cascade to remove keys and memberships.
|
||||
This is a best-effort operation - errors are logged but not raised.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Exception | None: Exception if cleanup failed, None if successful
|
||||
"""
|
||||
try:
|
||||
await LiteLlmManager.delete_team(str(org_id))
|
||||
|
||||
logger.info(
|
||||
'Successfully cleaned up LiteLLM team',
|
||||
extra={'org_id': str(org_id), 'user_id': user_id},
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Failed to cleanup LiteLLM team (resources may be orphaned)',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return e
|
||||
|
||||
@staticmethod
|
||||
def has_admin_or_owner_role(user_id: str, org_id: UUID) -> bool:
|
||||
"""
|
||||
Check if user has admin or owner role in the specified organization.
|
||||
|
||||
Args:
|
||||
user_id: User ID to check
|
||||
org_id: Organization ID to check membership in
|
||||
|
||||
Returns:
|
||||
bool: True if user has admin or owner role, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Parse user_id as UUID for database query
|
||||
user_uuid = parse_uuid(user_id)
|
||||
|
||||
# Get the user's membership in this organization
|
||||
# Note: The type annotation says int but the actual column is UUID
|
||||
org_member = OrgMemberStore.get_org_member(org_id, user_uuid)
|
||||
if not org_member:
|
||||
return False
|
||||
|
||||
# Get the role details
|
||||
role = RoleStore.get_role_by_id(org_member.role_id)
|
||||
if not role:
|
||||
return False
|
||||
|
||||
# Admin and owner roles have elevated permissions
|
||||
# Based on test files, both admin and owner have rank 1
|
||||
return role.name in ['admin', 'owner']
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Error checking user role in organization',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def is_org_member(user_id: str, org_id: UUID) -> bool:
|
||||
"""
|
||||
Check if user is a member of the specified organization.
|
||||
|
||||
Args:
|
||||
user_id: User ID to check
|
||||
org_id: Organization ID to check membership in
|
||||
|
||||
Returns:
|
||||
bool: True if user is a member, False otherwise
|
||||
"""
|
||||
try:
|
||||
user_uuid = parse_uuid(user_id)
|
||||
org_member = OrgMemberStore.get_org_member(org_id, user_uuid)
|
||||
return org_member is not None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Error checking user membership in organization',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_llm_settings_fields() -> set[str]:
|
||||
"""
|
||||
Get the set of organization fields that are considered LLM settings
|
||||
and require admin/owner role to update.
|
||||
|
||||
Returns:
|
||||
set[str]: Set of field names that require elevated permissions
|
||||
"""
|
||||
return {
|
||||
'default_llm_model',
|
||||
'default_llm_api_key_for_byor',
|
||||
'default_llm_base_url',
|
||||
'search_api_key',
|
||||
'security_analyzer',
|
||||
'agent',
|
||||
'confirmation_mode',
|
||||
'enable_default_condenser',
|
||||
'condenser_max_size',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _has_llm_settings_updates(update_data: OrgUpdate) -> set[str]:
|
||||
"""
|
||||
Check if the update contains any LLM settings fields.
|
||||
|
||||
Args:
|
||||
update_data: The organization update data
|
||||
|
||||
Returns:
|
||||
set[str]: Set of LLM fields being updated (empty if none)
|
||||
"""
|
||||
llm_fields = OrgService._get_llm_settings_fields()
|
||||
update_dict = update_data.model_dump(exclude_none=True)
|
||||
return llm_fields.intersection(update_dict.keys())
|
||||
|
||||
@staticmethod
|
||||
async def update_org_with_permissions(
|
||||
org_id: UUID,
|
||||
update_data: OrgUpdate,
|
||||
user_id: str,
|
||||
) -> Org:
|
||||
"""
|
||||
Update organization with permission checks for LLM settings.
|
||||
|
||||
Args:
|
||||
org_id: Organization UUID to update
|
||||
update_data: Organization update data from request
|
||||
user_id: ID of the user requesting the update
|
||||
|
||||
Returns:
|
||||
Org: The updated organization object
|
||||
|
||||
Raises:
|
||||
ValueError: If organization not found
|
||||
PermissionError: If user is not a member, or lacks admin/owner role for LLM settings
|
||||
OrgDatabaseError: If database update fails
|
||||
"""
|
||||
logger.info(
|
||||
'Updating organization with permission checks',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'has_update_data': update_data is not None,
|
||||
},
|
||||
)
|
||||
|
||||
# Validate organization exists
|
||||
existing_org = OrgStore.get_org_by_id(org_id)
|
||||
if not existing_org:
|
||||
raise ValueError(f'Organization with ID {org_id} not found')
|
||||
|
||||
# Check if user is a member of this organization
|
||||
if not OrgService.is_org_member(user_id, org_id):
|
||||
logger.warning(
|
||||
'Non-member attempted to update organization',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
},
|
||||
)
|
||||
raise PermissionError(
|
||||
'User must be a member of the organization to update it'
|
||||
)
|
||||
|
||||
# Check if update contains any LLM settings
|
||||
llm_fields_being_updated = OrgService._has_llm_settings_updates(update_data)
|
||||
if llm_fields_being_updated:
|
||||
# Verify user has admin or owner role
|
||||
has_permission = OrgService.has_admin_or_owner_role(user_id, org_id)
|
||||
if not has_permission:
|
||||
logger.warning(
|
||||
'User attempted to update LLM settings without permission',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'attempted_fields': list(llm_fields_being_updated),
|
||||
},
|
||||
)
|
||||
raise PermissionError(
|
||||
'Admin or owner role required to update LLM settings'
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'User has permission to update LLM settings',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'llm_fields': list(llm_fields_being_updated),
|
||||
},
|
||||
)
|
||||
|
||||
# Convert to dict for OrgStore (excluding None values)
|
||||
update_dict = update_data.model_dump(exclude_none=True)
|
||||
if not update_dict:
|
||||
logger.info(
|
||||
'No fields to update',
|
||||
extra={'org_id': str(org_id), 'user_id': user_id},
|
||||
)
|
||||
return existing_org
|
||||
|
||||
# Perform the update
|
||||
try:
|
||||
updated_org = OrgStore.update_org(org_id, update_dict)
|
||||
if not updated_org:
|
||||
raise OrgDatabaseError('Failed to update organization in database')
|
||||
|
||||
logger.info(
|
||||
'Organization updated successfully',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'updated_fields': list(update_dict.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
return updated_org
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Failed to update organization',
|
||||
extra={
|
||||
'org_id': str(org_id),
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise OrgDatabaseError(f'Failed to update organization: {str(e)}')
|
||||
|
||||
@staticmethod
|
||||
async def get_org_credits(user_id: str, org_id: UUID) -> float | None:
|
||||
"""
|
||||
Get organization credits from LiteLLM team.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
org_id: Organization ID
|
||||
|
||||
Returns:
|
||||
float | None: Credits (max_budget - spend) or None if LiteLLM not configured
|
||||
"""
|
||||
try:
|
||||
user_team_info = await LiteLlmManager.get_user_team_info(
|
||||
user_id, str(org_id)
|
||||
)
|
||||
if not user_team_info:
|
||||
logger.warning(
|
||||
'No team info available from LiteLLM',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
return None
|
||||
|
||||
max_budget = (user_team_info.get('litellm_budget_table') or {}).get(
|
||||
'max_budget', 0
|
||||
)
|
||||
spend = user_team_info.get('spend', 0)
|
||||
credits = max(max_budget - spend, 0)
|
||||
|
||||
logger.debug(
|
||||
'Retrieved organization credits',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'credits': credits,
|
||||
'max_budget': max_budget,
|
||||
'spend': spend,
|
||||
},
|
||||
)
|
||||
|
||||
return credits
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Failed to retrieve organization credits',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_user_orgs_paginated(
|
||||
user_id: str, page_id: str | None = None, limit: int = 100
|
||||
):
|
||||
"""
|
||||
Get paginated list of organizations for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID (string that will be converted to UUID)
|
||||
page_id: Optional page ID (offset as string) for pagination
|
||||
limit: Maximum number of organizations to return
|
||||
|
||||
Returns:
|
||||
Tuple of (list of Org objects, next_page_id or None)
|
||||
"""
|
||||
logger.debug(
|
||||
'Fetching paginated organizations for user',
|
||||
extra={'user_id': user_id, 'page_id': page_id, 'limit': limit},
|
||||
)
|
||||
|
||||
# Convert user_id string to UUID
|
||||
user_uuid = parse_uuid(user_id)
|
||||
|
||||
# Fetch organizations from store
|
||||
orgs, next_page_id = OrgStore.get_user_orgs_paginated(
|
||||
user_id=user_uuid, page_id=page_id, limit=limit
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'Retrieved organizations for user',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_count': len(orgs),
|
||||
'has_more': next_page_id is not None,
|
||||
},
|
||||
)
|
||||
|
||||
return orgs, next_page_id
|
||||
|
||||
@staticmethod
|
||||
async def get_org_by_id(org_id: UUID, user_id: str) -> Org:
|
||||
"""
|
||||
Get organization by ID with membership validation.
|
||||
|
||||
This method verifies that the user is a member of the organization
|
||||
before returning the organization details.
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
user_id: User ID (string that will be converted to UUID)
|
||||
|
||||
Returns:
|
||||
Org: The organization object
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If organization not found or user is not a member
|
||||
"""
|
||||
logger.info(
|
||||
'Retrieving organization',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# Verify user is a member of the organization
|
||||
org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
|
||||
if not org_member:
|
||||
logger.warning(
|
||||
'User is not a member of organization or organization does not exist',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise OrgNotFoundError(str(org_id))
|
||||
|
||||
# Retrieve organization
|
||||
org = OrgStore.get_org_by_id(org_id)
|
||||
if not org:
|
||||
logger.error(
|
||||
'Organization not found despite valid membership',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
raise OrgNotFoundError(str(org_id))
|
||||
|
||||
logger.info(
|
||||
'Successfully retrieved organization',
|
||||
extra={
|
||||
'org_id': str(org.id),
|
||||
'org_name': org.name,
|
||||
'user_id': user_id,
|
||||
},
|
||||
)
|
||||
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def verify_owner_authorization(user_id: str, org_id: UUID) -> None:
|
||||
"""
|
||||
Verify that the user is the owner of the organization.
|
||||
|
||||
Args:
|
||||
user_id: User ID to check
|
||||
org_id: Organization ID
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If organization doesn't exist
|
||||
OrgAuthorizationError: If user is not authorized to delete
|
||||
"""
|
||||
# Check if organization exists
|
||||
org = OrgStore.get_org_by_id(org_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError(str(org_id))
|
||||
|
||||
# Check if user is a member of the organization
|
||||
org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
|
||||
if not org_member:
|
||||
raise OrgAuthorizationError('User is not a member of this organization')
|
||||
|
||||
# Check if user has owner role
|
||||
role = RoleStore.get_role_by_id(org_member.role_id)
|
||||
if not role or role.name != 'owner':
|
||||
raise OrgAuthorizationError(
|
||||
'Only organization owners can delete organizations'
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
'User authorization verified for organization deletion',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'role': role.name},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def delete_org_with_cleanup(user_id: str, org_id: UUID) -> Org:
|
||||
"""
|
||||
Delete organization with complete cleanup of all associated data.
|
||||
|
||||
This method performs the complete organization deletion workflow:
|
||||
1. Verifies user authorization (owner only)
|
||||
2. Performs database cascade deletion and LiteLLM cleanup in single transaction
|
||||
|
||||
Args:
|
||||
user_id: User ID requesting deletion (must be owner)
|
||||
org_id: Organization ID to delete
|
||||
|
||||
Returns:
|
||||
Org: The deleted organization details
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If organization doesn't exist
|
||||
OrgAuthorizationError: If user is not authorized to delete
|
||||
OrgDatabaseError: If database operations or LiteLLM cleanup fail
|
||||
"""
|
||||
logger.info(
|
||||
'Starting organization deletion',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# Step 1: Verify user authorization
|
||||
OrgService.verify_owner_authorization(user_id, org_id)
|
||||
|
||||
# Step 2: Perform database cascade deletion with LiteLLM cleanup in transaction
|
||||
try:
|
||||
deleted_org = await OrgStore.delete_org_cascade(org_id)
|
||||
if not deleted_org:
|
||||
# This shouldn't happen since we verified existence above
|
||||
raise OrgDatabaseError('Organization not found during deletion')
|
||||
|
||||
logger.info(
|
||||
'Organization deletion completed successfully',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'org_name': deleted_org.name,
|
||||
},
|
||||
)
|
||||
|
||||
return deleted_org
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Organization deletion failed',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise OrgDatabaseError(f'Failed to delete organization: {str(e)}')
|
||||
363
enterprise/storage/org_store.py
Normal file
363
enterprise/storage/org_store.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""
|
||||
Store class for managing organizations.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import (
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
class OrgStore:
|
||||
"""Store for managing organizations."""
|
||||
|
||||
@staticmethod
|
||||
def create_org(
|
||||
kwargs: dict,
|
||||
) -> Org:
|
||||
"""Create a new organization."""
|
||||
with session_maker() as session:
|
||||
org = Org(**kwargs)
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
session.add(org)
|
||||
session.commit()
|
||||
session.refresh(org)
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def get_org_by_id(org_id: UUID) -> Org | None:
|
||||
"""Get organization by ID."""
|
||||
org = None
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == org_id).first()
|
||||
return OrgStore._validate_org_version(org)
|
||||
|
||||
@staticmethod
|
||||
def get_current_org_from_keycloak_user_id(keycloak_user_id: str) -> Org | None:
|
||||
with session_maker() as session:
|
||||
user = (
|
||||
session.query(User)
|
||||
.options(joinedload(User.org_members))
|
||||
.filter(User.id == UUID(keycloak_user_id))
|
||||
.first()
|
||||
)
|
||||
if not user:
|
||||
logger.warning(f'User not found for ID {keycloak_user_id}')
|
||||
return None
|
||||
org_id = user.current_org_id
|
||||
org = session.query(Org).filter(Org.id == org_id).first()
|
||||
if not org:
|
||||
logger.warning(
|
||||
f'Org not found for ID {org_id} as the current org for user {keycloak_user_id}'
|
||||
)
|
||||
return None
|
||||
return OrgStore._validate_org_version(org)
|
||||
|
||||
@staticmethod
|
||||
def get_org_by_name(name: str) -> Org | None:
|
||||
"""Get organization by name."""
|
||||
org = None
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.name == name).first()
|
||||
return OrgStore._validate_org_version(org)
|
||||
|
||||
@staticmethod
|
||||
def _validate_org_version(org: Org) -> Org | None:
|
||||
"""Check if we need to update org version."""
|
||||
if org and org.org_version < ORG_SETTINGS_VERSION:
|
||||
org = OrgStore.update_org(
|
||||
org.id,
|
||||
{
|
||||
'org_version': ORG_SETTINGS_VERSION,
|
||||
'default_llm_model': get_default_litellm_model(),
|
||||
'llm_base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
)
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def list_orgs() -> list[Org]:
|
||||
"""List all organizations."""
|
||||
with session_maker() as session:
|
||||
orgs = session.query(Org).all()
|
||||
return orgs
|
||||
|
||||
@staticmethod
|
||||
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.
|
||||
|
||||
Args:
|
||||
user_id: User UUID
|
||||
page_id: Optional page ID (offset as string) for pagination
|
||||
limit: Maximum number of organizations to return
|
||||
|
||||
Returns:
|
||||
Tuple of (list of Org objects, next_page_id or None)
|
||||
"""
|
||||
with session_maker() as session:
|
||||
# Build query joining OrgMember with Org
|
||||
query = (
|
||||
session.query(Org)
|
||||
.join(OrgMember, Org.id == OrgMember.org_id)
|
||||
.filter(OrgMember.user_id == user_id)
|
||||
.order_by(Org.name)
|
||||
)
|
||||
|
||||
# Apply pagination offset
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
query = query.offset(offset)
|
||||
except ValueError:
|
||||
# If page_id is not a valid integer, start from beginning
|
||||
offset = 0
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
# Fetch limit + 1 to check if there are more results
|
||||
query = query.limit(limit + 1)
|
||||
orgs = query.all()
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(orgs) > limit
|
||||
if has_more:
|
||||
orgs = orgs[:limit]
|
||||
|
||||
# Calculate next page ID
|
||||
next_page_id = None
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
|
||||
# Validate org versions
|
||||
validated_orgs = [
|
||||
OrgStore._validate_org_version(org) for org in orgs if org
|
||||
]
|
||||
validated_orgs = [org for org in validated_orgs if org is not None]
|
||||
|
||||
return validated_orgs, next_page_id
|
||||
|
||||
@staticmethod
|
||||
def update_org(
|
||||
org_id: UUID,
|
||||
kwargs: dict,
|
||||
) -> Optional[Org]:
|
||||
"""Update organization details."""
|
||||
with session_maker() as session:
|
||||
org = session.query(Org).filter(Org.id == org_id).first()
|
||||
if not org:
|
||||
return None
|
||||
|
||||
if 'id' in kwargs:
|
||||
kwargs.pop('id')
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
session.commit()
|
||||
session.refresh(org)
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
def get_kwargs_from_settings(settings: Settings):
|
||||
kwargs = {}
|
||||
|
||||
for c in Org.__table__.columns:
|
||||
# Normalize for lookup
|
||||
normalized = (
|
||||
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
|
||||
)
|
||||
|
||||
if not hasattr(settings, normalized):
|
||||
continue
|
||||
|
||||
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
|
||||
key = c.name
|
||||
if key.startswith('_'):
|
||||
key = key[1:] # remove only the very first leading underscore
|
||||
|
||||
kwargs[key] = getattr(settings, normalized)
|
||||
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def get_kwargs_from_user_settings(user_settings: UserSettings):
|
||||
kwargs = {}
|
||||
|
||||
for c in Org.__table__.columns:
|
||||
# Normalize for lookup
|
||||
normalized = (
|
||||
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
|
||||
)
|
||||
|
||||
if not hasattr(user_settings, normalized):
|
||||
continue
|
||||
|
||||
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
|
||||
key = c.name
|
||||
if key.startswith('_'):
|
||||
key = key[1:] # remove only the very first leading underscore
|
||||
|
||||
kwargs[key] = getattr(user_settings, normalized)
|
||||
|
||||
kwargs['org_version'] = user_settings.user_version
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def persist_org_with_owner(
|
||||
org: Org,
|
||||
org_member: OrgMember,
|
||||
) -> Org:
|
||||
"""
|
||||
Persist organization and owner membership in a single transaction.
|
||||
|
||||
Args:
|
||||
org: Organization entity to persist
|
||||
org_member: Organization member entity to persist
|
||||
|
||||
Returns:
|
||||
Org: The persisted organization object
|
||||
|
||||
Raises:
|
||||
Exception: If database operations fail
|
||||
"""
|
||||
with session_maker() as session:
|
||||
session.add(org)
|
||||
session.add(org_member)
|
||||
session.commit()
|
||||
session.refresh(org)
|
||||
return org
|
||||
|
||||
@staticmethod
|
||||
async def delete_org_cascade(org_id: UUID) -> Org | None:
|
||||
"""
|
||||
Delete organization and all associated data in cascade, including external LiteLLM cleanup.
|
||||
|
||||
Args:
|
||||
org_id: UUID of the organization to delete
|
||||
|
||||
Returns:
|
||||
Org: The deleted organization object, or None if not found
|
||||
|
||||
Raises:
|
||||
Exception: If database operations or LiteLLM cleanup fail
|
||||
"""
|
||||
with session_maker() as session:
|
||||
# First get the organization to return it
|
||||
org = session.query(Org).filter(Org.id == org_id).first()
|
||||
if not org:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 1. Delete conversation data for organization conversations
|
||||
session.execute(
|
||||
text("""
|
||||
DELETE FROM conversation_metadata
|
||||
WHERE conversation_id IN (
|
||||
SELECT conversation_id FROM conversation_metadata_saas WHERE org_id = :org_id
|
||||
)
|
||||
"""),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
session.execute(
|
||||
text("""
|
||||
DELETE FROM app_conversation_start_task
|
||||
WHERE app_conversation_id::text IN (
|
||||
SELECT conversation_id FROM conversation_metadata_saas WHERE org_id = :org_id
|
||||
)
|
||||
"""),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# 2. Delete organization-owned data tables (direct org_id foreign keys)
|
||||
session.execute(
|
||||
text('DELETE FROM billing_sessions WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
session.execute(
|
||||
text(
|
||||
'DELETE FROM conversation_metadata_saas WHERE org_id = :org_id'
|
||||
),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
session.execute(
|
||||
text('DELETE FROM custom_secrets WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
session.execute(
|
||||
text('DELETE FROM api_keys WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
session.execute(
|
||||
text('DELETE FROM slack_conversation WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
session.execute(
|
||||
text('DELETE FROM slack_users WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
session.execute(
|
||||
text('DELETE FROM stripe_customers WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# 3. Delete organization memberships
|
||||
session.execute(
|
||||
text('DELETE FROM org_member WHERE org_id = :org_id'),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# 4. Handle users with this as current_org_id
|
||||
session.execute(
|
||||
text(
|
||||
'UPDATE "user" SET current_org_id = NULL WHERE current_org_id = :org_id'
|
||||
),
|
||||
{'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# 5. Finally delete the organization
|
||||
session.delete(org)
|
||||
|
||||
# 6. Clean up LiteLLM team before committing transaction
|
||||
logger.info(
|
||||
'Deleting LiteLLM team within database transaction',
|
||||
extra={'org_id': str(org_id)},
|
||||
)
|
||||
await LiteLlmManager.delete_team(str(org_id))
|
||||
|
||||
# 7. Commit all changes only if everything succeeded
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'Successfully deleted organization and all associated data including LiteLLM team',
|
||||
extra={'org_id': str(org_id), 'org_name': org.name},
|
||||
)
|
||||
|
||||
return org
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error(
|
||||
'Failed to delete organization - transaction rolled back',
|
||||
extra={'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise
|
||||
21
enterprise/storage/role.py
Normal file
21
enterprise/storage/role.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
SQLAlchemy model for Role.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Identity, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class Role(Base): # type: ignore
|
||||
"""Role model for user permissions."""
|
||||
|
||||
__tablename__ = 'role'
|
||||
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
rank = Column(Integer, nullable=False)
|
||||
|
||||
# Relationships
|
||||
users = relationship('User', back_populates='role')
|
||||
org_members = relationship('OrgMember', back_populates='role')
|
||||
56
enterprise/storage/role_store.py
Normal file
56
enterprise/storage/role_store.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Store class for managing roles.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.role import Role
|
||||
|
||||
|
||||
class RoleStore:
|
||||
"""Store for managing roles."""
|
||||
|
||||
@staticmethod
|
||||
def create_role(name: str, rank: int) -> Role:
|
||||
"""Create a new role."""
|
||||
with session_maker() as session:
|
||||
role = Role(name=name, rank=rank)
|
||||
session.add(role)
|
||||
session.commit()
|
||||
session.refresh(role)
|
||||
return role
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(role_id: int) -> Optional[Role]:
|
||||
"""Get role by ID."""
|
||||
with session_maker() as session:
|
||||
return session.query(Role).filter(Role.id == role_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_name(name: str) -> Optional[Role]:
|
||||
"""Get role by name."""
|
||||
with session_maker() as session:
|
||||
return session.query(Role).filter(Role.name == name).first()
|
||||
|
||||
@staticmethod
|
||||
async def get_role_by_name_async(
|
||||
name: str,
|
||||
session: Optional[AsyncSession] = None,
|
||||
) -> Optional[Role]:
|
||||
"""Get role by name."""
|
||||
if session is not None:
|
||||
result = await session.execute(select(Role).where(Role.name == name))
|
||||
return result.scalars().first()
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(Role).where(Role.name == name))
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
def list_roles() -> List[Role]:
|
||||
"""List all roles."""
|
||||
with session_maker() as session:
|
||||
return session.query(Role).order_by(Role.rank).all()
|
||||
@@ -4,10 +4,13 @@ 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
|
||||
@@ -29,20 +32,36 @@ logger = logging.getLogger(__name__)
|
||||
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):
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
self.session_maker = session_maker
|
||||
|
||||
def _select_by_id(self, session, conversation_id: str):
|
||||
return (
|
||||
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
|
||||
query = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.user_id == self.user_id)
|
||||
.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
|
||||
if c.name != 'github_user_id' # Skip github_user_id field
|
||||
}
|
||||
# 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)
|
||||
@@ -53,6 +72,8 @@ class SaasConversationStore(ConversationStore):
|
||||
# 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)
|
||||
@@ -67,7 +88,10 @@ class SaasConversationStore(ConversationStore):
|
||||
|
||||
async def save_metadata(self, metadata: ConversationMetadata):
|
||||
kwargs = dataclasses.asdict(metadata)
|
||||
kwargs['user_id'] = self.user_id
|
||||
|
||||
# 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:
|
||||
@@ -81,7 +105,41 @@ class SaasConversationStore(ConversationStore):
|
||||
|
||||
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=self.org_id,
|
||||
)
|
||||
session.add(saas_metadata)
|
||||
else:
|
||||
# Validate
|
||||
expected_user_id = UUID(self.user_id)
|
||||
expected_org_id = self.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)
|
||||
@@ -101,8 +159,29 @@ class SaasConversationStore(ConversationStore):
|
||||
async def delete_metadata(self, conversation_id: str) -> None:
|
||||
def _delete_metadata():
|
||||
with self.session_maker() as session:
|
||||
self._select_by_id(session, conversation_id).delete()
|
||||
session.commit()
|
||||
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)
|
||||
|
||||
@@ -125,7 +204,15 @@ class SaasConversationStore(ConversationStore):
|
||||
with self.session_maker() as session:
|
||||
conversations = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.user_id == self.user_id)
|
||||
.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)
|
||||
@@ -147,4 +234,6 @@ class SaasConversationStore(ConversationStore):
|
||||
cls, config: OpenHandsConfig, user_id: str | None
|
||||
) -> ConversationStore:
|
||||
# user_id should not be None in SaaS, should we raise?
|
||||
return SaasConversationStore(str(user_id), session_maker)
|
||||
user = await UserStore.get_user_by_id_async(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(str(user_id), org_id, session_maker)
|
||||
|
||||
@@ -8,6 +8,7 @@ from cryptography.fernet import Fernet
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.database import session_maker
|
||||
from storage.stored_custom_secrets import StoredCustomSecrets
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -24,14 +25,17 @@ class SaasSecretsStore(SecretsStore):
|
||||
async def load(self) -> Secrets | None:
|
||||
if not self.user_id:
|
||||
return None
|
||||
user = await UserStore.get_user_by_id_async(self.user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
|
||||
with self.session_maker() as session:
|
||||
# Fetch all secrets for the given user ID
|
||||
settings = (
|
||||
session.query(StoredCustomSecrets)
|
||||
.filter(StoredCustomSecrets.keycloak_user_id == self.user_id)
|
||||
.all()
|
||||
query = session.query(StoredCustomSecrets).filter(
|
||||
StoredCustomSecrets.keycloak_user_id == self.user_id
|
||||
)
|
||||
if org_id is not None:
|
||||
query = query.filter(StoredCustomSecrets.org_id == org_id)
|
||||
settings = query.all()
|
||||
|
||||
if not settings:
|
||||
return Secrets()
|
||||
@@ -48,6 +52,8 @@ class SaasSecretsStore(SecretsStore):
|
||||
return Secrets(custom_secrets=kwargs) # type: ignore[arg-type]
|
||||
|
||||
async def store(self, item: Secrets):
|
||||
user = await UserStore.get_user_by_id_async(self.user_id)
|
||||
org_id = user.current_org_id
|
||||
with self.session_maker() as session:
|
||||
# Incoming secrets are always the most updated ones
|
||||
# Delete all existing records and override with incoming ones
|
||||
@@ -76,6 +82,7 @@ class SaasSecretsStore(SecretsStore):
|
||||
for secret_name, secret_value, description in secret_tuples:
|
||||
new_secret = StoredCustomSecrets(
|
||||
keycloak_user_id=self.user_id,
|
||||
org_id=org_id,
|
||||
secret_name=secret_name,
|
||||
secret_value=secret_value,
|
||||
description=description,
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from base64 import b64decode, b64encode
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from cryptography.fernet import Fernet
|
||||
from integrations import stripe_service
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import (
|
||||
CURRENT_USER_SETTINGS_VERSION,
|
||||
DEFAULT_INITIAL_BUDGET,
|
||||
LITE_LLM_API_KEY,
|
||||
LITE_LLM_API_URL,
|
||||
LITE_LLM_TEAM_ID,
|
||||
REQUIRE_PAYMENT,
|
||||
USER_SETTINGS_VERSION_TO_MODEL,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from server.logger import logger
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import joinedload, sessionmaker
|
||||
from storage.database import session_maker
|
||||
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.org_store import OrgStore
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
# The max possible time to wait for another process to finish creating a user before retrying
|
||||
_REDIS_CREATE_TIMEOUT_SECONDS = 30
|
||||
# The delay to wait for another process to finish creating a user before trying to load again
|
||||
_RETRY_LOAD_DELAY_SECONDS = 2
|
||||
# Redis key prefix for user creation locks
|
||||
_REDIS_USER_CREATION_KEY_PREFIX = 'create_user:'
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -48,8 +31,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
user_id: str
|
||||
session_maker: sessionmaker
|
||||
config: OpenHandsConfig
|
||||
ENCRYPT_VALUES = ['llm_api_key', 'llm_api_key_for_byor', 'search_api_key']
|
||||
|
||||
def get_user_settings_by_keycloak_id(
|
||||
def _get_user_settings_by_keycloak_id(
|
||||
self, keycloak_user_id: str, session=None
|
||||
) -> UserSettings | None:
|
||||
"""
|
||||
@@ -85,354 +69,109 @@ class SaasSettingsStore(SettingsStore):
|
||||
return _get_settings()
|
||||
|
||||
async def load(self) -> Settings | None:
|
||||
if not self.user_id:
|
||||
user = await call_sync_from_async(UserStore.get_user_by_id, self.user_id)
|
||||
if not user:
|
||||
logger.error(f'User not found for ID {self.user_id}')
|
||||
return None
|
||||
with self.session_maker() as session:
|
||||
settings = self.get_user_settings_by_keycloak_id(self.user_id, session)
|
||||
|
||||
if not settings or settings.user_version != CURRENT_USER_SETTINGS_VERSION:
|
||||
logger.info(
|
||||
'saas_settings_store:load:triggering_migration',
|
||||
extra={'user_id': self.user_id},
|
||||
org_id = user.current_org_id
|
||||
org_member: OrgMember = None
|
||||
for om in user.org_members:
|
||||
if om.org_id == org_id:
|
||||
org_member = om
|
||||
break
|
||||
if not org_member or not org_member.llm_api_key:
|
||||
return None
|
||||
org = OrgStore.get_org_by_id(org_id)
|
||||
if not org:
|
||||
logger.error(
|
||||
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
|
||||
)
|
||||
return None
|
||||
kwargs = {
|
||||
**{
|
||||
normalized: getattr(org, c.name)
|
||||
for c in Org.__table__.columns
|
||||
if (
|
||||
normalized := c.name.removeprefix('_default_')
|
||||
.removeprefix('default_')
|
||||
.lstrip('_')
|
||||
)
|
||||
return await self.create_default_settings(settings)
|
||||
kwargs = {
|
||||
c.name: getattr(settings, c.name)
|
||||
for c in UserSettings.__table__.columns
|
||||
if c.name in Settings.model_fields
|
||||
}
|
||||
self._decrypt_kwargs(kwargs)
|
||||
settings = Settings(**kwargs)
|
||||
in Settings.model_fields
|
||||
},
|
||||
**{
|
||||
normalized: getattr(user, c.name)
|
||||
for c in User.__table__.columns
|
||||
if (normalized := c.name.lstrip('_')) in Settings.model_fields
|
||||
},
|
||||
}
|
||||
kwargs['llm_api_key'] = org_member.llm_api_key
|
||||
if org_member.max_iterations is not None:
|
||||
kwargs['max_iterations'] = org_member.max_iterations
|
||||
if org_member.llm_model is not None:
|
||||
kwargs['llm_model'] = org_member.llm_model
|
||||
if org_member.llm_api_key_for_byor is not None:
|
||||
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
|
||||
if org_member.llm_base_url is not None:
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
|
||||
return settings
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
|
||||
async def store(self, item: Settings):
|
||||
# Check if provider is OpenHands and generate API key if needed
|
||||
if item and self._is_openhands_provider(item):
|
||||
await self._ensure_openhands_api_key(item)
|
||||
|
||||
with self.session_maker() as session:
|
||||
existing = None
|
||||
kwargs = {}
|
||||
if item:
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
self._encrypt_kwargs(kwargs)
|
||||
# First check if we have an existing entry in the new table
|
||||
existing = self.get_user_settings_by_keycloak_id(self.user_id, session)
|
||||
|
||||
kwargs = {
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if key in UserSettings.__table__.columns
|
||||
}
|
||||
if existing:
|
||||
# Update existing entry
|
||||
for key, value in kwargs.items():
|
||||
setattr(existing, key, value)
|
||||
existing.user_version = CURRENT_USER_SETTINGS_VERSION
|
||||
session.merge(existing)
|
||||
else:
|
||||
kwargs['keycloak_user_id'] = self.user_id
|
||||
kwargs['user_version'] = CURRENT_USER_SETTINGS_VERSION
|
||||
kwargs.pop('secrets_store', None) # Don't save secrets_store to db
|
||||
settings = UserSettings(**kwargs)
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
|
||||
def _get_redis_client(self):
|
||||
"""Get the Redis client from the Socket.IO manager."""
|
||||
from openhands.server.shared import sio
|
||||
|
||||
return getattr(sio.manager, 'redis', None)
|
||||
|
||||
async def _acquire_user_creation_lock(self) -> bool:
|
||||
"""Attempt to acquire a distributed lock for user creation.
|
||||
|
||||
Returns True if the lock was acquired or if Redis is unavailable (fallback to no locking).
|
||||
Returns False if another process holds the lock.
|
||||
"""
|
||||
redis_client = self._get_redis_client()
|
||||
if redis_client is None:
|
||||
logger.warning(
|
||||
'saas_settings_store:_acquire_user_creation_lock:no_redis_client',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
return True # Proceed without locking if Redis is unavailable
|
||||
|
||||
user_key = f'{_REDIS_USER_CREATION_KEY_PREFIX}{self.user_id}'
|
||||
lock_acquired = await redis_client.set(
|
||||
user_key, 1, nx=True, ex=_REDIS_CREATE_TIMEOUT_SECONDS
|
||||
)
|
||||
return bool(lock_acquired)
|
||||
|
||||
async def create_default_settings(self, user_settings: UserSettings | None):
|
||||
logger.info(
|
||||
'saas_settings_store:create_default_settings:start',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
# You must log in before you get default settings
|
||||
if not self.user_id:
|
||||
return None
|
||||
|
||||
# Prevent duplicate settings creation using distributed lock
|
||||
if not await self._acquire_user_creation_lock():
|
||||
# The user is already being created in another thread / process
|
||||
logger.info(
|
||||
'saas_settings_store:create_default_settings:waiting_for_lock',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
await asyncio.sleep(_RETRY_LOAD_DELAY_SECONDS)
|
||||
return await self.load()
|
||||
|
||||
# Only users that have specified a payment method get default settings
|
||||
if REQUIRE_PAYMENT and not await stripe_service.has_payment_method(
|
||||
self.user_id
|
||||
):
|
||||
logger.info(
|
||||
'saas_settings_store:create_default_settings:no_payment',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
return None
|
||||
settings: Settings | None = None
|
||||
if user_settings is None:
|
||||
settings = Settings(
|
||||
language='en',
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
elif isinstance(user_settings, UserSettings):
|
||||
# Convert UserSettings (SQLAlchemy model) to Settings (Pydantic model)
|
||||
kwargs = {
|
||||
c.name: getattr(user_settings, c.name)
|
||||
for c in UserSettings.__table__.columns
|
||||
if c.name in Settings.model_fields
|
||||
}
|
||||
self._decrypt_kwargs(kwargs)
|
||||
settings = Settings(**kwargs)
|
||||
|
||||
if settings:
|
||||
settings = await self.update_settings_with_litellm_default(settings)
|
||||
if settings is None:
|
||||
logger.info(
|
||||
'saas_settings_store:create_default_settings:litellm_update_failed',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
return None
|
||||
|
||||
await self.store(settings)
|
||||
return settings
|
||||
|
||||
async def load_legacy_file_store_settings(self, github_user_id: str):
|
||||
if not github_user_id:
|
||||
return None
|
||||
|
||||
file_store = get_file_store(self.config.file_store, self.config.file_store_path)
|
||||
path = f'users/github/{github_user_id}/settings.json'
|
||||
|
||||
try:
|
||||
json_str = await call_sync_from_async(file_store.read, path)
|
||||
logger.info(
|
||||
'saas_settings_store:load_legacy_file_store_settings:found',
|
||||
extra={'github_user_id': github_user_id},
|
||||
)
|
||||
kwargs = json.loads(json_str)
|
||||
self._decrypt_kwargs(kwargs)
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'saas_settings_store:load_legacy_file_store_settings:error',
|
||||
extra={'github_user_id': github_user_id, 'error': str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
def _has_custom_settings(
|
||||
self, settings: Settings, old_user_version: int | None
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has custom LLM settings that should be preserved.
|
||||
Returns True if user customized either model or base_url.
|
||||
|
||||
Args:
|
||||
settings: The user's current settings
|
||||
old_user_version: The user's old settings version, if any
|
||||
|
||||
Returns:
|
||||
True if user has custom settings, False if using old defaults
|
||||
"""
|
||||
# Normalize values
|
||||
user_model = (
|
||||
settings.llm_model.strip()
|
||||
if settings.llm_model and settings.llm_model.strip()
|
||||
else None
|
||||
)
|
||||
user_base_url = (
|
||||
settings.llm_base_url.strip()
|
||||
if settings.llm_base_url and settings.llm_base_url.strip()
|
||||
else None
|
||||
)
|
||||
|
||||
# Custom base_url = definitely custom settings (BYOK)
|
||||
if user_base_url and user_base_url != LITE_LLM_API_URL:
|
||||
return True
|
||||
|
||||
# No model set = using defaults
|
||||
if not user_model:
|
||||
return False
|
||||
|
||||
# Check if model matches old version's default
|
||||
if (
|
||||
old_user_version
|
||||
and old_user_version < CURRENT_USER_SETTINGS_VERSION
|
||||
and old_user_version in USER_SETTINGS_VERSION_TO_MODEL
|
||||
):
|
||||
old_default_base = USER_SETTINGS_VERSION_TO_MODEL[old_user_version]
|
||||
user_model_base = user_model.split('/')[-1]
|
||||
if user_model_base == old_default_base:
|
||||
return False # Matches old default
|
||||
|
||||
return True # Custom model
|
||||
|
||||
async def update_settings_with_litellm_default(
|
||||
self, settings: Settings
|
||||
) -> Settings | None:
|
||||
logger.info(
|
||||
'saas_settings_store:update_settings_with_litellm_default:start',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
|
||||
return None
|
||||
local_deploy = os.environ.get('LOCAL_DEPLOYMENT', None)
|
||||
key = LITE_LLM_API_KEY
|
||||
|
||||
# Check if user has custom settings
|
||||
has_custom = self._has_custom_settings(settings, settings.user_version)
|
||||
|
||||
# Determine model to use (needed before LiteLLM user creation)
|
||||
llm_model_to_use = (
|
||||
settings.llm_model
|
||||
if has_custom and settings.llm_model
|
||||
else get_default_litellm_model()
|
||||
)
|
||||
|
||||
if not local_deploy:
|
||||
# Get user info to add to litellm
|
||||
token_manager = TokenManager()
|
||||
keycloak_user_info = (
|
||||
await token_manager.get_user_info_from_user_id(self.user_id) or {}
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
headers={
|
||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||
},
|
||||
) as client:
|
||||
# Get the previous max budget to prevent accidental loss.
|
||||
#
|
||||
# LiteLLM v1.80+ returns 404 for non-existent users (previously returned empty user_info)
|
||||
response = await client.get(
|
||||
f'{LITE_LLM_API_URL}/user/info?user_id={self.user_id}'
|
||||
)
|
||||
user_info: dict
|
||||
if response.status_code == 404:
|
||||
# New user - doesn't exist in LiteLLM yet (v1.80+ behavior)
|
||||
user_info = {}
|
||||
else:
|
||||
# For any other status, use standard error handling
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
user_info = response_json.get('user_info') or {}
|
||||
logger.info(
|
||||
f'creating_litellm_user: {self.user_id}; prev_max_budget: {user_info.get("max_budget")}; prev_metadata: {user_info.get("metadata")}'
|
||||
)
|
||||
max_budget = user_info.get('max_budget') or DEFAULT_INITIAL_BUDGET
|
||||
spend = user_info.get('spend') or 0
|
||||
if not item:
|
||||
return None
|
||||
user = (
|
||||
session.query(User)
|
||||
.options(joinedload(User.org_members))
|
||||
.filter(User.id == uuid.UUID(self.user_id))
|
||||
).first()
|
||||
|
||||
if not user:
|
||||
# Check if we need to migrate from user_settings
|
||||
user_settings = None
|
||||
with session_maker() as session:
|
||||
user_settings = self.get_user_settings_by_keycloak_id(
|
||||
user_settings = self._get_user_settings_by_keycloak_id(
|
||||
self.user_id, session
|
||||
)
|
||||
# In upgrade to V4, we no longer use billing margin, but instead apply this directly
|
||||
# in litellm. The default billing marign was 2 before this (hence the magic numbers below)
|
||||
if (
|
||||
user_settings
|
||||
and user_settings.user_version < 4
|
||||
and user_settings.billing_margin
|
||||
and user_settings.billing_margin != 1.0
|
||||
):
|
||||
billing_margin = user_settings.billing_margin
|
||||
logger.info(
|
||||
'user_settings_v4_budget_upgrade',
|
||||
extra={
|
||||
'max_budget': max_budget,
|
||||
'billing_margin': billing_margin,
|
||||
'spend': spend,
|
||||
},
|
||||
)
|
||||
max_budget *= billing_margin
|
||||
spend *= billing_margin
|
||||
user_settings.billing_margin = 1.0
|
||||
session.commit()
|
||||
|
||||
email = keycloak_user_info.get('email')
|
||||
|
||||
# We explicitly delete here to guard against odd inherited settings on upgrade.
|
||||
# We don't care if this fails with a 404
|
||||
await client.post(
|
||||
f'{LITE_LLM_API_URL}/user/delete', json={'user_ids': [self.user_id]}
|
||||
)
|
||||
|
||||
# Create the new litellm user
|
||||
response = await self._create_user_in_lite_llm(
|
||||
client, email, max_budget, spend, llm_model_to_use
|
||||
)
|
||||
if not response.is_success:
|
||||
logger.warning(
|
||||
'duplicate_user_email',
|
||||
extra={'user_id': self.user_id, 'email': email},
|
||||
)
|
||||
# Litellm insists on unique email addresses - it is possible the email address was registered with a different user.
|
||||
response = await self._create_user_in_lite_llm(
|
||||
client, None, max_budget, spend, llm_model_to_use
|
||||
)
|
||||
|
||||
# User failed to create in litellm - this is an unforseen error state...
|
||||
if not response.is_success:
|
||||
logger.error(
|
||||
'error_creating_litellm_user',
|
||||
extra={
|
||||
'status_code': response.status_code,
|
||||
'text': response.text,
|
||||
'user_id': [self.user_id],
|
||||
'email': email,
|
||||
'max_budget': max_budget,
|
||||
'spend': spend,
|
||||
},
|
||||
)
|
||||
if user_settings:
|
||||
user = await UserStore.migrate_user(self.user_id, user_settings)
|
||||
else:
|
||||
logger.error(f'User not found for ID {self.user_id}')
|
||||
return None
|
||||
|
||||
response_json = response.json()
|
||||
key = response_json['key']
|
||||
org_id = user.current_org_id
|
||||
|
||||
logger.info(
|
||||
'saas_settings_store:update_settings_with_litellm_default:user_created',
|
||||
extra={'user_id': self.user_id},
|
||||
org_member: OrgMember = None
|
||||
for om in user.org_members:
|
||||
if om.org_id == org_id:
|
||||
org_member = om
|
||||
break
|
||||
if not org_member or not org_member.llm_api_key:
|
||||
return None
|
||||
|
||||
org: Org = session.query(Org).filter(Org.id == org_id).first()
|
||||
if not org:
|
||||
logger.error(
|
||||
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
|
||||
)
|
||||
return None
|
||||
|
||||
if has_custom:
|
||||
settings.llm_model = settings.llm_model or get_default_litellm_model()
|
||||
settings.llm_base_url = settings.llm_base_url or LITE_LLM_API_URL
|
||||
settings.llm_api_key = settings.llm_api_key or SecretStr(key)
|
||||
else:
|
||||
settings.llm_model = get_default_litellm_model()
|
||||
settings.llm_base_url = LITE_LLM_API_URL
|
||||
settings.llm_api_key = SecretStr(key)
|
||||
# Check if we need to generate an LLM key.
|
||||
is_openhands_provider = self._is_openhands_provider(item)
|
||||
if is_openhands_provider or item.llm_base_url == LITE_LLM_API_URL:
|
||||
await self._ensure_api_key(item, str(org_id), is_openhands_provider)
|
||||
|
||||
settings.agent = 'CodeActAgent'
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
for model in (user, org, org_member):
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
return settings
|
||||
session.commit()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(
|
||||
@@ -443,6 +182,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
logger.debug(f'saas_settings_store.get_instance::{user_id}')
|
||||
return SaasSettingsStore(user_id, session_maker, config)
|
||||
|
||||
def _should_encrypt(self, key):
|
||||
return key in self.ENCRYPT_VALUES
|
||||
|
||||
def _decrypt_kwargs(self, kwargs: dict):
|
||||
fernet = self._fernet()
|
||||
for key, value in kwargs.items():
|
||||
@@ -486,109 +228,53 @@ class SaasSettingsStore(SettingsStore):
|
||||
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
|
||||
return Fernet(fernet_key)
|
||||
|
||||
def _should_encrypt(self, key: str) -> bool:
|
||||
return key in ('llm_api_key', 'llm_api_key_for_byor', 'search_api_key')
|
||||
|
||||
def _is_openhands_provider(self, item: Settings) -> bool:
|
||||
"""Check if the settings use the OpenHands provider."""
|
||||
return bool(item.llm_model and item.llm_model.startswith('openhands/'))
|
||||
|
||||
async def _ensure_openhands_api_key(self, item: Settings) -> None:
|
||||
async def _ensure_api_key(
|
||||
self, item: Settings, org_id: str, openhands_type: bool = False
|
||||
) -> None:
|
||||
"""Generate and set the OpenHands API key for the given settings.
|
||||
|
||||
First checks if an existing key with the OpenHands alias exists,
|
||||
and reuses it if found. Otherwise, generates a new key.
|
||||
First checks if an existing key exists for the user and verifies it
|
||||
is valid in LiteLLM. If valid, reuses it. Otherwise, generates a new key.
|
||||
"""
|
||||
# Generate new key if none exists
|
||||
generated_key = await self._generate_openhands_key()
|
||||
if generated_key:
|
||||
item.llm_api_key = SecretStr(generated_key)
|
||||
logger.info(
|
||||
'saas_settings_store:store:generated_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'saas_settings_store:store:failed_to_generate_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
|
||||
async def _create_user_in_lite_llm(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
email: str | None,
|
||||
max_budget: int,
|
||||
spend: int,
|
||||
llm_model: str,
|
||||
):
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/user/new',
|
||||
json={
|
||||
'user_email': email,
|
||||
'models': [],
|
||||
'max_budget': max_budget,
|
||||
'spend': spend,
|
||||
'user_id': str(self.user_id),
|
||||
'teams': [LITE_LLM_TEAM_ID],
|
||||
'auto_create_key': True,
|
||||
'send_invite_email': False,
|
||||
'metadata': {
|
||||
'version': CURRENT_USER_SETTINGS_VERSION,
|
||||
'model': llm_model,
|
||||
},
|
||||
'key_alias': f'OpenHands Cloud - user {self.user_id}',
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
async def _generate_openhands_key(self) -> str | None:
|
||||
"""Generate a new OpenHands provider key for a user."""
|
||||
if not (LITE_LLM_API_KEY and LITE_LLM_API_URL):
|
||||
logger.warning(
|
||||
'saas_settings_store:_generate_openhands_key:litellm_config_not_found',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
verify=httpx_verify_option(),
|
||||
headers={
|
||||
'x-goog-api-key': LITE_LLM_API_KEY,
|
||||
},
|
||||
) as client:
|
||||
response = await client.post(
|
||||
f'{LITE_LLM_API_URL}/key/generate',
|
||||
json={
|
||||
'user_id': self.user_id,
|
||||
'metadata': {'type': 'openhands'},
|
||||
},
|
||||
# First, check if our current key is valid
|
||||
if item.llm_api_key and not await LiteLlmManager.verify_existing_key(
|
||||
item.llm_api_key.get_secret_value(),
|
||||
self.user_id,
|
||||
org_id,
|
||||
openhands_type=openhands_type,
|
||||
):
|
||||
generated_key = None
|
||||
if openhands_type:
|
||||
generated_key = await LiteLlmManager.generate_key(
|
||||
self.user_id,
|
||||
org_id,
|
||||
None,
|
||||
{'type': 'openhands'},
|
||||
)
|
||||
else:
|
||||
# Must delete any existing key with the same alias first
|
||||
key_alias = get_openhands_cloud_key_alias(self.user_id, org_id)
|
||||
await LiteLlmManager.delete_key_by_alias(key_alias=key_alias)
|
||||
generated_key = await LiteLlmManager.generate_key(
|
||||
self.user_id,
|
||||
org_id,
|
||||
key_alias,
|
||||
None,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
key = response_json.get('key')
|
||||
|
||||
if key:
|
||||
logger.info(
|
||||
'saas_settings_store:_generate_openhands_key:success',
|
||||
extra={
|
||||
'user_id': self.user_id,
|
||||
'key_length': len(key) if key else 0,
|
||||
'key_prefix': (
|
||||
key[:10] + '...' if key and len(key) > 10 else key
|
||||
),
|
||||
},
|
||||
)
|
||||
return key
|
||||
else:
|
||||
logger.error(
|
||||
'saas_settings_store:_generate_openhands_key:no_key_in_response',
|
||||
extra={'user_id': self.user_id, 'response_json': response_json},
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'saas_settings_store:_generate_openhands_key:error',
|
||||
extra={'user_id': self.user_id, 'error': str(e)},
|
||||
)
|
||||
return None
|
||||
if generated_key:
|
||||
item.llm_api_key = SecretStr(generated_key)
|
||||
logger.info(
|
||||
'saas_settings_store:store:generated_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'saas_settings_store:store:failed_to_generate_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Boolean, Column, Identity, Integer, String
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Identity, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -8,5 +10,9 @@ class SlackConversation(Base): # type: ignore
|
||||
conversation_id = Column(String, nullable=False, index=True)
|
||||
channel_id = Column(String, nullable=False)
|
||||
keycloak_user_id = Column(String, nullable=False)
|
||||
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
|
||||
parent_id = Column(String, nullable=True, index=True)
|
||||
v1_enabled = Column(Boolean, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='slack_conversations')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Column, DateTime, Identity, Integer, String, text
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Identity, Integer, String, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -6,6 +8,7 @@ class SlackUser(Base): # type: ignore
|
||||
__tablename__ = 'slack_users'
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
keycloak_user_id = Column(String, nullable=False, index=True)
|
||||
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
|
||||
slack_user_id = Column(String, nullable=False, index=True)
|
||||
slack_display_name = Column(String, nullable=False)
|
||||
created_at = Column(
|
||||
@@ -13,3 +16,6 @@ class SlackUser(Base): # type: ignore
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='slack_users')
|
||||
|
||||
@@ -4,5 +4,4 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
|
||||
|
||||
StoredConversationMetadata = _StoredConversationMetadata
|
||||
|
||||
|
||||
__all__ = ['StoredConversationMetadata']
|
||||
|
||||
28
enterprise/storage/stored_conversation_metadata_saas.py
Normal file
28
enterprise/storage/stored_conversation_metadata_saas.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
SQLAlchemy model for ConversationMetadataSaas.
|
||||
|
||||
This model stores the SaaS-specific metadata for conversations,
|
||||
containing only the conversation_id, user_id, and org_id.
|
||||
"""
|
||||
|
||||
from sqlalchemy import UUID as SQL_UUID
|
||||
from sqlalchemy import Column, ForeignKey, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class StoredConversationMetadataSaas(Base): # type: ignore
|
||||
"""SaaS conversation metadata model containing user and org associations."""
|
||||
|
||||
__tablename__ = 'conversation_metadata_saas'
|
||||
|
||||
conversation_id = Column(String, primary_key=True)
|
||||
user_id = Column(SQL_UUID(as_uuid=True), ForeignKey('user.id'), nullable=False)
|
||||
org_id = Column(SQL_UUID(as_uuid=True), ForeignKey('org.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='stored_conversation_metadata_saas')
|
||||
org = relationship('Org', back_populates='stored_conversation_metadata_saas')
|
||||
|
||||
|
||||
__all__ = ['StoredConversationMetadataSaas']
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Column, Identity, Integer, String
|
||||
from sqlalchemy import Column, ForeignKey, Identity, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -6,6 +8,10 @@ class StoredCustomSecrets(Base): # type: ignore
|
||||
__tablename__ = 'custom_secrets'
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
keycloak_user_id = Column(String, nullable=True, index=True)
|
||||
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
|
||||
secret_name = Column(String, nullable=False)
|
||||
secret_value = Column(String, nullable=False)
|
||||
description = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='user_secrets')
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import Column, DateTime, Integer, String, text
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -13,6 +15,7 @@ class StripeCustomer(Base): # type: ignore
|
||||
__tablename__ = 'stripe_customers'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
keycloak_user_id = Column(String, nullable=False)
|
||||
org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=True)
|
||||
stripe_customer_id = Column(String, nullable=False)
|
||||
created_at = Column(
|
||||
DateTime, server_default=text('CURRENT_TIMESTAMP'), nullable=False
|
||||
@@ -23,3 +26,6 @@ class StripeCustomer(Base): # type: ignore
|
||||
onupdate=text('CURRENT_TIMESTAMP'),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='stripe_customers')
|
||||
|
||||
43
enterprise/storage/user.py
Normal file
43
enterprise/storage/user.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
SQLAlchemy model for User.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import (
|
||||
UUID,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class User(Base): # type: ignore
|
||||
"""User model with organizational relationships."""
|
||||
|
||||
__tablename__ = 'user'
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||
current_org_id = Column(UUID(as_uuid=True), ForeignKey('org.id'), nullable=False)
|
||||
role_id = Column(Integer, ForeignKey('role.id'), nullable=True)
|
||||
accepted_tos = Column(DateTime, nullable=True)
|
||||
enable_sound_notifications = Column(Boolean, nullable=True)
|
||||
language = Column(String, nullable=True)
|
||||
user_consents_to_analytics = Column(Boolean, nullable=True)
|
||||
email = Column(String, nullable=True)
|
||||
email_verified = Column(Boolean, nullable=True)
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
org_members = relationship('OrgMember', back_populates='user')
|
||||
current_org = relationship('Org', back_populates='current_users')
|
||||
stored_conversation_metadata_saas = relationship(
|
||||
'StoredConversationMetadataSaas', back_populates='user'
|
||||
)
|
||||
@@ -39,3 +39,6 @@ class UserSettings(Base): # type: ignore
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
v1_enabled = Column(Boolean, nullable=True)
|
||||
already_migrated = Column(
|
||||
Boolean, nullable=True, default=False
|
||||
) # False = not migrated, True = migrated
|
||||
|
||||
1002
enterprise/storage/user_store.py
Normal file
1002
enterprise/storage/user_store.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ from sqlalchemy import text
|
||||
# Add the parent directory to the path so we can import from storage
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from server.auth.token_manager import get_keycloak_admin
|
||||
from storage.database import engine
|
||||
from storage.database import get_engine
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -85,7 +85,7 @@ def get_recent_conversations(minutes: int = 60) -> List[Dict[str, Any]]:
|
||||
created_at DESC
|
||||
""")
|
||||
|
||||
with engine.connect() as connection:
|
||||
with get_engine().connect() as connection:
|
||||
result = connection.execute(query, {'minutes': minutes})
|
||||
conversations = [
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy import text
|
||||
# Add the parent directory to the path so we can import from storage
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from storage.database import engine
|
||||
from storage.database import get_engine
|
||||
|
||||
|
||||
def test_conversation_count_query():
|
||||
@@ -29,6 +29,8 @@ def test_conversation_count_query():
|
||||
user_id
|
||||
""")
|
||||
|
||||
engine = get_engine()
|
||||
|
||||
with engine.connect() as connection:
|
||||
count_result = connection.execute(count_query)
|
||||
user_counts = [
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from server.constants import CURRENT_USER_SETTINGS_VERSION
|
||||
from server.maintenance_task_processor.user_version_upgrade_processor import (
|
||||
UserVersionUpgradeProcessor,
|
||||
)
|
||||
from server.constants import ORG_SETTINGS_VERSION
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.base import Base
|
||||
@@ -15,11 +14,16 @@ from storage.conversation_work import ConversationWork
|
||||
from storage.device_code import DeviceCode # noqa: F401
|
||||
from storage.feedback import Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
from storage.stored_offline_token import StoredOfflineToken
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -68,7 +72,6 @@ def add_minimal_fixtures(session_maker):
|
||||
session.add(
|
||||
StoredConversationMetadata(
|
||||
conversation_id='mock-conversation-id',
|
||||
user_id='mock-user-id',
|
||||
created_at=datetime.fromisoformat('2025-03-07'),
|
||||
last_updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
accumulated_cost=5.25,
|
||||
@@ -77,6 +80,13 @@ def add_minimal_fixtures(session_maker):
|
||||
total_tokens=750,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StoredConversationMetadataSaas(
|
||||
conversation_id='mock-conversation-id',
|
||||
user_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
org_id=UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StoredOfflineToken(
|
||||
user_id='mock-user-id',
|
||||
@@ -85,7 +95,38 @@ def add_minimal_fixtures(session_maker):
|
||||
updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
)
|
||||
)
|
||||
|
||||
session.add(
|
||||
Org(
|
||||
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
name='mock-org',
|
||||
org_version=ORG_SETTINGS_VERSION,
|
||||
enable_default_condenser=True,
|
||||
enable_proactive_conversation_starters=True,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Role(
|
||||
id=1,
|
||||
name='admin',
|
||||
rank=1,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
User(
|
||||
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
current_org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
user_consents_to_analytics=True,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
OrgMember(
|
||||
org_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
user_id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
|
||||
role_id=1,
|
||||
llm_api_key='mock-api-key',
|
||||
status='active',
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
StripeCustomer(
|
||||
keycloak_user_id='mock-user-id',
|
||||
@@ -94,13 +135,6 @@ def add_minimal_fixtures(session_maker):
|
||||
updated_at=datetime.fromisoformat('2025-03-10'),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
UserSettings(
|
||||
keycloak_user_id='mock-user-id',
|
||||
user_consents_to_analytics=True,
|
||||
user_version=CURRENT_USER_SETTINGS_VERSION,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
ConversationWork(
|
||||
conversation_id='mock-conversation-id',
|
||||
@@ -109,17 +143,6 @@ def add_minimal_fixtures(session_maker):
|
||||
updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
)
|
||||
)
|
||||
maintenance_task = MaintenanceTask(
|
||||
status=MaintenanceTaskStatus.PENDING,
|
||||
)
|
||||
maintenance_task.set_processor(
|
||||
UserVersionUpgradeProcessor(
|
||||
user_ids=['mock-user-id'],
|
||||
created_at=datetime.fromisoformat('2025-03-07'),
|
||||
updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
)
|
||||
)
|
||||
session.add(maintenance_task)
|
||||
session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from integrations.jira.jira_manager import JiraManager
|
||||
from integrations.jira.jira_payload import (
|
||||
JiraEventType,
|
||||
JiraWebhookPayload,
|
||||
)
|
||||
from integrations.jira.jira_view import (
|
||||
JiraExistingConversationView,
|
||||
JiraNewConversationView,
|
||||
)
|
||||
from integrations.models import JobContext
|
||||
from jinja2 import DictLoader, Environment
|
||||
from storage.jira_conversation import JiraConversation
|
||||
from storage.jira_user import JiraUser
|
||||
@@ -25,7 +27,7 @@ def mock_token_manager():
|
||||
"""Create a mock TokenManager for testing."""
|
||||
token_manager = MagicMock()
|
||||
token_manager.get_user_id_from_user_email = AsyncMock()
|
||||
token_manager.decrypt_text = MagicMock()
|
||||
token_manager.decrypt_text = MagicMock(return_value='decrypted_key')
|
||||
return token_manager
|
||||
|
||||
|
||||
@@ -60,6 +62,7 @@ def sample_jira_workspace():
|
||||
workspace = MagicMock(spec=JiraWorkspace)
|
||||
workspace.id = 1
|
||||
workspace.name = 'test.atlassian.net'
|
||||
workspace.jira_cloud_id = 'cloud-123'
|
||||
workspace.admin_user_id = 'admin_id'
|
||||
workspace.webhook_secret = 'encrypted_secret'
|
||||
workspace.svc_acc_email = 'service@example.com'
|
||||
@@ -75,22 +78,41 @@ def sample_user_auth():
|
||||
user_auth.get_provider_tokens = AsyncMock(return_value={})
|
||||
user_auth.get_access_token = AsyncMock(return_value='test_token')
|
||||
user_auth.get_user_id = AsyncMock(return_value='test_user_id')
|
||||
user_auth.get_secrets = AsyncMock(return_value=None)
|
||||
return user_auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_job_context():
|
||||
"""Create a sample JobContext for testing."""
|
||||
return JobContext(
|
||||
def sample_webhook_payload():
|
||||
"""Create a sample JiraWebhookPayload for testing."""
|
||||
return JiraWebhookPayload(
|
||||
event_type=JiraEventType.COMMENT_MENTION,
|
||||
raw_event='comment_created',
|
||||
issue_id='12345',
|
||||
issue_key='TEST-123',
|
||||
user_msg='Fix this bug @openhands',
|
||||
user_email='user@test.com',
|
||||
display_name='Test User',
|
||||
account_id='user123',
|
||||
workspace_name='test.atlassian.net',
|
||||
base_api_url='https://test.atlassian.net',
|
||||
issue_title='Test Issue',
|
||||
issue_description='This is a test issue',
|
||||
comment_body='Fix this bug @openhands',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_label_webhook_payload():
|
||||
"""Create a sample labeled ticket JiraWebhookPayload for testing."""
|
||||
return JiraWebhookPayload(
|
||||
event_type=JiraEventType.LABELED_TICKET,
|
||||
raw_event='jira:issue_updated',
|
||||
issue_id='12345',
|
||||
issue_key='PROJ-123',
|
||||
user_email='user@company.com',
|
||||
display_name='Test User',
|
||||
account_id='user456',
|
||||
workspace_name='jira.company.com',
|
||||
base_api_url='https://jira.company.com',
|
||||
comment_body='',
|
||||
)
|
||||
|
||||
|
||||
@@ -181,31 +203,17 @@ def jira_conversation():
|
||||
|
||||
@pytest.fixture
|
||||
def new_conversation_view(
|
||||
sample_job_context, sample_user_auth, sample_jira_user, sample_jira_workspace
|
||||
sample_webhook_payload, sample_user_auth, sample_jira_user, sample_jira_workspace
|
||||
):
|
||||
"""JiraNewConversationView instance for testing"""
|
||||
return JiraNewConversationView(
|
||||
job_context=sample_job_context,
|
||||
saas_user_auth=sample_user_auth,
|
||||
jira_user=sample_jira_user,
|
||||
jira_workspace=sample_jira_workspace,
|
||||
selected_repo='test/repo1',
|
||||
conversation_id='conv-123',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def existing_conversation_view(
|
||||
sample_job_context, sample_user_auth, sample_jira_user, sample_jira_workspace
|
||||
):
|
||||
"""JiraExistingConversationView instance for testing"""
|
||||
return JiraExistingConversationView(
|
||||
job_context=sample_job_context,
|
||||
payload=sample_webhook_payload,
|
||||
saas_user_auth=sample_user_auth,
|
||||
jira_user=sample_jira_user,
|
||||
jira_workspace=sample_jira_workspace,
|
||||
selected_repo='test/repo1',
|
||||
conversation_id='conv-123',
|
||||
_decrypted_api_key='decrypted_key',
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user