mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
177 Commits
test-8core
...
APP-1167/c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb5b985e0 | ||
|
|
a5a7a86600 | ||
|
|
28fcfaae25 | ||
|
|
57bcc69f64 | ||
|
|
5c8d7c4c2d | ||
|
|
2068694ea0 | ||
|
|
385122e260 | ||
|
|
97343ebe9a | ||
|
|
926f25a74b | ||
|
|
1406937961 | ||
|
|
5d07387b4e | ||
|
|
52c4d0d9d9 | ||
|
|
d1637cbc3c | ||
|
|
0eab81f89b | ||
|
|
f1ff98b2fc | ||
|
|
26c43d1955 | ||
|
|
d81c2bc0a6 | ||
|
|
fdf5c398fd | ||
|
|
c78b923468 | ||
|
|
db78925d77 | ||
|
|
b4da0e1c69 | ||
|
|
d548665bcf | ||
|
|
eb940ea5e7 | ||
|
|
22b91976fd | ||
|
|
dcf044f8c3 | ||
|
|
d58106b29b | ||
|
|
e11faa6dd1 | ||
|
|
b4b77fbc31 | ||
|
|
ef452b6544 | ||
|
|
0eafa9fd15 | ||
|
|
ab64a65f25 | ||
|
|
4cdf88d480 | ||
|
|
eab9d9e3c7 | ||
|
|
58df84e16c | ||
|
|
3cd74d3bac | ||
|
|
20018842a4 | ||
|
|
cce2080ae0 | ||
|
|
a0304b9e4c | ||
|
|
de492b792f | ||
|
|
7a6eb7e07c | ||
|
|
c92178ac6b | ||
|
|
5400fea1e4 | ||
|
|
635b090065 | ||
|
|
f3815a769f | ||
|
|
4f81d2ae7a | ||
|
|
a06b9ccffa | ||
|
|
8406dcb82f | ||
|
|
6c0a92c2cd | ||
|
|
7f25348506 | ||
|
|
e9067237f2 | ||
|
|
cae7d36522 | ||
|
|
27a2d59c23 | ||
|
|
d3d916745a | ||
|
|
50f1d332cc | ||
|
|
de53245d1b | ||
|
|
8c2661638e | ||
|
|
bdbaba0c34 | ||
|
|
d866d735d9 | ||
|
|
39f3b293f5 | ||
|
|
fa4afa9412 | ||
|
|
f274d5e90f | ||
|
|
dd5eb69c65 | ||
|
|
21d86b6b5e | ||
|
|
2c2e37902f | ||
|
|
f7f029ec1a | ||
|
|
3e9017bb6e | ||
|
|
78e48ace2d | ||
|
|
60ece6d7c2 | ||
|
|
738e7a9834 | ||
|
|
8b4a1f9763 | ||
|
|
0804abec80 | ||
|
|
06c3d9c17b | ||
|
|
754a96e7f3 | ||
|
|
211b73a088 | ||
|
|
54041dd093 | ||
|
|
f271346724 | ||
|
|
d6a0dd7fe4 | ||
|
|
e46bcfa82f | ||
|
|
2eefa5edfd | ||
|
|
54858c0fc0 | ||
|
|
384c324652 | ||
|
|
4e68f57807 | ||
|
|
649ebc4078 | ||
|
|
e3246c27d4 | ||
|
|
72194f19db | ||
|
|
0c5e30ab33 | ||
|
|
b8f2932b02 | ||
|
|
62673c028a | ||
|
|
7af2285fe6 | ||
|
|
69d281c6be | ||
|
|
8ce3089a68 | ||
|
|
b9b10ebf5e | ||
|
|
ce6d5b77c4 | ||
|
|
a458c9b785 | ||
|
|
a65ddc3db6 | ||
|
|
732a1c1991 | ||
|
|
d058323a87 | ||
|
|
7d04cffe4e | ||
|
|
6ad27b77bb | ||
|
|
2739fc8fbe | ||
|
|
38b7e10252 | ||
|
|
7b7d1c0c55 | ||
|
|
e38eda4ac9 | ||
|
|
99c19b6ef0 | ||
|
|
bd4a094eaf | ||
|
|
3ce4f629d6 | ||
|
|
78e8a6c986 | ||
|
|
bacbbad32a | ||
|
|
d077b48a19 | ||
|
|
5e96574730 | ||
|
|
589d12b5bd | ||
|
|
cf48f4c91b | ||
|
|
63b93b6dc3 | ||
|
|
6f0ee09629 | ||
|
|
ed461b3ec1 | ||
|
|
155da8dfd1 | ||
|
|
29aa4f26d8 | ||
|
|
5409004c8d | ||
|
|
918f366a76 | ||
|
|
9b02f06400 | ||
|
|
331c513042 | ||
|
|
61af4662f1 | ||
|
|
4b77beaaa5 | ||
|
|
c0f08a33c3 | ||
|
|
ddf2713483 | ||
|
|
d39de5998a | ||
|
|
782817c1c1 | ||
|
|
463777581e | ||
|
|
5c42ee7a6c | ||
|
|
aa9aed7016 | ||
|
|
894d0eb439 | ||
|
|
7c8e0b1eec | ||
|
|
1a5d024c47 | ||
|
|
0738e75dcf | ||
|
|
54766b4aeb | ||
|
|
4f65eae750 | ||
|
|
fdb6369476 | ||
|
|
77d672c68d | ||
|
|
21ac2a77ff | ||
|
|
b1c61c1534 | ||
|
|
f450c407b5 | ||
|
|
500ed84d01 | ||
|
|
999c18e072 | ||
|
|
a2e16d4819 | ||
|
|
2f5147836f | ||
|
|
ed0f104645 | ||
|
|
192cfd5d91 | ||
|
|
8c2d3d1b9d | ||
|
|
d3a274bbfa | ||
|
|
b9107ea3ad | ||
|
|
8d66a58943 | ||
|
|
7864e9a8e3 | ||
|
|
f0b7e36bab | ||
|
|
53e87a7c27 | ||
|
|
926ebf6906 | ||
|
|
7f25e9cad8 | ||
|
|
2689768c95 | ||
|
|
2f467558ed | ||
|
|
b42ab23e1f | ||
|
|
3c5c307930 | ||
|
|
d62d32af74 | ||
|
|
f6201dd0de | ||
|
|
ed4e2efd50 | ||
|
|
9b00b66efd | ||
|
|
d78d9c4d99 | ||
|
|
04577c6448 | ||
|
|
f8a0533f91 | ||
|
|
893a0db754 | ||
|
|
1f2bef34e3 | ||
|
|
62ed9e47cf | ||
|
|
7b87237d3e | ||
|
|
af74146f80 | ||
|
|
7760aba8e7 | ||
|
|
8550c91d0d | ||
|
|
d8db62b85b | ||
|
|
677f9bdd81 | ||
|
|
9b1ce6d330 |
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@@ -1,8 +0,0 @@
|
||||
# CODEOWNERS file for OpenHands repository
|
||||
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
/frontend/ @amanape @hieptl
|
||||
/openhands-ui/ @amanape @hieptl
|
||||
/openhands/ @tofarr @malhotra5 @hieptl
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
62
.github/pull_request_template.md
vendored
62
.github/pull_request_template.md
vendored
@@ -1,38 +1,46 @@
|
||||
<!-- 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 -->
|
||||
<!-- Keep this PR as draft until it is ready for review. -->
|
||||
|
||||
## Summary of PR
|
||||
<!-- AI/LLM agents: be concise and specific. Do not check the box below. -->
|
||||
|
||||
<!-- Summarize what the PR does -->
|
||||
- [ ] A human has tested these changes.
|
||||
|
||||
## Demo Screenshots/Videos
|
||||
---
|
||||
|
||||
<!-- AI/LLM AGENTS: This section is intended for a human author to add screenshots or videos demonstrating the PR in action (optional). While many pull requests may be generated by AI/LLM agents, we are fine with this as long as a human author has reviewed and tested the changes to ensure accuracy and functionality. -->
|
||||
## Why
|
||||
|
||||
## Change Type
|
||||
<!-- Describe problem, motivation, etc.-->
|
||||
|
||||
<!-- Choose the types that apply to your PR -->
|
||||
## Summary
|
||||
|
||||
<!-- 1-3 bullets describing what changed. -->
|
||||
-
|
||||
|
||||
## Issue Number
|
||||
<!-- Required if there is a relevant issue to this PR. -->
|
||||
|
||||
## How to Test
|
||||
|
||||
<!--
|
||||
Required. Share the steps for the reviewer to be able to test your PR. e.g. You can test by running `npm install` then `npm build dev`.
|
||||
|
||||
If you could not test this, say why.
|
||||
-->
|
||||
|
||||
## Video/Screenshots
|
||||
|
||||
<!--
|
||||
Provide a video or screenshots of testing your PR. e.g. you added a new feature to the gui, show us the video of you testing it successfully.
|
||||
|
||||
-->
|
||||
|
||||
## Type
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] Other (dependency update, docs, typo fixes, etc.)
|
||||
- [ ] Breaking change
|
||||
- [ ] Docs / chore
|
||||
|
||||
## Checklist
|
||||
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
|
||||
## Notes
|
||||
|
||||
- [ ] I have read and reviewed the code and I understand what the code is doing.
|
||||
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
|
||||
|
||||
## Fixes
|
||||
|
||||
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
|
||||
|
||||
Resolves #(issue)
|
||||
|
||||
## Release Notes
|
||||
|
||||
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
|
||||
end-user friendly description for your change below the checkbox. -->
|
||||
|
||||
- [ ] Include this change in the Release Notes.
|
||||
<!-- Optional: migrations, config changes, rollout concerns, follow-ups, or anything reviewers should know. -->
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
4
.github/workflows/e2e-tests.yml
vendored
4
.github/workflows/e2e-tests.yml
vendored
@@ -192,7 +192,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
|
||||
8
.github/workflows/fe-e2e-tests.yml
vendored
8
.github/workflows/fe-e2e-tests.yml
vendored
@@ -17,7 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
fe-e2e-test:
|
||||
name: FE E2E Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
@@ -26,9 +26,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
@@ -39,7 +41,7 @@ jobs:
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test --project=chromium
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
|
||||
6
.github/workflows/fe-unit-tests.yml
vendored
6
.github/workflows/fe-unit-tests.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
# Run frontend unit tests
|
||||
fe-test:
|
||||
name: FE Unit Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
@@ -30,9 +30,11 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
250
.github/workflows/ghcr-build.yml
vendored
250
.github/workflows/ghcr-build.yml
vendored
@@ -30,50 +30,51 @@ env:
|
||||
|
||||
jobs:
|
||||
define-matrix:
|
||||
runs-on: blacksmith
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
base_image: ${{ steps.define-base-images.outputs.base_image }}
|
||||
platforms: ${{ steps.define-base-images.outputs.platforms }}
|
||||
architectures: ${{ steps.define-base-images.outputs.architectures }}
|
||||
steps:
|
||||
- name: Define base images
|
||||
shell: bash
|
||||
id: define-base-images
|
||||
run: |
|
||||
architectures='["amd64", "arm64"]'
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
platforms="linux/amd64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms }
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" }
|
||||
]')
|
||||
else
|
||||
platforms="linux/amd64,linux/arm64"
|
||||
json=$(jq -n -c --arg platforms "$platforms" '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik", platforms: $platforms },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu", platforms: $platforms }
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22-slim", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
]')
|
||||
fi
|
||||
echo "base_image=$json" >> "$GITHUB_OUTPUT"
|
||||
echo "platforms=$platforms" >> "$GITHUB_OUTPUT"
|
||||
echo "architectures=$architectures" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Builds the OpenHands Docker images
|
||||
# Builds the OpenHands Docker images (one per architecture, natively)
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
name: Build App Image (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
needs: define-matrix
|
||||
outputs:
|
||||
# All arch variants produce the same base tags, so any entry works
|
||||
base_tags: ${{ steps.build.outputs.base_tags }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.7.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -85,33 +86,79 @@ jobs:
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
- name: Build and push app image
|
||||
id: build
|
||||
if: "!github.event.pull_request.head.repo.fork"
|
||||
run: |
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push -p ${{ needs.define-matrix.outputs.platforms }}
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push --arch ${{ matrix.arch }}
|
||||
|
||||
# Builds the runtime Docker images
|
||||
# Output base tags (without arch suffix) for the merge job
|
||||
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --arch ${{ matrix.arch }} --dry
|
||||
BASE_TAGS=$(jq -r '.base_tags | join("\n")' docker-build-dry.json)
|
||||
echo "base_tags<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$BASE_TAGS" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Merges per-architecture app images into a multi-arch manifest
|
||||
ghcr_build_app_merge:
|
||||
name: Merge App Multi-Arch Manifest
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [define-matrix, ghcr_build_app]
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Merge multi-arch manifest
|
||||
run: |
|
||||
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
|
||||
TAGS="${{ needs.ghcr_build_app.outputs.base_tags }}"
|
||||
while IFS= read -r tag; do
|
||||
[[ -z "$tag" ]] && continue
|
||||
sources=""
|
||||
for arch in $ARCHS; do
|
||||
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
|
||||
echo "::error::Missing image ${tag}-${arch}"
|
||||
exit 1
|
||||
fi
|
||||
sources+=" ${tag}-${arch}"
|
||||
done
|
||||
echo "Creating manifest for $tag from:$sources"
|
||||
docker buildx imagetools create -t "$tag" $sources
|
||||
done <<< "$TAGS"
|
||||
|
||||
# Builds the runtime Docker images (one per architecture x base_image, natively)
|
||||
ghcr_build_runtime:
|
||||
name: Build Runtime Image
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
name: Build Runtime Image (${{ matrix.base_image.tag }}, ${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: define-matrix
|
||||
outputs:
|
||||
# Keyed by base_image tag so the merge job can access per-variant tags.
|
||||
# Matrix outputs from different entries with the same key overwrite each other,
|
||||
# but all arch variants of the same base_image produce identical base tags.
|
||||
base_tags_nikolaik: ${{ steps.params.outputs.base_tags_nikolaik }}
|
||||
base_tags_ubuntu: ${{ steps.params.outputs.base_tags_ubuntu }}
|
||||
strategy:
|
||||
matrix:
|
||||
base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
|
||||
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.7.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -122,7 +169,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: poetry
|
||||
@@ -137,52 +184,103 @@ jobs:
|
||||
run: |
|
||||
echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
|
||||
- name: Determine docker build params
|
||||
id: params
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry -p ${{ matrix.base_image.platforms }}
|
||||
./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --arch ${{ matrix.arch }} --dry
|
||||
|
||||
DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
|
||||
echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
|
||||
echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
|
||||
echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
|
||||
|
||||
# Output base tags (without arch suffix) keyed by base_image tag for the merge job
|
||||
BASE_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.base_tags | join("\n")')
|
||||
echo "base_tags_${{ matrix.base_image.tag }}<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$BASE_TAGS" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push runtime image ${{ matrix.base_image.image }}
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
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
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}-${{ matrix.arch }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.REPO_OWNER }}/runtime:buildcache-${{ matrix.base_image.tag }}-${{ matrix.arch }},mode=max
|
||||
build-args: ${{ env.DOCKER_BUILD_ARGS }}
|
||||
context: containers/runtime
|
||||
provenance: false
|
||||
# Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
|
||||
- name: Build runtime image ${{ matrix.base_image.image }} for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- name: Upload runtime source for fork
|
||||
if: github.event.pull_request.head.repo.fork
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: runtime-src-${{ matrix.base_image.tag }}
|
||||
name: runtime-src-${{ matrix.base_image.tag }}-${{ matrix.arch }}
|
||||
path: containers/runtime
|
||||
|
||||
# Merges per-architecture runtime images into multi-arch manifests
|
||||
ghcr_build_runtime_merge:
|
||||
name: Merge Runtime Multi-Arch Manifest
|
||||
runs-on: ubuntu-22.04
|
||||
needs: [define-matrix, ghcr_build_runtime]
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Merge multi-arch manifests
|
||||
run: |
|
||||
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
|
||||
|
||||
# Merge all runtime base_image variants
|
||||
for variant_tags in \
|
||||
"${{ needs.ghcr_build_runtime.outputs.base_tags_nikolaik }}" \
|
||||
"${{ needs.ghcr_build_runtime.outputs.base_tags_ubuntu }}"; do
|
||||
while IFS= read -r tag; do
|
||||
[[ -z "$tag" ]] && continue
|
||||
sources=""
|
||||
for arch in $ARCHS; do
|
||||
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
|
||||
echo "::error::Missing image ${tag}-${arch}"
|
||||
exit 1
|
||||
fi
|
||||
sources+=" ${tag}-${arch}"
|
||||
done
|
||||
echo "Creating manifest for $tag from:$sources"
|
||||
docker buildx imagetools create -t "$tag" $sources
|
||||
done <<< "$variant_tags"
|
||||
done
|
||||
|
||||
ghcr_build_enterprise:
|
||||
name: Push Enterprise Image
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
name: Push Enterprise Image (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-22.04' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs: [define-matrix, ghcr_build_app]
|
||||
needs: [define-matrix, ghcr_build_app_merge]
|
||||
# Do not build enterprise in forks
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
outputs:
|
||||
# Tags without arch suffix, for the merge job
|
||||
base_tags: ${{ steps.meta_base.outputs.tags }}
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ${{ fromJson(needs.define-matrix.outputs.architectures) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -196,7 +294,7 @@ jobs:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -204,6 +302,28 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/openhands/enterprise-server
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=sha,format=long
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=cloud-\d+\.\d+\.\d+
|
||||
flavor: |
|
||||
latest=auto
|
||||
prefix=
|
||||
suffix=-${{ matrix.arch }}
|
||||
env:
|
||||
DOCKER_METADATA_PR_HEAD_SHA: true
|
||||
|
||||
# Also compute base tags (no arch suffix) for the merge job output
|
||||
- name: Extract base metadata for merge
|
||||
id: meta_base
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/openhands/enterprise-server
|
||||
@@ -222,6 +342,7 @@ jobs:
|
||||
suffix=
|
||||
env:
|
||||
DOCKER_METADATA_PR_HEAD_SHA: true
|
||||
|
||||
- name: Determine app image tag
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -229,7 +350,7 @@ jobs:
|
||||
# rather than a mutable branch tag like "main" which can serve stale cached layers.
|
||||
echo "OPENHANDS_DOCKER_TAG=${RELEVANT_SHA}" >> $GITHUB_ENV
|
||||
- name: Build and push Docker image
|
||||
uses: useblacksmith/build-push-action@v1
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: enterprise/Dockerfile
|
||||
@@ -238,17 +359,54 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
|
||||
platforms: linux/amd64
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
# Add build provenance
|
||||
provenance: true
|
||||
# Add build attestations for better security
|
||||
sbom: true
|
||||
|
||||
# Merges per-architecture enterprise images into a multi-arch manifest
|
||||
ghcr_build_enterprise_merge:
|
||||
name: Merge Enterprise Multi-Arch Manifest
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
packages: write
|
||||
needs: [define-matrix, ghcr_build_enterprise]
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Merge multi-arch manifest
|
||||
run: |
|
||||
ARCHS='${{ join(fromJson(needs.define-matrix.outputs.architectures), ' ') }}'
|
||||
TAGS="${{ needs.ghcr_build_enterprise.outputs.base_tags }}"
|
||||
while IFS= read -r tag; do
|
||||
[[ -z "$tag" ]] && continue
|
||||
sources=""
|
||||
for arch in $ARCHS; do
|
||||
if ! docker buildx imagetools inspect "${tag}-${arch}" > /dev/null 2>&1; then
|
||||
echo "::error::Missing image ${tag}-${arch}"
|
||||
exit 1
|
||||
fi
|
||||
sources+=" ${tag}-${arch}"
|
||||
done
|
||||
echo "Creating manifest for $tag from:$sources"
|
||||
docker buildx imagetools create -t "$tag" $sources
|
||||
done <<< "$TAGS"
|
||||
|
||||
# "All Runtime Tests Passed" is a required job for PRs to merge
|
||||
# We can remove this once the config changes
|
||||
runtime_tests_check_success:
|
||||
name: All Runtime Tests Passed
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
@@ -256,8 +414,8 @@ jobs:
|
||||
update_pr_description:
|
||||
name: Update PR Description
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
|
||||
needs: [ghcr_build_runtime]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [ghcr_build_runtime_merge]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
15
.github/workflows/lint-fix.yml
vendored
15
.github/workflows/lint-fix.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
lint-fix-frontend:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix frontend linting issues
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -22,13 +22,14 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
- name: Generate i18n and route types
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -58,7 +59,7 @@ jobs:
|
||||
lint-fix-python:
|
||||
if: github.event.label.name == 'lint-fix'
|
||||
name: Fix Python linting issues
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -71,7 +72,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
21
.github/workflows/lint.yml
vendored
21
.github/workflows/lint.yml
vendored
@@ -19,34 +19,35 @@ jobs:
|
||||
# Run lint on the frontend code
|
||||
lint-frontend:
|
||||
name: Lint frontend
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm install --frozen-lockfile
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
- name: Lint, TypeScript compilation, and translation checks
|
||||
run: |
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run make-i18n && tsc
|
||||
npm run make-i18n && npx tsc
|
||||
npm run check-translation-completeness
|
||||
|
||||
# Run lint on the python code
|
||||
lint-python:
|
||||
name: Lint python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
@@ -57,13 +58,13 @@ jobs:
|
||||
|
||||
lint-enterprise-python:
|
||||
name: Lint enterprise python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
4
.github/workflows/npm-publish-ui.yml
vendored
4
.github/workflows/npm-publish-ui.yml
vendored
@@ -18,7 +18,7 @@ concurrency:
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check if version has changed
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should-publish == 'true'
|
||||
defaults:
|
||||
|
||||
12
.github/workflows/openhands-resolver.yml
vendored
12
.github/workflows/openhands-resolver.yml
vendored
@@ -192,7 +192,7 @@ jobs:
|
||||
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Comment on issue with start message
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
script: |
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Install OpenHands
|
||||
id: install_openhands
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body || '' }}
|
||||
REVIEW_BODY: ${{ github.event.review.body || '' }}
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload output.jsonl as artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() # Upload even if the previous steps fail
|
||||
with:
|
||||
name: resolver-output
|
||||
@@ -305,7 +305,7 @@ jobs:
|
||||
|
||||
# Step leaves comment for when agent is invoked on PR
|
||||
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
if: always()
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
@@ -341,7 +341,7 @@ jobs:
|
||||
|
||||
# Step leaves comment for when agent is invoked on issue
|
||||
- name: Comment on issue # Comment link to either PR or branch created by agent
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
@@ -416,7 +416,7 @@ jobs:
|
||||
|
||||
# Leave error comment when both PR/Issue comment handling fail
|
||||
- name: Fallback Error Comment
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
|
||||
8
.github/workflows/pr-artifacts.yml
vendored
8
.github/workflows/pr-artifacts.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
echo "is_fork=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Update PR comment after cleanup
|
||||
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Check for .pr/ directory
|
||||
id: check
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
|
||||
4
.github/workflows/pr-review-evaluation.yml
vendored
4
.github/workflows/pr-review-evaluation.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
# Always checkout main branch for security - cannot test script changes in PRs
|
||||
- name: Checkout extensions repository
|
||||
if: steps.check-trace.outputs.trace_exists == 'true'
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: OpenHands/extensions
|
||||
path: extensions
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
--trace-file trace-info/laminar_trace_info.json
|
||||
|
||||
- name: Upload evaluation logs
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() && steps.check-trace.outputs.trace_exists == 'true'
|
||||
with:
|
||||
name: pr-review-evaluation-${{ github.event.pull_request.number }}
|
||||
|
||||
16
.github/workflows/py-tests.yml
vendored
16
.github/workflows/py-tests.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
# Run python tests on Linux
|
||||
test-on-linux:
|
||||
name: Python Tests on Linux
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
INSTALL_DOCKER: "0" # Set to '0' to skip Docker installation
|
||||
strategy:
|
||||
@@ -37,13 +37,15 @@ jobs:
|
||||
- name: Install tmux
|
||||
run: sudo apt-get update && sudo apt-get install -y tmux
|
||||
- name: Setup Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.x"
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -63,7 +65,7 @@ jobs:
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-openhands
|
||||
path: |
|
||||
@@ -73,7 +75,7 @@ jobs:
|
||||
|
||||
test-enterprise:
|
||||
name: Enterprise Python Unit Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
@@ -82,7 +84,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -95,7 +97,7 @@ jobs:
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-enterprise
|
||||
path: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
|
||||
4
.github/workflows/pypi-release.yml
vendored
4
.github/workflows/pypi-release.yml
vendored
@@ -17,14 +17,14 @@ on:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli' and don't start with 'cloud-'
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: useblacksmith/setup-python@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install Poetry
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
if: github.repository == 'OpenHands/OpenHands'
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
|
||||
2
.github/workflows/ui-build.yml
vendored
2
.github/workflows/ui-build.yml
vendored
@@ -19,7 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
ui-build:
|
||||
name: Build openhands-ui
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if welcome comment already exists
|
||||
id: check_comment
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
result-encoding: string
|
||||
script: |
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Leave welcome comment
|
||||
if: steps.check_comment.outputs.result == 'false'
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const repoUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}`;
|
||||
|
||||
15
README.md
15
README.md
@@ -86,8 +86,19 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
|
||||
|
||||
<hr>
|
||||
|
||||
### Thank You to Our Contributors
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/OpenHands/OpenHands/graphs/contributors)
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
### Trusted by Engineers at
|
||||
|
||||
<div align="center">
|
||||
<strong>Trusted by engineers at</strong>
|
||||
<br/><br/>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
|
||||
@@ -138,3 +149,5 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
|
||||
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -20,9 +20,11 @@ ENV POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=1 \
|
||||
POETRY_CACHE_DIR=/tmp/poetry_cache
|
||||
|
||||
# Pin Poetry version to match the version used to generate poetry.lock
|
||||
ARG POETRY_VERSION=2.3.3
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl make git build-essential jq gettext \
|
||||
&& python3 -m pip install poetry --break-system-packages
|
||||
&& python3 -m pip install "poetry==${POETRY_VERSION}" --break-system-packages
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN touch README.md
|
||||
|
||||
@@ -8,18 +8,18 @@ push=0
|
||||
load=0
|
||||
tag_suffix=""
|
||||
dry_run=0
|
||||
platform_override=""
|
||||
arch_suffix=""
|
||||
|
||||
# Function to display usage information
|
||||
usage() {
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [-p <platform>] [--dry]"
|
||||
echo "Usage: $0 -i <image_name> [-o <org_name>] [--push] [--load] [-t <tag_suffix>] [--dry] [--arch <arch>]"
|
||||
echo " -i: Image name (required)"
|
||||
echo " -o: Organization name"
|
||||
echo " --push: Push the image"
|
||||
echo " --load: Load the image"
|
||||
echo " -t: Tag suffix"
|
||||
echo " -p: Platform(s) to build for (e.g. linux/amd64 or linux/amd64,linux/arm64)"
|
||||
echo " --dry: Don't build, only create build-args.json"
|
||||
echo " --arch: Architecture suffix (e.g. amd64 or arm64). Appends -<arch> to tags and forces single-platform build"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ while [[ $# -gt 0 ]]; do
|
||||
--push) push=1; shift ;;
|
||||
--load) load=1; shift ;;
|
||||
-t) tag_suffix="$2"; shift 2 ;;
|
||||
-p) platform_override="$2"; shift 2 ;;
|
||||
--dry) dry_run=1; shift ;;
|
||||
--arch) arch_suffix="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
@@ -78,7 +78,7 @@ if [[ -n $tag_suffix ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Tags: ${tags[@]}"
|
||||
echo "Tags (before arch suffix): ${tags[@]}"
|
||||
|
||||
if [[ "$image_name" == "openhands" ]]; then
|
||||
dir="./containers/app"
|
||||
@@ -113,10 +113,21 @@ if [[ -n "$DOCKER_IMAGE_TAG" ]]; then
|
||||
tags+=("$DOCKER_IMAGE_TAG")
|
||||
fi
|
||||
|
||||
# Apply architecture suffix for split-arch builds (after all tags are collected)
|
||||
if [[ -n "$arch_suffix" ]]; then
|
||||
cache_tag+="-${arch_suffix}"
|
||||
for i in "${!tags[@]}"; do
|
||||
tags[$i]="${tags[$i]}-${arch_suffix}"
|
||||
done
|
||||
# Force single-platform build for this architecture
|
||||
arch_platform="linux/${arch_suffix}"
|
||||
fi
|
||||
|
||||
DOCKER_REPOSITORY="$DOCKER_REGISTRY/$DOCKER_ORG/$DOCKER_IMAGE"
|
||||
DOCKER_REPOSITORY=${DOCKER_REPOSITORY,,} # lowercase
|
||||
echo "Repo: $DOCKER_REPOSITORY"
|
||||
echo "Base dir: $DOCKER_BASE_DIR"
|
||||
echo "Tags: ${tags[@]}"
|
||||
|
||||
args=""
|
||||
full_tags=()
|
||||
@@ -125,7 +136,6 @@ for tag in "${tags[@]}"; do
|
||||
full_tags+=("$DOCKER_REPOSITORY:$tag")
|
||||
done
|
||||
|
||||
|
||||
if [[ $push -eq 1 ]]; then
|
||||
args+=" --push"
|
||||
args+=" --cache-to=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag,mode=max"
|
||||
@@ -138,8 +148,8 @@ fi
|
||||
echo "Args: $args"
|
||||
|
||||
# Determine the platform(s) to build for
|
||||
if [[ -n "$platform_override" ]]; then
|
||||
platform="$platform_override"
|
||||
if [[ -n "$arch_platform" ]]; then
|
||||
platform="$arch_platform"
|
||||
elif [[ $load -eq 1 ]]; then
|
||||
# When loading, build only for the current platform
|
||||
platform=$(docker version -f '{{.Server.Os}}/{{.Server.Arch}}')
|
||||
@@ -149,13 +159,24 @@ else
|
||||
fi
|
||||
if [[ $dry_run -eq 1 ]]; then
|
||||
echo "Dry Run is enabled. Writing build config to docker-build-dry.json"
|
||||
# Compute base tags (arch suffix stripped) for use by merge jobs
|
||||
base_tags=()
|
||||
for ftag in "${full_tags[@]}"; do
|
||||
if [[ -n "$arch_suffix" ]]; then
|
||||
base_tags+=("${ftag%-${arch_suffix}}")
|
||||
else
|
||||
base_tags+=("$ftag")
|
||||
fi
|
||||
done
|
||||
jq -n \
|
||||
--argjson tags "$(printf '%s\n' "${full_tags[@]}" | jq -R . | jq -s .)" \
|
||||
--argjson base_tags "$(printf '%s\n' "${base_tags[@]}" | jq -R . | jq -s .)" \
|
||||
--arg platform "$platform" \
|
||||
--arg openhands_build_version "$OPENHANDS_BUILD_VERSION" \
|
||||
--arg dockerfile "$dir/Dockerfile" \
|
||||
'{
|
||||
tags: $tags,
|
||||
base_tags: $base_tags,
|
||||
platform: $platform,
|
||||
build_args: [
|
||||
"OPENHANDS_BUILD_VERSION=" + $openhands_build_version
|
||||
@@ -174,7 +195,7 @@ docker buildx build \
|
||||
$args \
|
||||
--build-arg OPENHANDS_BUILD_VERSION="$OPENHANDS_BUILD_VERSION" \
|
||||
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag \
|
||||
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:$cache_tag_base-main \
|
||||
--cache-from=type=registry,ref=$DOCKER_REPOSITORY:${cache_tag_base}-main${arch_suffix:+-${arch_suffix}} \
|
||||
--platform $platform \
|
||||
--provenance=false \
|
||||
-f "$dir/Dockerfile" \
|
||||
|
||||
@@ -58,6 +58,8 @@ repos:
|
||||
types-Markdown,
|
||||
pydantic,
|
||||
lxml,
|
||||
"openhands-sdk==1.17.0",
|
||||
"openhands-tools==1.17.0",
|
||||
]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
|
||||
@@ -14,3 +14,11 @@ exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openhands.memory.condenser.impl.*]
|
||||
disable_error_code = override
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
ignore_missing_imports = True
|
||||
|
||||
[mypy-litellm.*]
|
||||
follow_imports = skip
|
||||
ignore_missing_imports = True
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# PolyForm Free Trial License 1.0.0
|
||||
|
||||
Copyright (c) 2026 All Hands AI
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
|
||||
@@ -59,7 +59,7 @@ handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = DEBUG
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
|
||||
@@ -106,16 +106,18 @@ async def summarize_issue_solvability(
|
||||
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
if user_settings.llm_api_key is None:
|
||||
agent_settings = user_settings.agent_settings
|
||||
llm_settings = agent_settings.llm
|
||||
if llm_settings.api_key is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
try:
|
||||
llm_config = LLMConfig(
|
||||
model=user_settings.llm_model,
|
||||
api_key=user_settings.llm_api_key.get_secret_value(),
|
||||
base_url=user_settings.llm_base_url,
|
||||
model=llm_settings.model,
|
||||
api_key=llm_settings.api_key.get_secret_value(),
|
||||
base_url=llm_settings.base_url,
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
|
||||
@@ -10,6 +10,7 @@ from integrations.github.github_types import (
|
||||
)
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
@@ -26,6 +27,7 @@ from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.org_store import OrgStore
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
@@ -41,16 +43,14 @@ from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.services.conversation_service import start_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
@@ -154,12 +154,17 @@ class GithubIssue(ResolverViewInterface):
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='github',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
@@ -173,16 +178,28 @@ class GithubIssue(ResolverViewInterface):
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
@@ -294,7 +311,10 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
github_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -322,7 +342,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
'full_repo_name': self.full_repo_name,
|
||||
'installation_id': self.installation_id,
|
||||
},
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
@@ -476,7 +496,7 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
'comment_id': self.comment_id,
|
||||
},
|
||||
inline_pr_comment=True,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_V1_GITLAB_RESOLVER,
|
||||
@@ -14,6 +15,7 @@ from integrations.utils import (
|
||||
from jinja2 import Environment
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
@@ -29,15 +31,13 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.services.conversation_service import start_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
CONFIDENTIAL_NOTE = 'confidential_note'
|
||||
@@ -118,6 +118,14 @@ class GitlabIssue(ResolverViewInterface):
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# v1_enabled is already set at construction time in the factory method
|
||||
# This is the source of truth for the conversation type
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='gitlab',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversation metadata
|
||||
# Don't save to conversation store
|
||||
@@ -128,16 +136,28 @@ class GitlabIssue(ResolverViewInterface):
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITLAB,
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
@@ -228,7 +248,10 @@ class GitlabIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitLab user context for the V1 system
|
||||
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
gitlab_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -260,7 +283,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
'is_mr': self.is_mr,
|
||||
'discussion_id': getattr(self, 'discussion_id', None),
|
||||
},
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -24,20 +24,20 @@ from integrations.jira.jira_types import (
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.jira.jira_view import JiraFactory, JiraNewConversationView
|
||||
from integrations.jira.jira_view import JiraFactory
|
||||
from integrations.manager import Manager
|
||||
from integrations.models import Message
|
||||
from integrations.utils import (
|
||||
HOST,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
format_jira_comment_body,
|
||||
get_oh_labels,
|
||||
get_session_expired_message,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.utils.conversation_callback_utils import register_callback_processor
|
||||
from storage.jira_integration_store import JiraIntegrationStore
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
@@ -259,11 +259,6 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
|
||||
async def start_job(self, view: JiraViewInterface) -> None:
|
||||
"""Start a Jira job/conversation."""
|
||||
# Import here to prevent circular import
|
||||
from server.conversation_callback_processor.jira_callback_processor import (
|
||||
JiraCallbackProcessor,
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
'[Jira] Starting job',
|
||||
@@ -285,19 +280,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
},
|
||||
)
|
||||
|
||||
# Register callback processor for updates
|
||||
if isinstance(view, JiraNewConversationView):
|
||||
processor = JiraCallbackProcessor(
|
||||
issue_key=view.payload.issue_key,
|
||||
workspace_name=view.jira_workspace.name,
|
||||
)
|
||||
register_callback_processor(conversation_id, processor)
|
||||
logger.info(
|
||||
'[Jira] Callback processor registered',
|
||||
extra={'conversation_id': conversation_id},
|
||||
)
|
||||
|
||||
# Send success response
|
||||
# Create success message
|
||||
msg_info = view.get_response_msg()
|
||||
|
||||
except MissingSettingsError as e:
|
||||
@@ -359,7 +342,7 @@ class JiraManager(Manager[JiraViewInterface]):
|
||||
url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
|
||||
)
|
||||
data = {'body': message}
|
||||
data = format_jira_comment_body(message)
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(
|
||||
url, auth=(svc_acc_email, svc_acc_api_key), json=data
|
||||
|
||||
@@ -136,11 +136,10 @@ class JiraPayloadParser:
|
||||
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
|
||||
]
|
||||
labels = set()
|
||||
for item in items:
|
||||
if item.get('field') == 'labels' and item.get('toString'):
|
||||
labels.update(item['toString'].split())
|
||||
|
||||
if self.oh_label not in labels:
|
||||
return JiraPayloadSkipped(
|
||||
|
||||
238
enterprise/integrations/jira/jira_v1_callback_processor.py
Normal file
238
enterprise/integrations/jira/jira_v1_callback_processor.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from integrations.utils import format_jira_comment_body, get_summary_instruction
|
||||
from pydantic import Field
|
||||
|
||||
from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
|
||||
from openhands.app_server.event_callback.event_callback_models import (
|
||||
EventCallback,
|
||||
EventCallbackProcessor,
|
||||
)
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResult,
|
||||
EventCallbackResultStatus,
|
||||
)
|
||||
from openhands.app_server.event_callback.util import (
|
||||
ensure_conversation_found,
|
||||
ensure_running_sandbox,
|
||||
get_agent_server_url_from_sandbox,
|
||||
)
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.event import ConversationStateUpdateEvent
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
|
||||
class JiraV1CallbackProcessor(EventCallbackProcessor):
|
||||
"""Callback processor for Jira V1 integrations."""
|
||||
|
||||
should_request_summary: bool = Field(default=True)
|
||||
svc_acc_email: str
|
||||
decrypted_api_key: str
|
||||
issue_key: str
|
||||
jira_cloud_id: str
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
conversation_id: UUID,
|
||||
callback: EventCallback,
|
||||
event: Event,
|
||||
) -> EventCallbackResult | None:
|
||||
"""Process events for Jira V1 integration."""
|
||||
# Only handle ConversationStateUpdateEvent for execution_status
|
||||
if not isinstance(event, ConversationStateUpdateEvent):
|
||||
return None
|
||||
|
||||
if event.key != 'execution_status':
|
||||
return None
|
||||
|
||||
_logger.info('[Jira] Callback agent state was %s', event)
|
||||
|
||||
# Only request summary when execution has finished successfully
|
||||
if event.value != 'finished':
|
||||
return None
|
||||
|
||||
_logger.info('[Jira] Should request summary: %s', self.should_request_summary)
|
||||
|
||||
if not self.should_request_summary:
|
||||
return None
|
||||
|
||||
self.should_request_summary = False
|
||||
|
||||
try:
|
||||
_logger.info(f'[Jira] Requesting summary {conversation_id}')
|
||||
summary = await self._request_summary(conversation_id)
|
||||
_logger.info(
|
||||
f'[Jira] Posting summary {conversation_id}',
|
||||
extra={'summary': summary},
|
||||
)
|
||||
await self._post_summary_to_jira(summary)
|
||||
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.SUCCESS,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=summary,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception(f'[Jira] Failed to post summary: {e}', stack_info=True)
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.ERROR,
|
||||
event_callback_id=callback.id,
|
||||
event_id=event.id,
|
||||
conversation_id=conversation_id,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
async def _request_summary(self, conversation_id: UUID) -> str:
|
||||
"""Ask the agent to produce a summary of its work and return the agent response."""
|
||||
# Import services within the method to avoid circular imports
|
||||
from openhands.app_server.config import (
|
||||
get_app_conversation_info_service,
|
||||
get_httpx_client,
|
||||
get_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import (
|
||||
ADMIN,
|
||||
USER_CONTEXT_ATTR,
|
||||
)
|
||||
|
||||
# Create injector state for dependency injection
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
async with (
|
||||
get_app_conversation_info_service(state) as app_conversation_info_service,
|
||||
get_sandbox_service(state) as sandbox_service,
|
||||
get_httpx_client(state) as httpx_client,
|
||||
):
|
||||
# 1. Conversation lookup
|
||||
app_conversation_info = ensure_conversation_found(
|
||||
await app_conversation_info_service.get_app_conversation_info(
|
||||
conversation_id
|
||||
),
|
||||
conversation_id,
|
||||
)
|
||||
|
||||
# 2. Sandbox lookup + validation
|
||||
sandbox = ensure_running_sandbox(
|
||||
await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
|
||||
app_conversation_info.sandbox_id,
|
||||
)
|
||||
|
||||
assert (
|
||||
sandbox.session_api_key is not None
|
||||
), f'No session API key for sandbox: {sandbox.id}'
|
||||
|
||||
# 3. URL + instruction
|
||||
agent_server_url = get_agent_server_url_from_sandbox(sandbox)
|
||||
|
||||
# Prepare message based on agent state
|
||||
message_content = get_summary_instruction()
|
||||
|
||||
# Ask the agent and return the response text
|
||||
return await self._ask_question(
|
||||
httpx_client=httpx_client,
|
||||
agent_server_url=agent_server_url,
|
||||
conversation_id=conversation_id,
|
||||
session_api_key=sandbox.session_api_key,
|
||||
message_content=message_content,
|
||||
)
|
||||
|
||||
async def _ask_question(
|
||||
self,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
agent_server_url: str,
|
||||
conversation_id: UUID,
|
||||
session_api_key: str,
|
||||
message_content: str,
|
||||
) -> str:
|
||||
"""Send a message to the agent server via the V1 API and return response text."""
|
||||
send_message_request = AskAgentRequest(question=message_content)
|
||||
|
||||
url = (
|
||||
f"{agent_server_url.rstrip('/')}"
|
||||
f"/api/conversations/{conversation_id}/ask_agent"
|
||||
)
|
||||
headers = {'X-Session-API-Key': session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
|
||||
try:
|
||||
response = await httpx_client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
agent_response = AskAgentResponse.model_validate(response.json())
|
||||
return agent_response.response
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = f'HTTP {e.response.status_code} error'
|
||||
try:
|
||||
error_body = e.response.text
|
||||
if error_body:
|
||||
error_detail += f': {error_body}'
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_logger.exception(
|
||||
'[Jira] HTTP error sending message to %s: %s. '
|
||||
'Request payload: %s. Response headers: %s',
|
||||
url,
|
||||
error_detail,
|
||||
payload,
|
||||
dict(e.response.headers),
|
||||
stack_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_detail = f'Request timeout after 30 seconds to {url}'
|
||||
_logger.exception(
|
||||
'[Jira] Timeout error: %s. Request payload: %s',
|
||||
error_detail,
|
||||
payload,
|
||||
stack_info=True,
|
||||
)
|
||||
raise Exception(f'Failed to send message to agent server: {error_detail}')
|
||||
|
||||
async def _post_summary_to_jira(self, summary: str):
|
||||
"""Post the summary back to the Jira issue."""
|
||||
if not all(
|
||||
[
|
||||
self.svc_acc_email,
|
||||
self.decrypted_api_key,
|
||||
self.issue_key,
|
||||
self.jira_cloud_id,
|
||||
]
|
||||
):
|
||||
_logger.warning('[Jira] Missing required data for posting summary')
|
||||
return
|
||||
|
||||
# Add a comment to the Jira issue with the summary
|
||||
comment_url = (
|
||||
f'{JIRA_CLOUD_API_URL}/{self.jira_cloud_id}'
|
||||
f'/rest/api/2/issue/{self.issue_key}/comment'
|
||||
)
|
||||
|
||||
message = f'OpenHands resolved this issue:\n\n{summary}'
|
||||
comment_body = format_jira_comment_body(message)
|
||||
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(
|
||||
comment_url,
|
||||
auth=(self.svc_acc_email, self.decrypted_api_key),
|
||||
json=comment_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
_logger.info(f'[Jira] Posted summary to {self.issue_key}')
|
||||
@@ -7,6 +7,7 @@ Views are responsible for:
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
@@ -15,18 +16,37 @@ from integrations.jira.jira_types import (
|
||||
RepositoryNotFoundError,
|
||||
StartingConvoException,
|
||||
)
|
||||
from integrations.utils import CONVERSATION_URL, infer_repo_from_message
|
||||
from integrations.jira.jira_v1_callback_processor import (
|
||||
JiraV1CallbackProcessor,
|
||||
)
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
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
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import create_new_conversation
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
@@ -46,7 +66,7 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
saas_user_auth: UserAuth
|
||||
jira_user: JiraUser
|
||||
jira_workspace: JiraWorkspace
|
||||
selected_repo: str | None = None
|
||||
selected_repo: str = ''
|
||||
conversation_id: str = ''
|
||||
|
||||
# Lazy-loaded issue details (cached after first fetch)
|
||||
@@ -56,6 +76,9 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
# Decrypted API key (set by factory)
|
||||
_decrypted_api_key: str = field(default='', repr=False)
|
||||
|
||||
# Resolved org ID for V1 conversations
|
||||
resolved_org_id: UUID | None = None
|
||||
|
||||
async def get_issue_details(self) -> tuple[str, str]:
|
||||
"""Fetch issue details from Jira API (cached after first call).
|
||||
|
||||
@@ -161,56 +184,131 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
jira_conversation = JiraConversation(
|
||||
conversation_id=self.conversation_id,
|
||||
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)
|
||||
|
||||
conversation_metadata = await self._create_v1_metadata()
|
||||
await self._create_v1_conversation(jinja_env, conversation_metadata)
|
||||
return self.conversation_id
|
||||
|
||||
async def _create_v1_metadata(self) -> ConversationMetadata:
|
||||
"""Create conversation metadata for V1 conversations.
|
||||
|
||||
The JiraConversation mapping is saved to the integration store (above), but
|
||||
V1 conversation metadata is managed by the app conversation system, not
|
||||
the legacy conversation store.
|
||||
"""
|
||||
logger.info('[Jira]: Creating V1 metadata')
|
||||
|
||||
# Generate a dummy conversation for V1 (not saved to store)
|
||||
self.conversation_id = uuid4().hex
|
||||
self.resolved_org_id = await self._get_resolved_org_id()
|
||||
|
||||
return ConversationMetadata(
|
||||
conversation_id=self.conversation_id,
|
||||
selected_repository=self.selected_repo,
|
||||
)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[Jira]: Creating V1 conversation')
|
||||
|
||||
initial_user_text = await self._get_v1_initial_user_message(jinja_env)
|
||||
|
||||
# Create the initial message request
|
||||
initial_message = SendMessageRequest(
|
||||
role='user', content=[TextContent(text=initial_user_text)]
|
||||
)
|
||||
|
||||
# Create the Jira V1 callback processor
|
||||
jira_callback_processor = self._create_jira_v1_callback_processor()
|
||||
|
||||
injector_state = InjectorState()
|
||||
|
||||
# Create the V1 conversation start request
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
system_message_suffix=None,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
title=f'Jira Issue {self.payload.issue_key}: {self._issue_title or "Unknown"}',
|
||||
trigger=ConversationTrigger.JIRA,
|
||||
processors=[jira_callback_processor],
|
||||
)
|
||||
|
||||
# Set up the Jira user context for the V1 system
|
||||
jira_user_context = ResolverUserContext(
|
||||
saas_user_auth=self.saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, jira_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
injector_state
|
||||
) as app_conversation_service:
|
||||
async for task in app_conversation_service.start_app_conversation(
|
||||
start_request
|
||||
):
|
||||
if task.status == AppConversationStartTaskStatus.ERROR:
|
||||
logger.error(f'Failed to start V1 conversation: {task.detail}')
|
||||
raise RuntimeError(
|
||||
f'Failed to start V1 conversation: {task.detail}'
|
||||
)
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
"""Build the initial user message for V1 resolver conversations."""
|
||||
issue_title, issue_description = await self.get_issue_details()
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_new_conversation.j2')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.payload.issue_key,
|
||||
issue_title=issue_title,
|
||||
issue_description=issue_description,
|
||||
user_message=self.payload.user_msg,
|
||||
)
|
||||
|
||||
return user_msg
|
||||
|
||||
def _create_jira_v1_callback_processor(self):
|
||||
"""Create a V1 callback processor for Jira integration."""
|
||||
return JiraV1CallbackProcessor(
|
||||
svc_acc_email=self.jira_workspace.svc_acc_email,
|
||||
decrypted_api_key=self._decrypted_api_key,
|
||||
issue_key=self.payload.issue_key,
|
||||
jira_cloud_id=self.jira_workspace.jira_cloud_id,
|
||||
)
|
||||
|
||||
async def _get_resolved_org_id(self) -> UUID | None:
|
||||
"""Resolve the org ID for V1 conversations."""
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
if not provider_tokens:
|
||||
return None
|
||||
|
||||
try:
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.jira_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_msg,
|
||||
conversation_instructions=instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.JIRA,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
resolved_org_id = await resolve_org_for_repo(
|
||||
provider=repository.git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=self.jira_user.keycloak_user_id,
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.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.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
|
||||
|
||||
return resolved_org_id
|
||||
except Exception as e:
|
||||
if isinstance(e, StartingConvoException):
|
||||
raise
|
||||
logger.error(
|
||||
'[Jira] Failed to create conversation',
|
||||
extra={'issue_key': self.payload.issue_key, 'error': str(e)},
|
||||
exc_info=True,
|
||||
logger.warning(
|
||||
f'[Jira] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
)
|
||||
raise StartingConvoException(f'Failed to create conversation: {str(e)}')
|
||||
return None
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
"""Get the response message to send back to Jira."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from integrations.utils import (
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
filter_potential_repos_by_user_msg,
|
||||
get_session_expired_message,
|
||||
markdown_to_jira_markup,
|
||||
)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.auth.saas_user_auth import get_user_auth_from_keycloak_id
|
||||
@@ -468,7 +469,8 @@ class JiraDcManager(Manager[JiraDcViewInterface]):
|
||||
"""
|
||||
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
|
||||
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
|
||||
data = {'body': message}
|
||||
# Convert standard Markdown to Jira Wiki Markup for proper rendering
|
||||
data = {'body': markdown_to_jira_markup(message)}
|
||||
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
||||
response = await client.post(url, headers=headers, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from integrations.linear.linear_types import LinearViewInterface, StartingConvoException
|
||||
from integrations.models import JobContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.utils import CONVERSATION_URL, get_final_agent_observation
|
||||
from jinja2 import Environment
|
||||
from server.config import get_config
|
||||
from storage.linear_conversation import LinearConversation
|
||||
from storage.linear_integration_store import LinearIntegrationStore
|
||||
from storage.linear_user import LinearUser
|
||||
from storage.linear_workspace import LinearWorkspace
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
integration_store = LinearIntegrationStore.get_instance()
|
||||
|
||||
@@ -61,20 +70,70 @@ class LinearNewConversationView(LinearViewInterface):
|
||||
instructions, user_msg = await self._get_instructions(jinja_env)
|
||||
|
||||
try:
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.linear_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_msg,
|
||||
conversation_instructions=instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.LINEAR,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
user_id = self.linear_user.keycloak_user_id
|
||||
|
||||
# Resolve git provider from repository
|
||||
resolved_git_provider = None
|
||||
if provider_tokens:
|
||||
try:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(
|
||||
self.selected_repo
|
||||
)
|
||||
resolved_git_provider = repository.git_provider
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Linear] Failed to resolve git provider for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
resolved_org_id = None
|
||||
if resolved_git_provider and self.selected_repo:
|
||||
try:
|
||||
resolved_org_id = await resolve_org_for_repo(
|
||||
provider=resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Linear] Failed to resolve org for {self.selected_repo}: {e}'
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
user_id,
|
||||
resolved_org_id,
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.LINEAR,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_msg,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=instructions,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
|
||||
logger.info(f'[Linear] Created conversation {self.conversation_id}')
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.service_types import ProviderType, UserGitInfo
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
@@ -12,8 +14,10 @@ class ResolverUserContext(UserContext):
|
||||
def __init__(
|
||||
self,
|
||||
saas_user_auth: UserAuth,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
self.resolver_org_id = resolver_org_id
|
||||
self._provider_handler: ProviderHandler | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
@@ -81,3 +85,6 @@ class ResolverUserContext(UserContext):
|
||||
|
||||
async def get_mcp_api_key(self) -> str | None:
|
||||
return await self.saas_user_auth.get_mcp_api_key()
|
||||
|
||||
async def get_user_git_info(self) -> UserGitInfo | None:
|
||||
return await self.saas_user_auth.get_user_git_info()
|
||||
|
||||
68
enterprise/integrations/resolver_org_router.py
Normal file
68
enterprise/integrations/resolver_org_router.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
|
||||
|
||||
This module provides a reusable utility for routing resolver conversations
|
||||
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
|
||||
workspace based on claimed Git organizations.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
async def resolve_org_for_repo(
|
||||
provider: str,
|
||||
full_repo_name: str,
|
||||
keycloak_user_id: str,
|
||||
) -> UUID | None:
|
||||
"""Determine the OpenHands org_id for a resolver conversation.
|
||||
|
||||
If the repo's git organization is claimed by an OpenHands org AND the user
|
||||
is a member of that org, returns the claiming org's ID. Otherwise returns
|
||||
None (caller should fall back to user.current_org_id / personal workspace).
|
||||
|
||||
Args:
|
||||
provider: Git provider name ("github", "gitlab", "bitbucket")
|
||||
full_repo_name: Full repository name (e.g., "OpenHands/foo")
|
||||
keycloak_user_id: The user's Keycloak UUID string
|
||||
|
||||
Returns:
|
||||
The org_id if the repo's org is claimed and user is a member, else None
|
||||
"""
|
||||
git_org = full_repo_name.split('/')[0].lower()
|
||||
|
||||
try:
|
||||
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider, git_org
|
||||
)
|
||||
if not claim:
|
||||
logger.debug(
|
||||
f'[OrgResolver] No claim found for {provider}/{git_org}',
|
||||
)
|
||||
return None
|
||||
|
||||
member = await OrgMemberStore.get_org_member(
|
||||
claim.org_id, UUID(keycloak_user_id)
|
||||
)
|
||||
if not member:
|
||||
logger.debug(
|
||||
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
|
||||
f'{claim.org_id} (claimed {provider}/{git_org}). '
|
||||
f'Falling back to personal workspace.',
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f'[OrgResolver] Routing conversation to org {claim.org_id} '
|
||||
f'for {provider}/{git_org} (user {keycloak_user_id})',
|
||||
)
|
||||
return claim.org_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
@@ -239,12 +239,14 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
def _generate_repo_selection_form(
|
||||
self, message_ts: str, thread_ts: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Generate a repo selection form using external_select for dynamic loading.
|
||||
"""Generate a repo selection form with immediate "No Repository" button and search dropdown.
|
||||
|
||||
This uses Slack's external_select element which allows:
|
||||
- Type-ahead search for repositories
|
||||
- Dynamic loading of options from an external endpoint
|
||||
- Support for users with many repositories (no 100 option limit)
|
||||
This form provides two options side-by-side:
|
||||
1. A "No Repository" button - immediately clickable without any loading
|
||||
2. An external_select dropdown - for searching repositories dynamically
|
||||
|
||||
This design ensures "No Repository" is always immediately available while
|
||||
still providing full dynamic search capability for repositories.
|
||||
|
||||
Args:
|
||||
message_ts: The message timestamp for tracking
|
||||
@@ -266,12 +268,22 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'type': 'section',
|
||||
'text': {
|
||||
'type': 'mrkdwn',
|
||||
'text': 'Type to search your repositories:',
|
||||
'text': 'Select a repository or continue without one:',
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'actions',
|
||||
'elements': [
|
||||
{
|
||||
'type': 'button',
|
||||
'action_id': f'no_repository:{message_ts}:{thread_ts}',
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
'text': 'No Repository',
|
||||
'emoji': True,
|
||||
},
|
||||
'value': '-',
|
||||
},
|
||||
{
|
||||
'type': 'external_select',
|
||||
'action_id': f'repository_select:{message_ts}:{thread_ts}',
|
||||
@@ -279,8 +291,8 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'type': 'plain_text',
|
||||
'text': 'Search repositories...',
|
||||
},
|
||||
'min_query_length': 0, # Load initial options immediately
|
||||
}
|
||||
'min_query_length': 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -288,8 +300,11 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
def _build_repo_options(self, repos: list[Repository]) -> list[dict[str, Any]]:
|
||||
"""Build Slack options list from repositories.
|
||||
|
||||
Always includes a "No Repository" option at the top, followed by up to 99
|
||||
repositories (Slack has a 100 option limit for external_select).
|
||||
Returns up to 100 repositories formatted as Slack options
|
||||
(Slack has a 100 option limit for external_select).
|
||||
|
||||
Note: "No Repository" is handled by a separate button in the form,
|
||||
so it's not included in the dropdown options.
|
||||
|
||||
Args:
|
||||
repos: List of Repository objects
|
||||
@@ -297,13 +312,7 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
Returns:
|
||||
List of Slack option objects
|
||||
"""
|
||||
options: list[dict[str, Any]] = [
|
||||
{
|
||||
'text': {'type': 'plain_text', 'text': 'No Repository'},
|
||||
'value': '-',
|
||||
}
|
||||
]
|
||||
options.extend(
|
||||
return [
|
||||
{
|
||||
'text': {
|
||||
'type': 'plain_text',
|
||||
@@ -311,9 +320,8 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
},
|
||||
'value': repo.full_name,
|
||||
}
|
||||
for repo in repos[:99] # Leave room for "No Repository" option
|
||||
)
|
||||
return options
|
||||
for repo in repos[:100]
|
||||
]
|
||||
|
||||
async def search_repos_for_slack(
|
||||
self, user_auth: UserAuth, query: str, per_page: int = 20
|
||||
@@ -363,33 +371,69 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
SlackError(SlackErrorCode.UNEXPECTED_ERROR),
|
||||
)
|
||||
|
||||
async def receive_form_interaction(self, slack_payload: dict):
|
||||
"""Process a Slack form interaction (repository selection).
|
||||
def _parse_form_action(self, action: dict) -> tuple[str, str | None, str] | None:
|
||||
"""Parse action payload and extract message_ts, thread_ts, and selected value.
|
||||
|
||||
This handles the block_actions payload when a user selects a repository
|
||||
from the dropdown form. It retrieves the original user message from Redis
|
||||
and delegates to receive_message for processing.
|
||||
This handles the different payload structures for button clicks vs dropdown
|
||||
selections in the repository selection form.
|
||||
|
||||
Args:
|
||||
action: The action object from the Slack payload
|
||||
|
||||
Returns:
|
||||
Tuple of (message_ts, thread_ts, selected_value) if action is recognized,
|
||||
None if the action_id is unknown.
|
||||
"""
|
||||
action_id = action['action_id']
|
||||
|
||||
if action_id.startswith('no_repository:'):
|
||||
# Button click - value is in 'value' field
|
||||
attribs = action_id.split('no_repository:')[-1]
|
||||
selected_value = action.get('value', '-')
|
||||
elif action_id.startswith('repository_select:'):
|
||||
# Dropdown selection - value is in 'selected_option'
|
||||
attribs = action_id.split('repository_select:')[-1]
|
||||
selected_value = action['selected_option']['value']
|
||||
else:
|
||||
return None
|
||||
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
|
||||
return message_ts, thread_ts, selected_value
|
||||
|
||||
async def receive_form_interaction(self, slack_payload: dict):
|
||||
"""Process a Slack form interaction (repository selection or button click).
|
||||
|
||||
This handles the block_actions payload when a user interacts with the
|
||||
repository selection form. It can handle:
|
||||
- "No Repository" button click: proceeds with conversation without a repo
|
||||
- Repository selection from dropdown: proceeds with the selected repo
|
||||
|
||||
Args:
|
||||
slack_payload: The raw Slack interaction payload
|
||||
"""
|
||||
# Extract fields from the Slack interaction payload
|
||||
selected_repository = slack_payload['actions'][0]['selected_option']['value']
|
||||
if selected_repository == '-':
|
||||
selected_repository = None
|
||||
|
||||
action = slack_payload['actions'][0]
|
||||
slack_user_id = slack_payload['user']['id']
|
||||
channel_id = slack_payload['container']['channel_id']
|
||||
team_id = slack_payload['team']['id']
|
||||
|
||||
# Get original message_ts and thread_ts from action_id
|
||||
attribs = slack_payload['actions'][0]['action_id'].split('repository_select:')[
|
||||
-1
|
||||
]
|
||||
message_ts, thread_ts = attribs.split(':')
|
||||
thread_ts = None if thread_ts == 'None' else thread_ts
|
||||
# Parse the action to extract message_ts, thread_ts, and selected value
|
||||
parsed = self._parse_form_action(action)
|
||||
if parsed is None:
|
||||
logger.warning(
|
||||
'slack_unknown_action_id',
|
||||
extra={
|
||||
'action_id': action['action_id'],
|
||||
'slack_user_id': slack_user_id,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# Build partial payload for error handling during Redis retrieval
|
||||
message_ts, thread_ts, selected_value = parsed
|
||||
|
||||
# Build partial payload for error handling
|
||||
payload = {
|
||||
'team_id': team_id,
|
||||
'channel_id': channel_id,
|
||||
@@ -398,6 +442,9 @@ class SlackManager(Manager[SlackViewInterface]):
|
||||
'thread_ts': thread_ts,
|
||||
}
|
||||
|
||||
# Convert "-" (No Repository) to None
|
||||
selected_repository = None if selected_value == '-' else selected_value
|
||||
|
||||
# Retrieve the original user message from Redis
|
||||
try:
|
||||
user_msg = await self._retrieve_user_msg_for_form(message_ts, thread_ts)
|
||||
|
||||
@@ -111,9 +111,11 @@ class SlackV1CallbackProcessor(EventCallbackProcessor):
|
||||
|
||||
try:
|
||||
# Post the summary as a threaded reply
|
||||
# Use markdown_text instead of text to properly render standard Markdown
|
||||
# (e.g., **bold**, [link](url)) which is used throughout the codebase
|
||||
response = client.chat_postMessage(
|
||||
channel=channel_id,
|
||||
text=summary,
|
||||
markdown_text=summary,
|
||||
thread_ts=thread_ts,
|
||||
unfurl_links=False,
|
||||
unfurl_media=False,
|
||||
|
||||
@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
@@ -17,7 +18,9 @@ from integrations.utils import (
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from server.config import get_config
|
||||
from slack_sdk import WebClient
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
@@ -36,18 +39,20 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
# =================================================
|
||||
# SECTION: Slack view types
|
||||
@@ -202,6 +207,22 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
|
||||
# Determine git provider from repository (needed for both org routing and conversation creation)
|
||||
self._resolved_git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
self._resolved_git_provider = repository.git_provider
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = None
|
||||
if self._resolved_git_provider and self.selected_repo:
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider=self._resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
)
|
||||
|
||||
# Check if V1 conversations are enabled for this user
|
||||
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
|
||||
self.slack_to_openhands_user.keycloak_user_id
|
||||
@@ -224,30 +245,44 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
jinja
|
||||
)
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = repository.git_provider
|
||||
user_id = self.slack_to_openhands_user.keycloak_user_id
|
||||
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.SLACK,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=self._resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=(
|
||||
conversation_instructions if conversation_instructions else None
|
||||
),
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.SLACK,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
git_provider=git_provider,
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
self.conversation_id = conversation_id
|
||||
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=False)
|
||||
|
||||
@@ -265,13 +300,8 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
# Create the Slack V1 callback processor
|
||||
slack_callback_processor = self._create_slack_v1_callback_processor()
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = ProviderType(repository.git_provider.value)
|
||||
# Use git provider resolved in create_or_update_conversation
|
||||
git_provider = self._resolved_git_provider
|
||||
|
||||
# Get the app conversation service and start the conversation
|
||||
injector_state = InjectorState()
|
||||
@@ -292,7 +322,10 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
# Set up the Slack user context for the V1 system
|
||||
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
|
||||
slack_user_context = ResolverUserContext(
|
||||
saas_user_auth=self.saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
|
||||
@@ -436,12 +436,13 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
r'(?=\s|$|}}|[\]\)\'",.:`])' # right boundary
|
||||
)
|
||||
|
||||
matches: list[str] = []
|
||||
# Use dict to preserve ordering
|
||||
matches: dict[str, bool] = {}
|
||||
|
||||
# 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}')
|
||||
matches[f'{owner}/{repo}'] = True
|
||||
|
||||
# Direct mentions
|
||||
for owner, repo in re.findall(direct_pattern, normalized_msg):
|
||||
@@ -457,9 +458,10 @@ def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
continue
|
||||
|
||||
if full_match not in matches:
|
||||
matches.append(full_match)
|
||||
matches[full_match] = True
|
||||
|
||||
return matches
|
||||
result = list(matches)
|
||||
return result
|
||||
|
||||
|
||||
def filter_potential_repos_by_user_msg(
|
||||
@@ -595,3 +597,18 @@ def markdown_to_jira_markup(markdown_text: str) -> str:
|
||||
# Log the error but don't raise it - return original text as fallback
|
||||
print(f'Error converting markdown to Jira markup: {str(e)}')
|
||||
return markdown_text or ''
|
||||
|
||||
|
||||
def format_jira_comment_body(message: str) -> dict:
|
||||
"""Format a message as a Jira API v2 comment body.
|
||||
|
||||
This helper ensures consistent comment formatting across all Jira integrations.
|
||||
Converts markdown to Jira Wiki Markup and wraps in the expected API structure.
|
||||
|
||||
Args:
|
||||
message: The message content to send (may contain markdown)
|
||||
|
||||
Returns:
|
||||
dict: The comment body in Jira API v2 format {'body': ...}
|
||||
"""
|
||||
return {'body': markdown_to_jira_markup(message)}
|
||||
|
||||
@@ -6,6 +6,12 @@ from logging.config import fileConfig
|
||||
# These plugin setup messages would otherwise appear before logging is configured
|
||||
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
|
||||
|
||||
# Prevent SQLAlchemy engine from logging SQL results at DEBUG level, which can
|
||||
# leak sensitive column data (e.g. API keys, tokens) into log aggregators.
|
||||
# This is set before any engine is created so it takes effect immediately.
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
|
||||
|
||||
from alembic import context # noqa: E402
|
||||
from google.cloud.sql.connector import Connector # noqa: E402
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
@@ -70,6 +76,12 @@ config = context.config
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Re-apply SQLAlchemy engine log suppression after fileConfig, which may override
|
||||
# our earlier settings from alembic.ini. This ensures DEBUG-level SQL result logging
|
||||
# is always suppressed, preventing sensitive data from leaking into log aggregators.
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
@@ -6,7 +6,6 @@ Create Date: 2026-03-26
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -24,18 +23,18 @@ def upgrade() -> None:
|
||||
|
||||
# Migrate existing org-level MCP configs to all members in each org.
|
||||
# This preserves existing configurations while transitioning to user-specific settings.
|
||||
conn = op.get_bind()
|
||||
orgs_with_config = conn.execute(
|
||||
sa.text('SELECT id, mcp_config FROM org WHERE mcp_config IS NOT NULL')
|
||||
).fetchall()
|
||||
|
||||
for org_id, mcp_config in orgs_with_config:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
'UPDATE org_member SET mcp_config = :config WHERE org_id = :org_id'
|
||||
),
|
||||
{'config': json.dumps(mcp_config), 'org_id': str(org_id)},
|
||||
# Uses server-side SQL to avoid pulling sensitive config data into the Python process.
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org_member
|
||||
SET mcp_config = org.mcp_config
|
||||
FROM org
|
||||
WHERE org_member.org_id = org.id
|
||||
AND org.mcp_config IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add onboarding_completed column to user table.
|
||||
|
||||
Tracks whether a user has completed the onboarding flow.
|
||||
Used to redirect new SaaS users to /onboarding after accepting TOS.
|
||||
|
||||
Revision ID: 107
|
||||
Revises: 106
|
||||
Create Date: 2026-03-31
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '107'
|
||||
down_revision: Union[str, None] = '106'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('onboarding_completed', sa.Boolean(), nullable=True, default=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'onboarding_completed')
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Add agent_settings columns to enterprise settings tables.
|
||||
|
||||
Revision ID: 108
|
||||
Revises: 107
|
||||
Create Date: 2026-03-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '108'
|
||||
down_revision: Union[str, None] = '107'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
_EMPTY_JSON = sa.text("'{}'::json")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column(
|
||||
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column(
|
||||
'conversation_settings',
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_EMPTY_JSON,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'org_member',
|
||||
sa.Column(
|
||||
'agent_settings_diff',
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_EMPTY_JSON,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'org_member',
|
||||
sa.Column(
|
||||
'conversation_settings_diff',
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_EMPTY_JSON,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column(
|
||||
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column(
|
||||
'conversation_settings',
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=_EMPTY_JSON,
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column('org', sa.Column('_llm_api_key', sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
'org_member',
|
||||
sa.Column(
|
||||
'has_custom_llm_api_key',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET agent_settings = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'agent', agent,
|
||||
'llm.model', llm_model,
|
||||
'llm.base_url', llm_base_url,
|
||||
'verification.confirmation_mode', confirmation_mode,
|
||||
'verification.security_analyzer', security_analyzer,
|
||||
'condenser.enabled', enable_default_condenser,
|
||||
'condenser.max_size', condenser_max_size,
|
||||
'max_iterations', max_iterations
|
||||
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org_member
|
||||
SET agent_settings_diff = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'llm.model', llm_model,
|
||||
'llm.base_url', llm_base_url,
|
||||
'max_iterations', max_iterations,
|
||||
'mcp_config', mcp_config
|
||||
) || COALESCE(agent_settings_diff::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org
|
||||
SET agent_settings = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'agent', agent,
|
||||
'llm.model', default_llm_model,
|
||||
'llm.base_url', default_llm_base_url,
|
||||
'verification.confirmation_mode', confirmation_mode,
|
||||
'verification.security_analyzer', security_analyzer,
|
||||
'condenser.enabled', enable_default_condenser,
|
||||
'condenser.max_size', condenser_max_size,
|
||||
'max_iterations', default_max_iterations,
|
||||
'mcp_config', mcp_config
|
||||
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
op.alter_column('user_settings', 'agent_settings', server_default=None)
|
||||
op.alter_column('user_settings', 'conversation_settings', server_default=None)
|
||||
op.alter_column('org_member', 'agent_settings_diff', server_default=None)
|
||||
op.alter_column('org_member', 'conversation_settings_diff', server_default=None)
|
||||
op.alter_column('org', 'agent_settings', server_default=None)
|
||||
op.alter_column('org', 'conversation_settings', server_default=None)
|
||||
op.alter_column('org_member', 'has_custom_llm_api_key', server_default=None)
|
||||
op.drop_column('user_settings', 'agent')
|
||||
op.drop_column('user_settings', 'max_iterations')
|
||||
op.drop_column('user_settings', 'security_analyzer')
|
||||
op.drop_column('user_settings', 'confirmation_mode')
|
||||
op.drop_column('user_settings', 'llm_model')
|
||||
op.drop_column('user_settings', 'llm_base_url')
|
||||
op.drop_column('user_settings', 'enable_default_condenser')
|
||||
op.drop_column('user_settings', 'condenser_max_size')
|
||||
op.drop_column('org_member', 'max_iterations')
|
||||
op.drop_column('org_member', 'llm_model')
|
||||
op.drop_column('org_member', 'llm_base_url')
|
||||
op.drop_column('org_member', 'mcp_config')
|
||||
op.drop_column('org', 'agent')
|
||||
op.drop_column('org', 'default_max_iterations')
|
||||
op.drop_column('org', 'security_analyzer')
|
||||
op.drop_column('org', 'confirmation_mode')
|
||||
op.drop_column('org', 'default_llm_model')
|
||||
op.drop_column('org', 'default_llm_base_url')
|
||||
op.drop_column('org', 'enable_default_condenser')
|
||||
op.drop_column('org', 'mcp_config')
|
||||
op.drop_column('org', 'condenser_max_size')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column('user_settings', sa.Column('agent', sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
'user_settings', sa.Column('max_iterations', sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings', sa.Column('security_analyzer', sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings', sa.Column('confirmation_mode', sa.Boolean(), nullable=True)
|
||||
)
|
||||
op.add_column('user_settings', sa.Column('llm_model', sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
'user_settings', sa.Column('llm_base_url', sa.String(), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column(
|
||||
'enable_default_condenser',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings', sa.Column('condenser_max_size', sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column('org_member', sa.Column('llm_base_url', sa.String(), nullable=True))
|
||||
op.add_column('org_member', sa.Column('llm_model', sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
'org_member', sa.Column('max_iterations', sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column('org_member', sa.Column('mcp_config', sa.JSON(), nullable=True))
|
||||
op.add_column('org', sa.Column('agent', sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
'org', sa.Column('default_max_iterations', sa.Integer(), nullable=True)
|
||||
)
|
||||
op.add_column('org', sa.Column('security_analyzer', sa.String(), nullable=True))
|
||||
op.add_column('org', sa.Column('confirmation_mode', sa.Boolean(), nullable=True))
|
||||
op.add_column('org', sa.Column('default_llm_model', sa.String(), nullable=True))
|
||||
op.add_column('org', sa.Column('default_llm_base_url', sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column(
|
||||
'enable_default_condenser',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
)
|
||||
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
|
||||
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET
|
||||
agent = agent_settings ->> 'agent',
|
||||
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer,
|
||||
security_analyzer =
|
||||
agent_settings ->> 'verification.security_analyzer',
|
||||
confirmation_mode = CASE
|
||||
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
|
||||
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
|
||||
ELSE NULL
|
||||
END,
|
||||
llm_model = agent_settings ->> 'llm.model',
|
||||
llm_base_url = agent_settings ->> 'llm.base_url',
|
||||
enable_default_condenser = CASE
|
||||
WHEN agent_settings::jsonb ? 'condenser.enabled'
|
||||
THEN (agent_settings ->> 'condenser.enabled')::boolean
|
||||
ELSE TRUE
|
||||
END,
|
||||
condenser_max_size =
|
||||
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org_member
|
||||
SET
|
||||
llm_model = agent_settings_diff ->> 'llm.model',
|
||||
llm_base_url = agent_settings_diff ->> 'llm.base_url',
|
||||
max_iterations =
|
||||
NULLIF(agent_settings_diff ->> 'max_iterations', '')::integer,
|
||||
mcp_config = agent_settings_diff -> 'mcp_config'
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org
|
||||
SET
|
||||
agent = agent_settings ->> 'agent',
|
||||
default_max_iterations =
|
||||
NULLIF(agent_settings ->> 'max_iterations', '')::integer,
|
||||
security_analyzer =
|
||||
agent_settings ->> 'verification.security_analyzer',
|
||||
confirmation_mode = CASE
|
||||
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
|
||||
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
|
||||
ELSE NULL
|
||||
END,
|
||||
default_llm_model = agent_settings ->> 'llm.model',
|
||||
default_llm_base_url = agent_settings ->> 'llm.base_url',
|
||||
enable_default_condenser = CASE
|
||||
WHEN agent_settings::jsonb ? 'condenser.enabled'
|
||||
THEN (agent_settings ->> 'condenser.enabled')::boolean
|
||||
ELSE TRUE
|
||||
END,
|
||||
mcp_config = agent_settings -> 'mcp_config',
|
||||
condenser_max_size =
|
||||
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.drop_column('org', 'agent_settings')
|
||||
op.drop_column('org', 'conversation_settings')
|
||||
op.drop_column('org', '_llm_api_key')
|
||||
op.drop_column('org_member', 'agent_settings_diff')
|
||||
op.drop_column('org_member', 'conversation_settings_diff')
|
||||
op.drop_column('org_member', 'has_custom_llm_api_key')
|
||||
op.drop_column('user_settings', 'agent_settings')
|
||||
op.drop_column('user_settings', 'conversation_settings')
|
||||
87
enterprise/poetry.lock
generated
87
enterprise/poetry.lock
generated
@@ -549,7 +549,7 @@ description = "LTS Port of Python audioop"
|
||||
optional = false
|
||||
python-versions = ">=3.13"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"},
|
||||
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"},
|
||||
@@ -1944,8 +1944,8 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
bytecode = [
|
||||
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
|
||||
{version = ">=0.15.1", markers = "python_version ~= \"3.12.0\""},
|
||||
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
|
||||
]
|
||||
envier = ">=0.6.1,<0.7.0"
|
||||
legacy-cgi = {version = ">=2.0.0", markers = "python_version >= \"3.13.0\""}
|
||||
@@ -2994,8 +2994,8 @@ googleapis-common-protos = ">=1.63.2,<2.0.0"
|
||||
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
protobuf = ">=4.25.8,<7.0.0"
|
||||
requests = ">=2.20.0,<3.0.0"
|
||||
@@ -3106,8 +3106,8 @@ google-auth = ">=2.47.0,<3.0.0"
|
||||
google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0"
|
||||
google-cloud-resource-manager = ">=1.3.3,<3.0.0"
|
||||
google-cloud-storage = [
|
||||
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.32.0,<4.0.0", markers = "python_version < \"3.13\""},
|
||||
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
google-genai = {version = ">=1.59.0,<2.0.0", markers = "python_version >= \"3.10\""}
|
||||
packaging = ">=14.3"
|
||||
@@ -3214,8 +3214,8 @@ google-api-core = {version = ">=2.11.0,<3.0.0", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
|
||||
grpcio = ">=1.33.2,<2.0.0"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
protobuf = ">=4.25.8,<8.0.0"
|
||||
|
||||
@@ -3237,8 +3237,8 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
|
||||
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
|
||||
grpcio = ">=1.33.2,<2.0.0"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
protobuf = ">=4.25.8,<8.0.0"
|
||||
|
||||
@@ -4795,7 +4795,7 @@ description = "Fork of the standard library cgi and cgitb modules removed in Pyt
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd"},
|
||||
{file = "legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577"},
|
||||
@@ -4890,25 +4890,24 @@ valkey = ["valkey (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.80.10"
|
||||
version = "1.83.0"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "litellm-1.80.10-py3-none-any.whl", hash = "sha256:9b3e561efaba0eb1291cb1555d3dcb7283cf7f3cb65aadbcdb42e2a8765898c8"},
|
||||
{file = "litellm-1.80.10.tar.gz", hash = "sha256:4a4aff7558945c2f7e5c6523e67c1b5525a46b10b0e1ad6b8f847cb13b16779e"},
|
||||
{file = "litellm-1.83.0-py3-none-any.whl", hash = "sha256:88c536d339248f3987571493015784671ba3f193a328e1ea6780dbebaa2094a8"},
|
||||
{file = "litellm-1.83.0.tar.gz", hash = "sha256:860bebc76c4bb27b4cf90b4a77acd66dba25aced37e3db98750de8a1766bfb7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.10"
|
||||
click = "*"
|
||||
fastuuid = ">=0.13.0"
|
||||
grpcio = {version = ">=1.62.3,<1.68.0", markers = "python_version < \"3.14\""}
|
||||
httpx = ">=0.23.0"
|
||||
importlib-metadata = ">=6.8.0"
|
||||
jinja2 = ">=3.1.2,<4.0.0"
|
||||
jsonschema = ">=4.22.0,<5.0.0"
|
||||
jsonschema = ">=4.23.0,<5.0.0"
|
||||
openai = ">=2.8.0"
|
||||
pydantic = ">=2.5.0,<3.0.0"
|
||||
python-dotenv = ">=0.2.0"
|
||||
@@ -4917,9 +4916,11 @@ tokenizers = "*"
|
||||
|
||||
[package.extras]
|
||||
caching = ["diskcache (>=5.6.1,<6.0.0)"]
|
||||
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
|
||||
extra-proxy = ["a2a-sdk (>=0.3.22,<0.4.0) ; python_version >= \"3.10\"", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (>=0.11.0,<0.12.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
|
||||
google = ["google-cloud-aiplatform (>=1.38.0)"]
|
||||
grpc = ["grpcio (>=1.62.3,<1.68.dev0 || >1.71.0,!=1.71.1,!=1.72.0,!=1.72.1,!=1.73.0) ; python_version < \"3.14\"", "grpcio (>=1.75.0) ; python_version >= \"3.14\""]
|
||||
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
|
||||
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.25)", "litellm-proxy-extras (==0.4.14)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
proxy = ["PyJWT (>=2.12.0,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (>=1.40.76,<2.0.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.35)", "litellm-proxy-extras (>=0.4.62,<0.5.0)", "mcp (>=1.25.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "pyroscope-io (>=0.8,<0.9) ; sys_platform != \"win32\"", "python-multipart (>=0.0.20)", "pyyaml (>=6.0.1,<7.0.0)", "rich (>=13.7.1,<14.0.0)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.32.1,<1.0.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
|
||||
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
|
||||
utils = ["numpydoc"]
|
||||
|
||||
@@ -6453,14 +6454,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.16.1"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.16.1-py3-none-any.whl", hash = "sha256:015983b300510c9c329c8eace49fbd4117d31d0895a125e419c31a9964be4155"},
|
||||
{file = "openhands_agent_server-1.16.1.tar.gz", hash = "sha256:489151d35250a424dede8646396bef7b7095adb25e5c973ca8bc6dcbd19cdf07"},
|
||||
{file = "openhands_agent_server-1.17.0-py3-none-any.whl", hash = "sha256:44336cad001c31caeb516481a5a7aea6dd9b5ab4798461f147b5231668d8fb74"},
|
||||
{file = "openhands_agent_server-1.17.0.tar.gz", hash = "sha256:3a88449a3b9ded653dcd2a8c518810c75602873cf9f7d4e8f9b90fd8fd225652"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6486,7 +6487,7 @@ files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.13.3"
|
||||
aiohttp = ">=3.13.5"
|
||||
anthropic = {version = "*", extras = ["vertex"]}
|
||||
anyio = "4.9"
|
||||
asyncpg = ">=0.30"
|
||||
@@ -6499,7 +6500,7 @@ deprecation = ">=2.1"
|
||||
dirhash = "*"
|
||||
docker = "*"
|
||||
fastapi = "*"
|
||||
fastmcp = ">=3,<4"
|
||||
fastmcp = ">=3.2,<4"
|
||||
google-api-python-client = ">=2.164"
|
||||
google-auth-httplib2 = "*"
|
||||
google-auth-oauthlib = "*"
|
||||
@@ -6522,9 +6523,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.16.1"
|
||||
openhands-sdk = "1.16.1"
|
||||
openhands-tools = "1.16.1"
|
||||
openhands-agent-server = "1.17"
|
||||
openhands-sdk = "1.17"
|
||||
openhands-tools = "1.17"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
orjson = ">=3.11.6"
|
||||
@@ -6533,7 +6534,7 @@ pexpect = "*"
|
||||
pg8000 = ">=1.31.5"
|
||||
pillow = ">=12.1.1"
|
||||
playwright = ">=1.55"
|
||||
poetry = ">=2.1.2"
|
||||
poetry = ">=2.3.3"
|
||||
prompt-toolkit = ">=3.0.50"
|
||||
protobuf = ">=5.29.6,<6"
|
||||
psutil = "*"
|
||||
@@ -6554,7 +6555,7 @@ pyyaml = ">=6.0.2"
|
||||
qtconsole = ">=5.6.1"
|
||||
rapidfuzz = ">=3.9"
|
||||
redis = ">=5.2,<7"
|
||||
requests = ">=2.32.5"
|
||||
requests = ">=2.33"
|
||||
setuptools = ">=78.1.1"
|
||||
shellingham = ">=1.5.4"
|
||||
sqlalchemy = {version = ">=2.0.40", extras = ["asyncio"]}
|
||||
@@ -6579,14 +6580,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.16.1"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.16.1-py3-none-any.whl", hash = "sha256:0b487929e03e8c87ac6d99f37ff5314df3db6af70a06b516b0858327f9744f2b"},
|
||||
{file = "openhands_sdk-1.16.1.tar.gz", hash = "sha256:12f203c3766800bdf5d9dd4dd0a7988b88e13ff4954b0c208903778111e29567"},
|
||||
{file = "openhands_sdk-1.17.0-py3-none-any.whl", hash = "sha256:3b771e72209453871c3036a562cf33e9ad9642a54bd48edb44f89915ac54709d"},
|
||||
{file = "openhands_sdk-1.17.0.tar.gz", hash = "sha256:3c69df6590f023a514137272d413658848e0d5bc9aecf941b946c8662862779a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6596,7 +6597,7 @@ fakeredis = {version = ">=2.32.1", extras = ["lua"]}
|
||||
fastmcp = ">=3.0.0"
|
||||
filelock = ">=3.20.1"
|
||||
httpx = {version = ">=0.27.0", extras = ["socks"]}
|
||||
litellm = "1.80.10"
|
||||
litellm = ">=1.82.6,<1.82.7 || >1.82.7,<1.82.8 || >1.82.8"
|
||||
lmnr = ">=0.7.24"
|
||||
pydantic = ">=2.12.5"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
@@ -6609,14 +6610,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.16.1"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.16.1-py3-none-any.whl", hash = "sha256:f7fd1eb205571d02ee480ad71e96cac0c34c57c0938c4074fe135a579a7538d7"},
|
||||
{file = "openhands_tools-1.16.1.tar.gz", hash = "sha256:64488f2d7705ff90f4bfb7dfd1a2f1fbb4f379059d96e0073677c168d97135e7"},
|
||||
{file = "openhands_tools-1.17.0-py3-none-any.whl", hash = "sha256:76cd30fcc153627444f18638bcd926c9190989f80a3492381e84a181c021d815"},
|
||||
{file = "openhands_tools-1.17.0.tar.gz", hash = "sha256:4a9d6c1aec00d366d0feb1ac2e9ee9988ad9806a0ef89f7dbe4655644e639d4a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6691,8 +6692,8 @@ files = [
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.57,<2.0"
|
||||
grpcio = [
|
||||
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
|
||||
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
opentelemetry-api = ">=1.15,<2.0"
|
||||
opentelemetry-exporter-otlp-proto-common = "1.39.1"
|
||||
@@ -7401,14 +7402,14 @@ tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "p
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "6.9.3"
|
||||
version = "7.9.12"
|
||||
description = "Integrate PostHog into any python application."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "posthog-6.9.3-py3-none-any.whl", hash = "sha256:c71e9cb7ac4ef13eb604f04c3161edd10b1d08a32499edd54437ba5eab591c58"},
|
||||
{file = "posthog-6.9.3.tar.gz", hash = "sha256:7d201774ea9eba156f1de46d34313e30b2384d523900fe8e425accc92486cc34"},
|
||||
{file = "posthog-7.9.12-py3-none-any.whl", hash = "sha256:7175bd1698a566bfea98a016c64e3456399f8046aeeca8f1d04ae5bf6c5a38d0"},
|
||||
{file = "posthog-7.9.12.tar.gz", hash = "sha256:ebabf2eb2e1c1fbf22b0759df4644623fa43cc6c9dcbe9fd429b7937d14251ec"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7422,7 +7423,7 @@ typing-extensions = ">=4.2.0"
|
||||
[package.extras]
|
||||
dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"]
|
||||
langchain = ["langchain (>=0.2.0)"]
|
||||
test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
|
||||
test = ["anthropic (>=0.72)", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=1.0)", "langchain-community (>=0.4)", "langchain-core (>=1.0)", "langchain-openai (>=1.0)", "langgraph (>=1.0)", "mock (>=2.0.0)", "openai (>=2.0)", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
@@ -12130,14 +12131,14 @@ requests-toolbelt = ">=0.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.22"
|
||||
version = "0.0.26"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
|
||||
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
|
||||
{file = "python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185"},
|
||||
{file = "python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -13730,7 +13731,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
|
||||
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
|
||||
@@ -13747,7 +13748,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
|
||||
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
|
||||
@@ -15263,4 +15264,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "c468b13e2d26e31e0e8f84518bcb8379234d431ca3819625f49b91aa3589359c"
|
||||
content-hash = "55a09a40217bbbc876e5864b78c941d86a261e4111bce7e4495c1dd75df43fd7"
|
||||
|
||||
@@ -36,7 +36,7 @@ resend = "^2.7.0"
|
||||
tenacity = "^9.1.2"
|
||||
slack-sdk = "^3.35.0"
|
||||
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
|
||||
posthog = "^6.0.0"
|
||||
posthog = "^7.0.0"
|
||||
limits = "^5.2.0"
|
||||
coredis = "^4.22.0"
|
||||
httpx = "*"
|
||||
|
||||
@@ -12,6 +12,9 @@ import socketio # noqa: E402
|
||||
from fastapi import Request, status # noqa: E402
|
||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||
from fastapi.responses import JSONResponse # noqa: E402
|
||||
from server.app_lifespan.saas_app_lifespan_service import ( # noqa: E402
|
||||
SaasAppLifespanService,
|
||||
)
|
||||
from server.auth.auth_error import ExpiredError, NoCredentialsError # noqa: E402
|
||||
from server.auth.constants import ( # noqa: E402
|
||||
BITBUCKET_DATA_CENTER_HOST,
|
||||
@@ -23,7 +26,10 @@ from server.auth.constants import ( # noqa: E402
|
||||
)
|
||||
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
|
||||
from server.logger import logger # noqa: E402
|
||||
from server.middleware import SetAuthCookieMiddleware # noqa: E402
|
||||
from server.middleware import ( # noqa: E402
|
||||
PostHogSessionMiddleware,
|
||||
SetAuthCookieMiddleware,
|
||||
)
|
||||
from server.rate_limit import setup_rate_limit_handler # noqa: E402
|
||||
from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
from server.routes.auth import api_router, oauth_router # noqa: E402
|
||||
@@ -38,6 +44,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.onboarding import onboarding_router # noqa: E402
|
||||
from server.routes.org_invitations import ( # noqa: E402
|
||||
accept_router as invitation_accept_router,
|
||||
)
|
||||
@@ -49,6 +56,9 @@ from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.service import service_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
|
||||
from server.routes.users_v1 import ( # noqa: E402
|
||||
override_users_me_endpoint,
|
||||
)
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
router as shared_conversation_router,
|
||||
)
|
||||
@@ -62,6 +72,14 @@ from server.verified_models.verified_model_router import ( # noqa: E402
|
||||
override_llm_models_dependency,
|
||||
)
|
||||
|
||||
# Patch global config with SaaS lifespan BEFORE openhands.server.app is imported.
|
||||
# app.py reads get_app_lifespan_service() at module level (line ~69), so this
|
||||
# must execute first.
|
||||
from openhands.app_server.config import get_global_config # noqa: E402
|
||||
|
||||
_config = get_global_config()
|
||||
_config.lifespan = SaasAppLifespanService()
|
||||
|
||||
from openhands.server.app import app as base_app # noqa: E402
|
||||
from openhands.server.listen_socket import sio # noqa: E402
|
||||
from openhands.server.middleware import ( # noqa: E402
|
||||
@@ -123,6 +141,10 @@ base_app.include_router(
|
||||
# This must happen after all routers are included
|
||||
override_llm_models_dependency(base_app)
|
||||
|
||||
# Override the /api/v1/users/me endpoint to include organization info
|
||||
# This replaces the OSS endpoint with a SAAS version that adds org_id, org_name, role, permissions
|
||||
override_users_me_endpoint(base_app)
|
||||
|
||||
base_app.include_router(invitation_router) # Add routes for org invitation management
|
||||
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
|
||||
add_github_proxy_routes(base_app)
|
||||
@@ -141,6 +163,7 @@ if BITBUCKET_DATA_CENTER_HOST:
|
||||
base_app.include_router(bitbucket_dc_proxy_router)
|
||||
base_app.include_router(email_router) # Add routes for email management
|
||||
base_app.include_router(feedback_router) # Add routes for conversation feedback
|
||||
base_app.include_router(onboarding_router) # Add route for onboarding submission
|
||||
base_app.include_router(
|
||||
event_webhook_router
|
||||
) # Add routes for Events in nested runtimes
|
||||
@@ -154,6 +177,7 @@ base_app.add_middleware(
|
||||
allow_headers=['*'],
|
||||
)
|
||||
base_app.add_middleware(CacheControlMiddleware)
|
||||
base_app.middleware('http')(PostHogSessionMiddleware())
|
||||
base_app.middleware('http')(SetAuthCookieMiddleware())
|
||||
|
||||
base_app.mount('/', SPAStaticFiles(directory=directory, html=True), name='dist')
|
||||
|
||||
0
enterprise/server/app_lifespan/__init__.py
Normal file
0
enterprise/server/app_lifespan/__init__.py
Normal file
46
enterprise/server/app_lifespan/saas_app_lifespan_service.py
Normal file
46
enterprise/server/app_lifespan/saas_app_lifespan_service.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""SaaS-specific application lifespan service.
|
||||
|
||||
Initializes PostHog analytics on startup and flushes buffered events on
|
||||
clean shutdown so no events are lost when the server exits gracefully.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
|
||||
from openhands.analytics import get_analytics_service, init_analytics_service
|
||||
from openhands.app_server.app_lifespan.app_lifespan_service import AppLifespanService
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class SaasAppLifespanService(AppLifespanService):
|
||||
"""Lifespan service for the SaaS server.
|
||||
|
||||
On enter: initialises the PostHog analytics singleton from environment vars.
|
||||
On exit: calls ``analytics_service.shutdown()`` to flush any buffered events.
|
||||
"""
|
||||
|
||||
async def __aenter__(self):
|
||||
api_key = os.environ.get('POSTHOG_CLIENT_KEY', '')
|
||||
host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
|
||||
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', '')
|
||||
app_mode = AppMode.SAAS if 'saas' in config_cls.lower() else AppMode.OPENHANDS
|
||||
|
||||
init_analytics_service(
|
||||
api_key=api_key,
|
||||
host=host,
|
||||
app_mode=app_mode,
|
||||
is_feature_env=IS_FEATURE_ENV,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||||
try:
|
||||
svc = get_analytics_service()
|
||||
if svc is not None:
|
||||
svc.shutdown()
|
||||
except Exception:
|
||||
logger.exception('Error shutting down analytics service')
|
||||
@@ -87,6 +87,9 @@ class Permission(str, Enum):
|
||||
# Git organization claims
|
||||
MANAGE_ORG_CLAIMS = 'manage_org_claims'
|
||||
|
||||
# Manage Automations
|
||||
MANAGE_AUTOMATIONS = 'manage_automations'
|
||||
|
||||
|
||||
class RoleName(str, Enum):
|
||||
"""Role names used in the system."""
|
||||
@@ -123,6 +126,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
Permission.DELETE_ORGANIZATION,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
# Manage Automations
|
||||
Permission.MANAGE_AUTOMATIONS,
|
||||
]
|
||||
),
|
||||
RoleName.ADMIN: frozenset(
|
||||
@@ -146,6 +151,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
Permission.EDIT_ORG_SETTINGS,
|
||||
# Git organization claims
|
||||
Permission.MANAGE_ORG_CLAIMS,
|
||||
# Manage Automations
|
||||
Permission.MANAGE_AUTOMATIONS,
|
||||
]
|
||||
),
|
||||
RoleName.MEMBER: frozenset(
|
||||
@@ -159,6 +166,8 @@ ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
|
||||
# Settings (View only)
|
||||
Permission.VIEW_ORG_SETTINGS,
|
||||
Permission.VIEW_LLM_SETTINGS,
|
||||
# Manage Automations
|
||||
Permission.MANAGE_AUTOMATIONS,
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ from server.auth.auth_error import (
|
||||
ExpiredError,
|
||||
NoCredentialsError,
|
||||
)
|
||||
from server.auth.authorization import (
|
||||
get_role_permissions,
|
||||
get_user_org_role,
|
||||
)
|
||||
from server.auth.constants import BITBUCKET_DATA_CENTER_HOST
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
@@ -23,10 +27,12 @@ from sqlalchemy import delete, select
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.database import a_session_maker
|
||||
from storage.org_store import OrgStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.user_authorization import UserAuthorizationType
|
||||
from storage.user_authorization_store import UserAuthorizationStore
|
||||
from storage.user_store import UserStore
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
|
||||
from openhands.integrations.provider import (
|
||||
@@ -64,6 +70,12 @@ class SaasUserAuth(UserAuth):
|
||||
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
|
||||
api_key_id: int | None = None
|
||||
api_key_name: str | None = None
|
||||
# Organization context fields - populated lazily via get_org_info()
|
||||
_org_id: str | None = None
|
||||
_org_name: str | None = None
|
||||
_role: str | None = None
|
||||
_permissions: list[str] | None = None
|
||||
_org_info_loaded: bool = False
|
||||
|
||||
def get_api_key_org_id(self) -> UUID | None:
|
||||
"""Get the organization ID bound to the API key used for authentication.
|
||||
@@ -242,6 +254,72 @@ class SaasUserAuth(UserAuth):
|
||||
)
|
||||
return mcp_api_key
|
||||
|
||||
async def get_org_info(self) -> dict | None:
|
||||
"""Get organization info for the current user.
|
||||
|
||||
Lazily loads and caches organization data including:
|
||||
- org_id: Current organization ID
|
||||
- org_name: Current organization name
|
||||
- role: User's role in the organization
|
||||
- permissions: List of permission names for the role
|
||||
|
||||
Returns:
|
||||
dict with org_id, org_name, role, permissions or None if not available
|
||||
"""
|
||||
if self._org_info_loaded:
|
||||
if self._org_id is None:
|
||||
return None
|
||||
return {
|
||||
'org_id': self._org_id,
|
||||
'org_name': self._org_name,
|
||||
'role': self._role,
|
||||
'permissions': self._permissions,
|
||||
}
|
||||
|
||||
# Mark as loaded to avoid repeated attempts on failure
|
||||
self._org_info_loaded = True
|
||||
|
||||
try:
|
||||
# Get user and their current org
|
||||
user = await UserStore.get_user_by_id(self.user_id)
|
||||
if not user:
|
||||
logger.warning(f'User {self.user_id} not found for org info')
|
||||
return None
|
||||
|
||||
# Get the current org
|
||||
org = await OrgStore.get_org_by_id(user.current_org_id)
|
||||
if not org:
|
||||
logger.warning(
|
||||
f'Organization {user.current_org_id} not found for user {self.user_id}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Get user's role in the current org
|
||||
role = await get_user_org_role(self.user_id, user.current_org_id)
|
||||
role_name = role.name if role else None
|
||||
|
||||
# Get permissions for the role
|
||||
permissions: list[str] = []
|
||||
if role_name:
|
||||
role_permissions = get_role_permissions(role_name)
|
||||
permissions = [p.value for p in role_permissions]
|
||||
|
||||
# Cache the results
|
||||
self._org_id = str(user.current_org_id)
|
||||
self._org_name = org.name
|
||||
self._role = role_name
|
||||
self._permissions = permissions
|
||||
|
||||
return {
|
||||
'org_id': self._org_id,
|
||||
'org_name': self._org_name,
|
||||
'role': self._role,
|
||||
'permissions': self._permissions,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f'Error fetching org info for user {self.user_id}: {e}')
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, request: Request) -> UserAuth:
|
||||
logger.debug('saas_user_auth_get_instance')
|
||||
|
||||
@@ -20,6 +20,7 @@ from server.auth.constants import (
|
||||
GITLAB_APP_CLIENT_ID,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
)
|
||||
from server.constants import DEPLOYMENT_MODE
|
||||
|
||||
from openhands.core.config.utils import load_openhands_config
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
@@ -179,6 +180,7 @@ class SaaSServerConfig(ServerConfig):
|
||||
'ENABLE_JIRA': self.enable_jira,
|
||||
'ENABLE_JIRA_DC': self.enable_jira_dc,
|
||||
'ENABLE_LINEAR': self.enable_linear,
|
||||
'DEPLOYMENT_MODE': DEPLOYMENT_MODE,
|
||||
},
|
||||
'PROVIDERS_CONFIGURED': providers_configured,
|
||||
}
|
||||
|
||||
@@ -15,6 +15,33 @@ IS_FEATURE_ENV = (
|
||||
) # Does not include the staging deployment
|
||||
IS_LOCAL_ENV = bool(HOST == 'localhost')
|
||||
|
||||
|
||||
# _is_all_hands_managed_domain() can be removed/replaced when a self-hosted specific
|
||||
# env var is created (e.g is_self_hosted` or `deployment_mode`)
|
||||
def _is_all_hands_managed_domain(host: str) -> bool:
|
||||
"""Check if the host is an All-Hands managed domain."""
|
||||
return (
|
||||
host == 'app.all-hands.dev'
|
||||
or host == 'app.openhands.ai'
|
||||
or host.endswith('.all-hands.dev')
|
||||
or host.endswith('.openhands.ai')
|
||||
)
|
||||
|
||||
|
||||
def _get_deployment_mode() -> str:
|
||||
"""Determine deployment mode based on WEB_HOST.
|
||||
|
||||
Returns:
|
||||
'cloud' for All-Hands managed infrastructure (app.all-hands.dev, etc.)
|
||||
'self_hosted' for enterprise self-hosted deployments (customer domains)
|
||||
"""
|
||||
if _is_all_hands_managed_domain(HOST):
|
||||
return 'cloud'
|
||||
return 'self_hosted'
|
||||
|
||||
|
||||
DEPLOYMENT_MODE = _get_deployment_mode()
|
||||
|
||||
# Role name constants
|
||||
ROLE_OWNER = 'owner'
|
||||
ROLE_ADMIN = 'admin'
|
||||
|
||||
@@ -4,9 +4,9 @@ if TYPE_CHECKING:
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
|
||||
from openhands.core.config.mcp_config import (
|
||||
MCPSHTTPServerConfig,
|
||||
MCPStdioServerConfig,
|
||||
OpenHandsMCPConfig,
|
||||
RemoteMCPServer,
|
||||
StdioMCPServer,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
@@ -24,16 +24,8 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
@staticmethod
|
||||
async def create_default_mcp_server_config(
|
||||
host: str, config: 'OpenHandsConfig', user_id: str | None = None
|
||||
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
|
||||
"""
|
||||
Create a default MCP server configuration.
|
||||
|
||||
Args:
|
||||
host: Host string
|
||||
config: OpenHandsConfig
|
||||
Returns:
|
||||
A tuple containing the default SSE server configuration and a list of MCP stdio server configurations
|
||||
"""
|
||||
) -> dict[str, RemoteMCPServer | StdioMCPServer]:
|
||||
"""Return a dict of default MCP server entries for SaaS mode."""
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
@@ -47,9 +39,14 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
|
||||
if not api_key:
|
||||
logger.error(f'Could not provision MCP API Key for user: {user_id}')
|
||||
return None, []
|
||||
return {}
|
||||
|
||||
return MCPSHTTPServerConfig(
|
||||
url=f'https://{host}/mcp/mcp', api_key=api_key
|
||||
), []
|
||||
return None, []
|
||||
return {
|
||||
'openhands': RemoteMCPServer(
|
||||
url=f'https://{host}/mcp/mcp',
|
||||
transport='http',
|
||||
auth=api_key,
|
||||
timeout=60,
|
||||
)
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -198,3 +198,19 @@ class SetAuthCookieMiddleware:
|
||||
await token_manager.logout(user_auth.refresh_token.get_secret_value())
|
||||
except Exception:
|
||||
logger.debug('Error logging out')
|
||||
|
||||
|
||||
class PostHogSessionMiddleware:
|
||||
"""Extract the PostHog session ID from the incoming request header.
|
||||
|
||||
Stores the value on ``request.state.posthog_session_id`` so that
|
||||
subsequent event-capture call sites can link server-side events to the
|
||||
corresponding frontend session-replay recording.
|
||||
|
||||
When the ``X-POSTHOG-SESSION-ID`` header is absent the attribute is set
|
||||
to ``None`` — never raises, never blocks.
|
||||
"""
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
request.state.posthog_session_id = request.headers.get('X-POSTHOG-SESSION-ID')
|
||||
return await call_next(request)
|
||||
|
||||
1
enterprise/server/models/__init__.py
Normal file
1
enterprise/server/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Enterprise server models
|
||||
16
enterprise/server/models/user_models.py
Normal file
16
enterprise/server/models/user_models.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""SAAS-specific user models that extend OSS UserInfo with organization fields."""
|
||||
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
|
||||
|
||||
class SaasUserInfo(UserInfo):
|
||||
"""User info model for SAAS mode with organization context.
|
||||
|
||||
Extends the base UserInfo with SAAS-specific fields for organization
|
||||
membership, role, and permissions.
|
||||
"""
|
||||
|
||||
org_id: str | None = None
|
||||
org_name: str | None = None
|
||||
role: str | None = None
|
||||
permissions: list[str] | None = None
|
||||
@@ -7,7 +7,6 @@ from typing import Annotated, Optional, cast
|
||||
from urllib.parse import quote, urlencode
|
||||
from uuid import UUID as parse_uuid
|
||||
|
||||
import posthog
|
||||
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from pydantic import SecretStr
|
||||
@@ -27,7 +26,10 @@ from server.auth.user.user_authorizer import (
|
||||
depends_user_authorizer,
|
||||
)
|
||||
from server.config import sign_token
|
||||
from server.constants import IS_FEATURE_ENV, IS_LOCAL_ENV
|
||||
from server.constants import (
|
||||
DEPLOYMENT_MODE,
|
||||
IS_FEATURE_ENV,
|
||||
)
|
||||
from server.routes.event_webhook import _get_session_api_key, _get_user_id
|
||||
from server.services.org_invitation_service import (
|
||||
EmailMismatchError,
|
||||
@@ -43,6 +45,7 @@ from storage.database import a_session_maker
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType, TokenResponse
|
||||
@@ -120,6 +123,35 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
|
||||
return state, None, None
|
||||
|
||||
|
||||
async def _get_user_orgs_with_data(user_id: str, org_member_ids: list) -> list:
|
||||
"""Load Org objects for a user's org memberships.
|
||||
|
||||
Uses org_member.org_id list to batch-load Org objects, avoiding N+1
|
||||
by loading all orgs a user belongs to in one query via OrgStore.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID string
|
||||
org_member_ids: List of org_id UUIDs from user.org_members
|
||||
|
||||
Returns:
|
||||
List of Org objects the user belongs to
|
||||
"""
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
orgs = []
|
||||
for org_id in org_member_ids:
|
||||
try:
|
||||
org = await OrgStore.get_org_by_id(org_id)
|
||||
if org:
|
||||
orgs.append(org)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'auth:_get_user_orgs_with_data:failed',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
return orgs
|
||||
|
||||
|
||||
@oauth_router.get('/keycloak/callback')
|
||||
async def keycloak_callback(
|
||||
request: Request,
|
||||
@@ -198,9 +230,11 @@ async def keycloak_callback(
|
||||
email = user_info.email
|
||||
user_id = user_info.sub
|
||||
user_info_dict = user_info.model_dump(exclude_none=True)
|
||||
is_new_user = False
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if not user:
|
||||
user = await UserStore.create_user(user_id, user_info_dict)
|
||||
is_new_user = True
|
||||
else:
|
||||
# Existing user — gradually backfill contact_name if it still has a username-style value
|
||||
await UserStore.backfill_contact_name(user_id, user_info_dict)
|
||||
@@ -215,6 +249,36 @@ async def keycloak_callback(
|
||||
|
||||
logger.info(f'Logging in user {str(user.id)} in org {user.current_org_id}')
|
||||
|
||||
# Analytics: user signed up event (fires only for new users, once per user)
|
||||
if is_new_user:
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
consented = (
|
||||
user.user_consents_to_analytics is True
|
||||
) # None = undecided = not consented
|
||||
org_id_str = str(user.current_org_id) if user.current_org_id else None
|
||||
|
||||
analytics.track_user_signed_up(
|
||||
distinct_id=user_id,
|
||||
idp=user_info.get('identity_provider', 'keycloak'),
|
||||
email_domain=email.split('@')[1]
|
||||
if email and '@' in email
|
||||
else None,
|
||||
invitation_source='invitation'
|
||||
if invitation_token
|
||||
else 'self_signup',
|
||||
org_id=org_id_str,
|
||||
consented=consented,
|
||||
)
|
||||
analytics.set_person_properties(
|
||||
distinct_id=user_id,
|
||||
properties={'signed_up_at': datetime.now(timezone.utc).isoformat()},
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:user_signed_up:failed')
|
||||
|
||||
# reCAPTCHA verification with Account Defender
|
||||
if RECAPTCHA_SITE_KEY:
|
||||
if not recaptcha_token:
|
||||
@@ -331,36 +395,68 @@ async def keycloak_callback(
|
||||
f'keycloakAccessToken: {keycloak_access_token}, keycloakUserId: {user_id}'
|
||||
)
|
||||
|
||||
# adding in posthog tracking
|
||||
# Server-side identity — full person and org group tracking via AnalyticsService
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
consented = (
|
||||
user.user_consents_to_analytics is True
|
||||
) # None = undecided = not consented
|
||||
org_id_str = str(user.current_org_id) if user.current_org_id else None
|
||||
|
||||
# If this is a feature environment, add "FEATURE_" prefix to user_id for PostHog
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
# Load current org for identify_user
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
try:
|
||||
posthog.set(
|
||||
distinct_id=posthog_user_id,
|
||||
properties={
|
||||
'user_id': posthog_user_id,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
current_org = (
|
||||
await OrgStore.get_org_by_id(user.current_org_id)
|
||||
if user.current_org_id
|
||||
else None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'auth:posthog_set:failed',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
|
||||
# Load org data for identify_user (orgs list with member_count)
|
||||
org_member_ids = (
|
||||
[om.org_id for om in user.org_members] if user.org_members else []
|
||||
)
|
||||
user_orgs = await _get_user_orgs_with_data(user_id, org_member_ids)
|
||||
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
orgs_data = []
|
||||
for org in user_orgs:
|
||||
try:
|
||||
member_count = await OrgMemberStore.get_org_members_count(org_id=org.id)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'auth:identify_user:member_count_failed',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
member_count = None
|
||||
orgs_data.append(
|
||||
{'id': str(org.id), 'name': org.name, 'member_count': member_count}
|
||||
)
|
||||
|
||||
analytics.identify_user(
|
||||
distinct_id=user_id,
|
||||
consented=consented,
|
||||
email=email,
|
||||
org_id=org_id_str,
|
||||
org_name=current_org.name if current_org else None,
|
||||
idp=idp,
|
||||
orgs=orgs_data,
|
||||
)
|
||||
|
||||
analytics.track_user_logged_in(
|
||||
distinct_id=user_id,
|
||||
idp=idp,
|
||||
org_id=org_id_str,
|
||||
consented=consented,
|
||||
)
|
||||
# Continue execution as this is not critical
|
||||
|
||||
logger.info(
|
||||
'user_logged_in',
|
||||
extra={
|
||||
'idp': idp,
|
||||
'idp_type': idp_type,
|
||||
'posthog_user_id': posthog_user_id,
|
||||
'user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
@@ -462,8 +558,20 @@ async def keycloak_callback(
|
||||
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
|
||||
response = RedirectResponse(tos_redirect_url, status_code=302)
|
||||
else:
|
||||
# User has accepted TOS - check if they need onboarding
|
||||
# Only redirect to onboarding if user has a valid offline token,
|
||||
# otherwise they need to complete the Keycloak offline token flow first
|
||||
if valid_offline_token and await _should_redirect_to_onboarding(user_id, user):
|
||||
redirect_url = f'{web_url}/onboarding'
|
||||
logger.info(
|
||||
'Redirecting returning user to onboarding',
|
||||
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
|
||||
)
|
||||
if invitation_token:
|
||||
redirect_url = f'{redirect_url}&invitation_success=true'
|
||||
if '?' in redirect_url:
|
||||
redirect_url = f'{redirect_url}&invitation_success=true'
|
||||
else:
|
||||
redirect_url = f'{redirect_url}?invitation_success=true'
|
||||
response = RedirectResponse(redirect_url, status_code=302)
|
||||
|
||||
set_response_cookie(
|
||||
@@ -471,7 +579,7 @@ async def keycloak_callback(
|
||||
response=response,
|
||||
keycloak_access_token=keycloak_access_token,
|
||||
keycloak_refresh_token=keycloak_refresh_token,
|
||||
secure=True if redirect_url.startswith('https') else False,
|
||||
secure=True if web_url.startswith('https') else False,
|
||||
accepted_tos=has_accepted_tos,
|
||||
)
|
||||
|
||||
@@ -512,8 +620,23 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
|
||||
user_id=user_info.sub, offline_token=keycloak_refresh_token
|
||||
)
|
||||
|
||||
user = await UserStore.get_user_by_id(user_info.sub)
|
||||
has_accepted_tos = user is not None and user.accepted_tos is not None
|
||||
|
||||
redirect_url, _, _ = _extract_oauth_state(state)
|
||||
return RedirectResponse(redirect_url if redirect_url else web_url, status_code=302)
|
||||
default_url = redirect_url if redirect_url else web_url
|
||||
final_url = await _get_post_auth_redirect(user_info.sub, default_url, web_url, user)
|
||||
|
||||
response = RedirectResponse(final_url, status_code=302)
|
||||
set_response_cookie(
|
||||
request=request,
|
||||
response=response,
|
||||
keycloak_access_token=keycloak_access_token,
|
||||
keycloak_refresh_token=keycloak_refresh_token,
|
||||
secure=True if web_url.startswith('https') else False,
|
||||
accepted_tos=has_accepted_tos,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@oauth_router.get('/github/callback')
|
||||
@@ -549,6 +672,69 @@ async def authenticate(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
async def _should_redirect_to_onboarding(user_id: str, user: User) -> bool:
|
||||
"""Check if user should be redirected to onboarding after TOS acceptance.
|
||||
Backend always redirects applicable users to /onboarding.
|
||||
Returns True if:
|
||||
- User has onboarding_completed explicitly set to False (new users)
|
||||
- Either:
|
||||
- Deployment mode is 'cloud' (all users)
|
||||
- Deployment mode is 'self_hosted' AND user is the super admin
|
||||
(first owner in their current org to accept TOS)
|
||||
|
||||
Returns False if:
|
||||
- User has onboarding_completed=True (already completed)
|
||||
- User has onboarding_completed=None (existing users before this feature)
|
||||
"""
|
||||
# Already completed onboarding
|
||||
if user.onboarding_completed is True:
|
||||
return False
|
||||
|
||||
# Existing user before this feature (NULL in database)
|
||||
if user.onboarding_completed is None:
|
||||
return False
|
||||
|
||||
# Cloud SaaS: all users go to onboarding
|
||||
if DEPLOYMENT_MODE == 'cloud':
|
||||
return True
|
||||
|
||||
# Self-hosted SaaS: only the super admin (first owner to accept TOS in the org)
|
||||
if DEPLOYMENT_MODE == 'self_hosted':
|
||||
first_owner = await UserStore.get_first_owner_in_org(user.current_org_id)
|
||||
if first_owner and str(first_owner.id) == user_id:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _get_post_auth_redirect(
|
||||
user_id: str, default_url: str, web_url: str, user: User | None = None
|
||||
) -> str:
|
||||
"""Determine where to redirect user after authentication completes.
|
||||
|
||||
Called after offline token is stored to determine final redirect destination.
|
||||
Checks for pending user flows (e.g., onboarding) before falling back to default.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID.
|
||||
default_url: The default URL to redirect to if no special flow is needed.
|
||||
web_url: The base web URL for constructing absolute paths.
|
||||
user: Optional user object to avoid refetching.
|
||||
|
||||
Returns:
|
||||
The URL to redirect the user to.
|
||||
"""
|
||||
if not user:
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if user and await _should_redirect_to_onboarding(user_id, user):
|
||||
logger.info(
|
||||
'Redirecting user to onboarding',
|
||||
extra={'user_id': user_id, 'deployment_mode': DEPLOYMENT_MODE},
|
||||
)
|
||||
return f'{web_url}/onboarding'
|
||||
return default_url
|
||||
|
||||
|
||||
@api_router.post('/accept_tos')
|
||||
async def accept_tos(request: Request):
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
@@ -589,6 +775,12 @@ async def accept_tos(request: Request):
|
||||
|
||||
logger.info(f'User {user_id} accepted TOS')
|
||||
|
||||
# Determine final redirect - but don't override if it's the offline token flow
|
||||
# (the offline callback will handle post-auth redirect after storing the token)
|
||||
is_offline_flow = 'offline' in redirect_url
|
||||
if not is_offline_flow:
|
||||
redirect_url = await _get_post_auth_redirect(user_id, redirect_url, web_url)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
|
||||
)
|
||||
@@ -598,12 +790,42 @@ async def accept_tos(request: Request):
|
||||
response=response,
|
||||
keycloak_access_token=access_token.get_secret_value(),
|
||||
keycloak_refresh_token=refresh_token.get_secret_value(),
|
||||
secure=not IS_LOCAL_ENV,
|
||||
secure=True if web_url.startswith('https') else False,
|
||||
accepted_tos=True,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@api_router.post('/complete_onboarding')
|
||||
async def complete_onboarding(request: Request):
|
||||
"""Mark onboarding as completed for the current user."""
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'User is not authenticated'},
|
||||
)
|
||||
|
||||
user = await UserStore.mark_onboarding_completed(user_id)
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'User not found'},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'User completed onboarding',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Onboarding completed'},
|
||||
)
|
||||
|
||||
|
||||
@api_router.post('/logout')
|
||||
async def logout(request: Request):
|
||||
# Always create the response object first to ensure we can return it even if errors occur
|
||||
|
||||
@@ -20,6 +20,7 @@ from storage.org import Org
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.app_server.config import get_global_config
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -28,9 +29,7 @@ billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
|
||||
|
||||
|
||||
async def validate_billing_enabled() -> None:
|
||||
"""
|
||||
Validate that the billing feature flag is enabled
|
||||
"""
|
||||
"""Validate that the billing feature flag is enabled"""
|
||||
config = get_global_config()
|
||||
web_client_config = await config.web_client.get_web_client_config()
|
||||
if not web_client_config.feature_flags.enable_billing:
|
||||
@@ -299,6 +298,22 @@ async def success_callback(session_id: str, request: Request):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Analytics: credit purchased event (fires after commit so event only fires on success)
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user:
|
||||
consented = user.user_consents_to_analytics is True
|
||||
analytics.track_credit_purchased(
|
||||
distinct_id=billing_session.user_id,
|
||||
amount_usd=add_credits,
|
||||
credit_balance_before=max_budget,
|
||||
credit_balance_after=new_max_budget,
|
||||
org_id=str(user.current_org_id) if user.current_org_id else None,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:credit_purchased:failed')
|
||||
|
||||
return RedirectResponse(
|
||||
f'{get_web_url(request)}/settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ from storage.database import a_session_maker
|
||||
from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
|
||||
@@ -149,7 +149,12 @@ async def verify_jira_signature(body: bytes, signature: str, payload: dict):
|
||||
|
||||
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')
|
||||
logger.warning(
|
||||
'[Jira] No workspace name found in webhook payload',
|
||||
extra={
|
||||
'payload': payload,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403, detail='Workspace name not found in payload'
|
||||
)
|
||||
|
||||
@@ -335,6 +335,9 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks):
|
||||
2. Searches for repositories matching the user's query
|
||||
3. Returns up to 100 options for the dropdown
|
||||
|
||||
Note: "No Repository" is handled by a separate button in the form, so it's
|
||||
not included in the dropdown options. Error cases return an empty list.
|
||||
|
||||
Configuration: Set the Options Load URL in Slack App settings to:
|
||||
https://your-domain/slack/on-options-load
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from server.utils.url_utils import get_web_url
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
from openhands.analytics import get_analytics_service, resolve_context
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -311,6 +312,42 @@ async def device_verification_authenticated(
|
||||
'Device code authorized with API key successfully',
|
||||
extra={'user_code': user_code, 'user_id': user_id},
|
||||
)
|
||||
|
||||
# Server-side identity tracking for device auth flow
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
try:
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
# Load current org name for identify_user
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
current_org = (
|
||||
await OrgStore.get_org_by_id(ctx.user.current_org_id)
|
||||
if ctx.user and ctx.user.current_org_id
|
||||
else None
|
||||
)
|
||||
|
||||
analytics.identify_user(
|
||||
distinct_id=user_id,
|
||||
consented=ctx.consented,
|
||||
org_id=ctx.org_id,
|
||||
org_name=current_org.name if current_org else None,
|
||||
idp='device_auth',
|
||||
)
|
||||
|
||||
analytics.track_user_logged_in(
|
||||
distinct_id=user_id,
|
||||
idp='device_auth',
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'oauth_device:analytics:failed',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Device authorized successfully!'},
|
||||
|
||||
68
enterprise/server/routes/onboarding.py
Normal file
68
enterprise/server/routes/onboarding.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Onboarding submission endpoint.
|
||||
|
||||
Receives user onboarding selections and fires analytics event.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
onboarding_router = APIRouter(prefix='/api', tags=['Onboarding'])
|
||||
|
||||
|
||||
class OnboardingSubmission(BaseModel):
|
||||
selections: dict[
|
||||
str, str
|
||||
] # step_id -> option_id (e.g., {"step1": "software_engineer", "step2": "solo", "step3": "new_features"})
|
||||
|
||||
|
||||
class OnboardingResponse(BaseModel):
|
||||
status: str
|
||||
redirect_url: str
|
||||
|
||||
|
||||
@onboarding_router.post('/onboarding', response_model=OnboardingResponse)
|
||||
async def submit_onboarding(
|
||||
body: OnboardingSubmission,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> OnboardingResponse:
|
||||
"""Submit onboarding form selections and fire analytics event."""
|
||||
# ACTV-03: onboarding completed
|
||||
try:
|
||||
from openhands.analytics import get_analytics_service, resolve_context
|
||||
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
analytics.track_onboarding_completed(
|
||||
distinct_id=user_id,
|
||||
role=body.selections.get('step1'),
|
||||
org_size=body.selections.get('step2'),
|
||||
use_case=body.selections.get('step3'),
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
|
||||
# Associate onboarding timestamp with org group
|
||||
if ctx.org_id:
|
||||
analytics.group_identify(
|
||||
group_type='org',
|
||||
group_key=ctx.org_id,
|
||||
properties={
|
||||
'onboarding_completed_at': datetime.now(
|
||||
timezone.utc
|
||||
).isoformat(),
|
||||
},
|
||||
distinct_id=user_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception('analytics:onboarding_completed:failed')
|
||||
|
||||
return OnboardingResponse(status='ok', redirect_url='/')
|
||||
@@ -22,6 +22,7 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -94,6 +95,28 @@ async def create_invitation(
|
||||
},
|
||||
)
|
||||
|
||||
# Analytics: track team members invited
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
from storage.user_store import UserStore
|
||||
|
||||
user_obj = await UserStore.get_user_by_id(user_id)
|
||||
consented = (
|
||||
user_obj.user_consents_to_analytics is True if user_obj else False
|
||||
)
|
||||
analytics.track_team_members_invited(
|
||||
distinct_id=user_id,
|
||||
org_id=str(org_id),
|
||||
invited_count=len(invitation_data.emails),
|
||||
successful_count=len(successful),
|
||||
failed_count=len(failed),
|
||||
role=invitation_data.role,
|
||||
consented=consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:team_members_invited:failed')
|
||||
|
||||
successful_responses = [
|
||||
await InvitationResponse.from_invitation(inv) for inv in successful
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -12,6 +12,8 @@ from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
|
||||
|
||||
class OrgCreationError(Exception):
|
||||
"""Base exception for organization creation errors."""
|
||||
@@ -144,21 +146,16 @@ class OrgResponse(BaseModel):
|
||||
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
|
||||
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
|
||||
conversation_settings: ConversationSettings = Field(
|
||||
default_factory=ConversationSettings
|
||||
)
|
||||
search_api_key: str | None = None
|
||||
sandbox_api_key: str | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
@@ -171,33 +168,14 @@ class OrgResponse(BaseModel):
|
||||
def from_org(
|
||||
cls, org: Org, credits: float | None = None, user_id: str | None = None
|
||||
) -> 'OrgResponse':
|
||||
"""Create an OrgResponse from an Org entity.
|
||||
|
||||
Args:
|
||||
org: The organization entity to convert
|
||||
credits: Optional credits value (defaults to None)
|
||||
user_id: Optional user ID to determine if org is personal (defaults to None)
|
||||
|
||||
Returns:
|
||||
OrgResponse: The response model instance
|
||||
"""
|
||||
"""Create an OrgResponse from an Org entity."""
|
||||
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
|
||||
@@ -205,7 +183,12 @@ class OrgResponse(BaseModel):
|
||||
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,
|
||||
agent_settings=AgentSettings.model_validate(
|
||||
dict(org.agent_settings) if org.agent_settings else {}
|
||||
),
|
||||
conversation_settings=ConversationSettings.model_validate(
|
||||
dict(org.conversation_settings) if org.conversation_settings else {}
|
||||
),
|
||||
search_api_key=None,
|
||||
sandbox_api_key=None,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
@@ -227,7 +210,6 @@ class OrgPage(BaseModel):
|
||||
class OrgUpdate(BaseModel):
|
||||
"""Request model for updating an organization."""
|
||||
|
||||
# Basic organization information (any authenticated user can update)
|
||||
name: Annotated[
|
||||
str | None,
|
||||
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
|
||||
@@ -235,7 +217,6 @@ class OrgUpdate(BaseModel):
|
||||
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
|
||||
@@ -245,31 +226,20 @@ class OrgUpdate(BaseModel):
|
||||
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)
|
||||
agent_settings_diff: dict[str, Any] | None = None
|
||||
conversation_settings_diff: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class OrgLLMSettingsResponse(BaseModel):
|
||||
"""Response model for organization LLM settings."""
|
||||
"""Response model for organization default LLM settings."""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
agent_settings: AgentSettings = Field(default_factory=AgentSettings)
|
||||
conversation_settings: ConversationSettings = Field(
|
||||
default_factory=ConversationSettings
|
||||
)
|
||||
llm_api_key_set: bool = False
|
||||
search_api_key: str | None = None # Masked in response
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool = True
|
||||
condenser_max_size: int | None = None
|
||||
default_max_iterations: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(secret: SecretStr | None) -> str | None:
|
||||
@@ -287,83 +257,55 @@ class OrgLLMSettingsResponse(BaseModel):
|
||||
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
|
||||
"""Create response from Org entity."""
|
||||
return cls(
|
||||
default_llm_model=org.default_llm_model,
|
||||
default_llm_base_url=org.default_llm_base_url,
|
||||
agent_settings=AgentSettings.model_validate(
|
||||
dict(org.agent_settings) if org.agent_settings else {}
|
||||
),
|
||||
conversation_settings=ConversationSettings.model_validate(
|
||||
dict(org.conversation_settings) if org.conversation_settings else {}
|
||||
),
|
||||
llm_api_key_set=org.llm_api_key is not None,
|
||||
search_api_key=cls._mask_key(org.search_api_key),
|
||||
agent=org.agent,
|
||||
confirmation_mode=org.confirmation_mode,
|
||||
security_analyzer=org.security_analyzer,
|
||||
enable_default_condenser=org.enable_default_condenser
|
||||
if org.enable_default_condenser is not None
|
||||
else True,
|
||||
condenser_max_size=org.condenser_max_size,
|
||||
default_max_iterations=org.default_max_iterations,
|
||||
)
|
||||
|
||||
|
||||
class OrgMemberLLMSettings(BaseModel):
|
||||
"""LLM settings to propagate to organization members.
|
||||
"""Shared LLM settings that may be propagated to organization members."""
|
||||
|
||||
Field names match OrgMember DB columns.
|
||||
"""
|
||||
|
||||
llm_model: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
max_iterations: int | None = None
|
||||
agent_settings_diff: dict[str, Any] | None = None
|
||||
conversation_settings_diff: dict[str, Any] | None = None
|
||||
llm_api_key: str | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
return any(
|
||||
getattr(self, field) is not None for field in type(self).model_fields
|
||||
)
|
||||
|
||||
|
||||
class OrgLLMSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization LLM settings.
|
||||
"""Request model for updating organization LLM settings."""
|
||||
|
||||
Field names match Org DB columns exactly.
|
||||
"""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
agent_settings_diff: dict[str, Any] | None = None
|
||||
conversation_settings_diff: dict[str, Any] | None = None
|
||||
search_api_key: str | None = None
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool | None = None
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
default_max_iterations: int | None = Field(default=None, gt=0)
|
||||
llm_api_key: str | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
return any(
|
||||
getattr(self, field) is not None for field in type(self).model_fields
|
||||
)
|
||||
|
||||
def apply_to_org(self, org: Org) -> None:
|
||||
"""Apply non-None settings to the organization model.
|
||||
|
||||
Args:
|
||||
org: Organization entity to update in place
|
||||
"""
|
||||
for field_name in self.model_fields:
|
||||
value = getattr(self, field_name)
|
||||
# Skip llm_api_key - it's only for member propagation, not org-level
|
||||
if value is not None and field_name != 'llm_api_key':
|
||||
setattr(org, field_name, value)
|
||||
"""Apply non-None settings to the organization model."""
|
||||
if self.search_api_key is not None:
|
||||
org.search_api_key = self.search_api_key or None
|
||||
if self.llm_api_key is not None:
|
||||
org.llm_api_key = self.llm_api_key or None
|
||||
|
||||
def get_member_updates(self) -> OrgMemberLLMSettings | None:
|
||||
"""Get updates that need to be propagated to org members.
|
||||
|
||||
Returns:
|
||||
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
|
||||
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
|
||||
default_max_iterations → max_iterations, llm_api_key → llm_api_key
|
||||
"""
|
||||
member_settings = OrgMemberLLMSettings(
|
||||
llm_model=self.default_llm_model,
|
||||
llm_base_url=self.default_llm_base_url,
|
||||
max_iterations=self.default_max_iterations,
|
||||
llm_api_key=self.llm_api_key,
|
||||
)
|
||||
"""Get updates that need to be propagated to org members."""
|
||||
member_settings = OrgMemberLLMSettings(llm_api_key=self.llm_api_key)
|
||||
return member_settings if member_settings.has_updates() else None
|
||||
|
||||
|
||||
@@ -393,25 +335,28 @@ class OrgMemberUpdate(BaseModel):
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
"""Response model for the current user's membership in an organization."""
|
||||
"""Response model for the current user's membership in an organization.
|
||||
|
||||
``agent_settings_diff`` and ``conversation_settings_diff`` carry the
|
||||
member-level overrides on top of the organization defaults.
|
||||
"""
|
||||
|
||||
org_id: str
|
||||
user_id: str
|
||||
email: str
|
||||
role: str
|
||||
llm_api_key: str
|
||||
max_iterations: int | None = None
|
||||
llm_model: str | None = None
|
||||
llm_api_key_for_byor: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
agent_settings_diff: dict[str, Any] = Field(default_factory=dict)
|
||||
conversation_settings_diff: dict[str, Any] = Field(default_factory=dict)
|
||||
status: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(secret: SecretStr | None) -> str:
|
||||
def _mask_key(secret: str | SecretStr | None) -> str:
|
||||
"""Mask an API key, showing only last 4 characters."""
|
||||
if secret is None:
|
||||
return ''
|
||||
raw = secret.get_secret_value()
|
||||
raw = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
|
||||
if not raw:
|
||||
return ''
|
||||
if len(raw) <= 4:
|
||||
@@ -419,27 +364,22 @@ class MeResponse(BaseModel):
|
||||
return '****' + raw[-4:]
|
||||
|
||||
@classmethod
|
||||
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
|
||||
"""Create a MeResponse from an OrgMember, Role, and user email.
|
||||
|
||||
Args:
|
||||
member: The OrgMember entity
|
||||
role: The Role entity (provides role name)
|
||||
email: The user's email address
|
||||
|
||||
Returns:
|
||||
MeResponse with masked API keys
|
||||
"""
|
||||
def from_org_member(
|
||||
cls,
|
||||
member: OrgMember,
|
||||
role: Role,
|
||||
email: str,
|
||||
) -> 'MeResponse':
|
||||
"""Create a MeResponse from an OrgMember, Role, and user email."""
|
||||
return cls(
|
||||
org_id=str(member.org_id),
|
||||
user_id=str(member.user_id),
|
||||
email=email,
|
||||
role=role.name,
|
||||
llm_api_key=cls._mask_key(member.llm_api_key),
|
||||
max_iterations=member.max_iterations,
|
||||
llm_model=member.llm_model,
|
||||
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
|
||||
llm_base_url=member.llm_base_url,
|
||||
agent_settings_diff=dict(member.agent_settings_diff or {}),
|
||||
conversation_settings_diff=dict(member.conversation_settings_diff or {}),
|
||||
status=member.status,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -1105,6 +1106,29 @@ async def switch_org(
|
||||
org_id=org_id,
|
||||
)
|
||||
|
||||
# Refresh person profile with new active org on org switch
|
||||
analytics = get_analytics_service()
|
||||
if analytics:
|
||||
try:
|
||||
from openhands.analytics import resolve_context
|
||||
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
analytics.set_person_properties(
|
||||
distinct_id=user_id,
|
||||
properties={
|
||||
'org_id': str(org_id),
|
||||
'org_name': org.name,
|
||||
'plan_tier': None, # plan_tier not yet on Org model
|
||||
},
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
'orgs:switch_org:analytics:failed',
|
||||
extra={'user_id': user_id, 'org_id': str(org_id)},
|
||||
)
|
||||
|
||||
# Retrieve credits from LiteLLM for the new current org
|
||||
credits = await OrgService.get_org_credits(user_id, org.id)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from server.auth.token_manager import TokenManager
|
||||
from storage.user_store import UserStore
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
@@ -23,7 +24,6 @@ from openhands.microagent.types import (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.routes.git import (
|
||||
get_repository_branches,
|
||||
get_repository_microagent_content,
|
||||
@@ -45,7 +45,12 @@ saas_user_router = APIRouter(prefix='/api/user', dependencies=get_dependencies()
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
@saas_user_router.get('/installations', response_model=list[str])
|
||||
@saas_user_router.get(
|
||||
'/installations',
|
||||
response_model=list[str],
|
||||
deprecated=True,
|
||||
description='Deprecated: Use `/api/v1/git/installations` instead.',
|
||||
)
|
||||
async def saas_get_user_installations(
|
||||
provider: ProviderType,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
@@ -115,7 +120,12 @@ async def saas_get_user_git_organizations(
|
||||
}
|
||||
|
||||
|
||||
@saas_user_router.get('/repositories', response_model=list[Repository])
|
||||
@saas_user_router.get(
|
||||
'/repositories',
|
||||
response_model=list[Repository],
|
||||
deprecated=True,
|
||||
description='Deprecated: Use `/api/v1/git/repositories` instead.',
|
||||
)
|
||||
async def saas_get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
selected_provider: ProviderType | None = None,
|
||||
@@ -146,12 +156,13 @@ async def saas_get_user_repositories(
|
||||
)
|
||||
|
||||
|
||||
@saas_user_router.get('/info', response_model=User)
|
||||
@saas_user_router.get('/info', response_model=User, deprecated=True)
|
||||
async def saas_get_user(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> User | JSONResponse:
|
||||
"""Get the current user git info. Use GET /api/v1/users/git-info instead"""
|
||||
if not provider_tokens:
|
||||
if not access_token:
|
||||
return JSONResponse(
|
||||
|
||||
137
enterprise/server/routes/users_v1.py
Normal file
137
enterprise/server/routes/users_v1.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""SAAS-specific extensions for the /api/v1/users endpoints.
|
||||
|
||||
This module provides SAAS-specific implementations that extend the OSS
|
||||
user endpoints with organization context (org_id, org_name, role, permissions).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI, Header, HTTPException, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.models.user_models import SaasUserInfo
|
||||
|
||||
from openhands.app_server.config import (
|
||||
depends_user_context,
|
||||
resolve_provider_llm_base_url,
|
||||
)
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
saas_users_v1_router = APIRouter(
|
||||
prefix='/api/v1/users', tags=['User'], dependencies=get_dependencies()
|
||||
)
|
||||
user_dependency = depends_user_context()
|
||||
|
||||
|
||||
def _inject_sdk_compat_fields(
|
||||
content: dict[str, Any], *, include_api_key: bool
|
||||
) -> None:
|
||||
"""Inject flat top-level convenience fields for the SDK.
|
||||
|
||||
The SDK's ``get_llm()`` and ``get_mcp_config()`` read ``llm_model``,
|
||||
``llm_api_key``, ``llm_base_url``, and ``mcp_config`` from the top
|
||||
level of the ``/api/v1/users/me`` response. These values live inside
|
||||
the nested ``agent_settings`` structure, so we mirror them at the top
|
||||
level for backward compatibility.
|
||||
|
||||
The canonical representation is ``agent_settings``; these flat fields
|
||||
exist solely for SDK backward compatibility.
|
||||
"""
|
||||
agent_settings = content.get('agent_settings') or {}
|
||||
llm = agent_settings.get('llm') or {}
|
||||
model = llm.get('model')
|
||||
content['llm_model'] = model
|
||||
content['llm_base_url'] = resolve_provider_llm_base_url(model, llm.get('base_url'))
|
||||
if include_api_key:
|
||||
content['llm_api_key'] = llm.get('api_key')
|
||||
content['mcp_config'] = agent_settings.get('mcp_config')
|
||||
|
||||
|
||||
@saas_users_v1_router.get('/me')
|
||||
async def get_current_user_saas(
|
||||
user_context: UserContext = user_dependency,
|
||||
expose_secrets: bool = Query(
|
||||
default=False,
|
||||
description='If true, return unmasked secret values (e.g. llm_api_key). '
|
||||
'Requires a valid X-Session-API-Key header for an active sandbox '
|
||||
'owned by the authenticated user.',
|
||||
),
|
||||
x_session_api_key: str | None = Header(default=None),
|
||||
) -> SaasUserInfo:
|
||||
"""Get the current authenticated user with SAAS-specific org info.
|
||||
|
||||
Returns user settings along with organization context:
|
||||
- org_id: Current organization ID
|
||||
- org_name: Current organization name
|
||||
- role: User's role in the organization
|
||||
- permissions: List of permission strings for the role
|
||||
"""
|
||||
# Get base user info from the context
|
||||
base_user_info = await user_context.get_user_info()
|
||||
if base_user_info is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='Not authenticated')
|
||||
|
||||
# Build SAAS user info from base settings
|
||||
user_info_data = base_user_info.model_dump(
|
||||
mode='json', context={'expose_secrets': True}
|
||||
)
|
||||
|
||||
# Add org info if available (from SaasUserAuth)
|
||||
org_info = await _get_org_info_from_context(user_context)
|
||||
if org_info:
|
||||
user_info_data.update(org_info)
|
||||
|
||||
user_info = SaasUserInfo(**user_info_data)
|
||||
|
||||
if expose_secrets:
|
||||
await validate_session_key_ownership(user_context, x_session_api_key)
|
||||
content = user_info.model_dump(mode='json', context={'expose_secrets': True})
|
||||
_inject_sdk_compat_fields(content, include_api_key=True)
|
||||
return JSONResponse(content=content) # type: ignore[return-value]
|
||||
|
||||
content = user_info.model_dump(mode='json')
|
||||
_inject_sdk_compat_fields(content, include_api_key=False)
|
||||
return JSONResponse(content=content) # type: ignore[return-value]
|
||||
|
||||
|
||||
async def _get_org_info_from_context(user_context: UserContext) -> dict | None:
|
||||
"""Extract org info from the user context if available.
|
||||
|
||||
This works by checking if the underlying user_auth is a SaasUserAuth
|
||||
instance that has the get_org_info method.
|
||||
"""
|
||||
# Check if this is an AuthUserContext with a SaasUserAuth
|
||||
if isinstance(user_context, AuthUserContext):
|
||||
user_auth = user_context.user_auth
|
||||
if isinstance(user_auth, SaasUserAuth):
|
||||
return await user_auth.get_org_info()
|
||||
return None
|
||||
|
||||
|
||||
def override_users_me_endpoint(app: FastAPI) -> None:
|
||||
"""Override the OSS /api/v1/users/me endpoint with SAAS version.
|
||||
|
||||
This removes the base OSS endpoint and registers the SAAS version
|
||||
which includes organization context (org_id, org_name, role, permissions).
|
||||
|
||||
Must be called after the app is created in saas_server.py.
|
||||
"""
|
||||
# Find and remove the OSS /api/v1/users/me route
|
||||
routes_to_remove = []
|
||||
for route in app.routes:
|
||||
if hasattr(route, 'path') and route.path == '/api/v1/users/me':
|
||||
routes_to_remove.append(route)
|
||||
|
||||
for route in routes_to_remove:
|
||||
app.routes.remove(route)
|
||||
_logger.debug('Removed OSS route: %s', route.path)
|
||||
|
||||
# Add the SAAS version
|
||||
app.include_router(saas_users_v1_router)
|
||||
_logger.debug('Added SAAS /api/v1/users/me endpoint')
|
||||
@@ -27,7 +27,7 @@ from storage.stored_conversation_metadata_saas import StoredConversationMetadata
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.core.config import LLMConfig, OpenHandsConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig, RemoteMCPServer
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.event_store import EventStore
|
||||
@@ -497,10 +497,16 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
if not mcp_api_key:
|
||||
return None
|
||||
web_host = os.environ.get('WEB_HOST', 'app.all-hands.dev')
|
||||
shttp_servers = [
|
||||
MCPSHTTPServerConfig(url=f'https://{web_host}/mcp/mcp', api_key=mcp_api_key)
|
||||
]
|
||||
return MCPConfig(shttp_servers=shttp_servers)
|
||||
return MCPConfig(
|
||||
mcpServers={
|
||||
'openhands': RemoteMCPServer(
|
||||
url=f'https://{web_host}/mcp/mcp',
|
||||
transport='http',
|
||||
auth=mcp_api_key,
|
||||
timeout=60,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
async def _create_nested_conversation(
|
||||
self,
|
||||
@@ -523,9 +529,11 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
mcp_config = await self._get_mcp_config(user_id)
|
||||
if mcp_config:
|
||||
# Merge with any MCP config from settings
|
||||
if settings.mcp_config:
|
||||
mcp_config = mcp_config.merge(settings.mcp_config)
|
||||
# Check again since theoretically merge could return None.
|
||||
sdk_mcp = settings.agent_settings.mcp_config
|
||||
if sdk_mcp and sdk_mcp.mcpServers:
|
||||
from openhands.core.config.mcp_config import merge_mcp_configs
|
||||
|
||||
mcp_config = merge_mcp_configs(mcp_config, sdk_mcp)
|
||||
if mcp_config:
|
||||
init_conversation['mcp_config'] = mcp_config.model_dump()
|
||||
|
||||
@@ -855,7 +863,7 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
user_id=user_id,
|
||||
)
|
||||
llm_registry.retry_listner = session._notify_on_llm_retry
|
||||
agent_cls = settings.agent or self.config.default_agent
|
||||
agent_cls = settings.agent_settings.agent or self.config.default_agent
|
||||
agent_config = self.config.get_agent_config(agent_cls)
|
||||
agent = Agent.get_cls(agent_cls)(agent_config, llm_registry)
|
||||
|
||||
|
||||
@@ -365,15 +365,17 @@ class OrgInvitationService:
|
||||
'Failed to set up organization access. Please try again.'
|
||||
)
|
||||
|
||||
# Step 4.5: Fetch organization to get its LLM settings
|
||||
# Step 4.5: Ensure the organization still exists before adding membership
|
||||
org = await OrgStore.get_org_by_id(invitation.org_id)
|
||||
if not org:
|
||||
raise InvitationInvalidError('Organization not found')
|
||||
|
||||
# Step 5: Add user to organization with inherited org LLM settings
|
||||
# Get the llm_api_key as string (it's SecretStr | None in Settings)
|
||||
# Step 5: Add user to organization. New members start with no
|
||||
# personal agent-setting overrides so future org default changes
|
||||
# continue to flow through automatically.
|
||||
llm_api_key_secret = settings.agent_settings.llm.api_key
|
||||
llm_api_key = (
|
||||
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
|
||||
llm_api_key_secret.get_secret_value() if llm_api_key_secret else ''
|
||||
)
|
||||
|
||||
await OrgMemberStore.add_user_to_org(
|
||||
@@ -382,9 +384,8 @@ class OrgInvitationService:
|
||||
role_id=invitation.role_id,
|
||||
llm_api_key=llm_api_key,
|
||||
status='active',
|
||||
llm_model=org.default_llm_model,
|
||||
llm_base_url=org.default_llm_base_url,
|
||||
max_iterations=org.default_max_iterations,
|
||||
agent_settings_diff={},
|
||||
conversation_settings_diff={},
|
||||
)
|
||||
|
||||
# Step 6: Mark invitation as accepted
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from server.sharing.shared_conversation_models import (
|
||||
SharedConversation,
|
||||
SharedConversationPage,
|
||||
SharedConversationSortOrder,
|
||||
)
|
||||
|
||||
from openhands.app_server.services.injector import Injector
|
||||
@@ -16,32 +13,6 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
class SharedConversationInfoService(ABC):
|
||||
"""Service for accessing shared conversation info without user restrictions."""
|
||||
|
||||
@abstractmethod
|
||||
async def search_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sort_order: SharedConversationSortOrder = SharedConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
include_sub_conversations: bool = False,
|
||||
) -> SharedConversationPage:
|
||||
"""Search for shared conversations."""
|
||||
|
||||
@abstractmethod
|
||||
async def count_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count shared conversations."""
|
||||
|
||||
@abstractmethod
|
||||
async def get_shared_conversation_info(
|
||||
self, conversation_id: UUID
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
# Simplified imports to avoid dependency chain issues
|
||||
# from openhands.integrations.service_types import ProviderType
|
||||
@@ -40,17 +39,3 @@ class SharedConversation(BaseModel):
|
||||
|
||||
created_at: datetime = Field(default_factory=utc_now)
|
||||
updated_at: datetime = Field(default_factory=utc_now)
|
||||
|
||||
|
||||
class SharedConversationSortOrder(Enum):
|
||||
CREATED_AT = 'CREATED_AT'
|
||||
CREATED_AT_DESC = 'CREATED_AT_DESC'
|
||||
UPDATED_AT = 'UPDATED_AT'
|
||||
UPDATED_AT_DESC = 'UPDATED_AT_DESC'
|
||||
TITLE = 'TITLE'
|
||||
TITLE_DESC = 'TITLE_DESC'
|
||||
|
||||
|
||||
class SharedConversationPage(BaseModel):
|
||||
items: list[SharedConversation]
|
||||
next_page_id: str | None = None
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Shared Conversation router for OpenHands Server."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
@@ -10,8 +9,6 @@ from server.sharing.shared_conversation_info_service import (
|
||||
)
|
||||
from server.sharing.shared_conversation_models import (
|
||||
SharedConversation,
|
||||
SharedConversationPage,
|
||||
SharedConversationSortOrder,
|
||||
)
|
||||
from server.sharing.sql_shared_conversation_info_service import (
|
||||
SQLSharedConversationInfoServiceInjector,
|
||||
@@ -22,101 +19,13 @@ shared_conversation_info_service_dependency = Depends(
|
||||
SQLSharedConversationInfoServiceInjector().depends
|
||||
)
|
||||
|
||||
|
||||
# Read methods
|
||||
|
||||
|
||||
@router.get('/search')
|
||||
async def search_shared_conversations(
|
||||
title__contains: Annotated[
|
||||
str | None,
|
||||
Query(title='Filter by title containing this string'),
|
||||
] = None,
|
||||
created_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
created_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at less than this datetime'),
|
||||
] = None,
|
||||
updated_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
updated_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at less than this datetime'),
|
||||
] = None,
|
||||
sort_order: Annotated[
|
||||
SharedConversationSortOrder,
|
||||
Query(title='Sort order for results'),
|
||||
] = SharedConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
Query(
|
||||
title='The max number of results in the page',
|
||||
gt=0,
|
||||
le=100,
|
||||
),
|
||||
] = 100,
|
||||
include_sub_conversations: Annotated[
|
||||
bool,
|
||||
Query(
|
||||
title='If True, include sub-conversations in the results. If False (default), exclude all sub-conversations.'
|
||||
),
|
||||
] = False,
|
||||
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
|
||||
) -> SharedConversationPage:
|
||||
"""Search / List shared conversations."""
|
||||
return await shared_conversation_service.search_shared_conversation_info(
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
include_sub_conversations=include_sub_conversations,
|
||||
)
|
||||
|
||||
|
||||
@router.get('/count')
|
||||
async def count_shared_conversations(
|
||||
title__contains: Annotated[
|
||||
str | None,
|
||||
Query(title='Filter by title containing this string'),
|
||||
] = None,
|
||||
created_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
created_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by created_at less than this datetime'),
|
||||
] = None,
|
||||
updated_at__gte: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at greater than or equal to this datetime'),
|
||||
] = None,
|
||||
updated_at__lt: Annotated[
|
||||
datetime | None,
|
||||
Query(title='Filter by updated_at less than this datetime'),
|
||||
] = None,
|
||||
shared_conversation_service: SharedConversationInfoService = shared_conversation_info_service_dependency,
|
||||
) -> int:
|
||||
"""Count shared conversations matching the given filters."""
|
||||
return await shared_conversation_service.count_shared_conversation_info(
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
#
|
||||
# These endpoints are unauthenticated. Only batch lookup by known IDs is
|
||||
# exposed publicly so that share links of the form
|
||||
# /shared/conversations/<id> can be viewed without auth. Listing or
|
||||
# enumerating shared conversations is intentionally not exposed.
|
||||
|
||||
|
||||
@router.get('')
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
"""Shared Event router for OpenHands Server."""
|
||||
"""Shared Event router for OpenHands Server.
|
||||
|
||||
All endpoints in this router are unauthenticated — shared conversations are
|
||||
public. To avoid returning internal system state that the viewer does not
|
||||
need, ``ConversationStateUpdateEvent`` instances are filtered out before the
|
||||
response is sent. The shared-conversation frontend only renders messages,
|
||||
actions, observations, errors, and hook-execution events; state snapshots
|
||||
are consumed exclusively by the authenticated WebSocket path.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
@@ -13,9 +21,15 @@ from server.sharing.shared_event_service import (
|
||||
from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.sdk import Event
|
||||
from openhands.sdk.event.conversation_state import ConversationStateUpdateEvent
|
||||
from openhands.utils.environment import StorageProvider, get_storage_provider
|
||||
|
||||
|
||||
def _is_viewable(event: Event) -> bool:
|
||||
"""Return True if *event* should be included in public shared responses."""
|
||||
return not isinstance(event, ConversationStateUpdateEvent)
|
||||
|
||||
|
||||
def get_shared_event_service_injector() -> SharedEventServiceInjector:
|
||||
"""Get the appropriate SharedEventServiceInjector based on configuration.
|
||||
|
||||
@@ -87,15 +101,36 @@ async def search_shared_events(
|
||||
] = 100,
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> EventPage:
|
||||
"""Search / List events for a shared conversation."""
|
||||
return await shared_event_service.search_shared_events(
|
||||
conversation_id=UUID(conversation_id),
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
"""Search / List events for a shared conversation.
|
||||
|
||||
Because non-viewable events (e.g. ``ConversationStateUpdateEvent``) are
|
||||
filtered out after fetching, a single backend page may yield fewer items
|
||||
than *limit*. This method transparently fetches additional backend pages
|
||||
until the requested *limit* is reached or there are no more results.
|
||||
"""
|
||||
conv_id = UUID(conversation_id)
|
||||
viewable: list[Event] = []
|
||||
cursor = page_id
|
||||
|
||||
while len(viewable) < limit:
|
||||
remaining = limit - len(viewable)
|
||||
page = await shared_event_service.search_shared_events(
|
||||
conversation_id=conv_id,
|
||||
kind__eq=kind__eq,
|
||||
timestamp__gte=timestamp__gte,
|
||||
timestamp__lt=timestamp__lt,
|
||||
sort_order=sort_order,
|
||||
page_id=cursor,
|
||||
limit=remaining,
|
||||
)
|
||||
viewable.extend(e for e in page.items if _is_viewable(e))
|
||||
cursor = page.next_page_id
|
||||
if cursor is None:
|
||||
break
|
||||
|
||||
return EventPage(
|
||||
items=viewable[:limit],
|
||||
next_page_id=cursor,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,7 +182,7 @@ async def batch_get_shared_events(
|
||||
events = await shared_event_service.batch_get_shared_events(
|
||||
UUID(conversation_id), event_ids
|
||||
)
|
||||
return events
|
||||
return [e if e is not None and _is_viewable(e) else None for e in events]
|
||||
|
||||
|
||||
@router.get('/{conversation_id}/{event_id}')
|
||||
@@ -157,6 +192,9 @@ async def get_shared_event(
|
||||
shared_event_service: SharedEventService = shared_event_service_dependency,
|
||||
) -> Event | None:
|
||||
"""Get a single event from a shared conversation by conversation_id and event_id."""
|
||||
return await shared_event_service.get_shared_event(
|
||||
event = await shared_event_service.get_shared_event(
|
||||
UUID(conversation_id), UUID(event_id)
|
||||
)
|
||||
if event is not None and not _is_viewable(event):
|
||||
return None
|
||||
return event
|
||||
|
||||
@@ -21,8 +21,6 @@ from server.sharing.shared_conversation_info_service import (
|
||||
)
|
||||
from server.sharing.shared_conversation_models import (
|
||||
SharedConversation,
|
||||
SharedConversationPage,
|
||||
SharedConversationSortOrder,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -33,8 +31,7 @@ from openhands.app_server.app_conversation.sql_app_conversation_info_service imp
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.llm.utils.metrics import TokenUsage
|
||||
from openhands.sdk.llm import MetricsSnapshot, TokenUsage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,113 +42,6 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def search_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
sort_order: SharedConversationSortOrder = SharedConversationSortOrder.CREATED_AT_DESC,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
include_sub_conversations: bool = False,
|
||||
) -> SharedConversationPage:
|
||||
"""Search for shared conversations."""
|
||||
query = self._public_select_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(
|
||||
query=query,
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
|
||||
# Add sort order
|
||||
if sort_order == SharedConversationSortOrder.CREATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.created_at)
|
||||
elif sort_order == SharedConversationSortOrder.CREATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.created_at.desc())
|
||||
elif sort_order == SharedConversationSortOrder.UPDATED_AT:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at)
|
||||
elif sort_order == SharedConversationSortOrder.UPDATED_AT_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.last_updated_at.desc())
|
||||
elif sort_order == SharedConversationSortOrder.TITLE:
|
||||
query = query.order_by(StoredConversationMetadata.title)
|
||||
elif sort_order == SharedConversationSortOrder.TITLE_DESC:
|
||||
query = query.order_by(StoredConversationMetadata.title.desc())
|
||||
|
||||
# Apply pagination
|
||||
if page_id is not None:
|
||||
try:
|
||||
offset = int(page_id)
|
||||
query = query.offset(offset)
|
||||
except ValueError:
|
||||
# If page_id is not a valid integer, start from beginning
|
||||
offset = 0
|
||||
else:
|
||||
offset = 0
|
||||
|
||||
# Apply limit and get one extra to check if there are more results
|
||||
query = query.limit(limit + 1)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
# Check if there are more results
|
||||
has_more = len(rows) > limit
|
||||
if has_more:
|
||||
rows = rows[:limit]
|
||||
|
||||
items = [
|
||||
self._to_shared_conversation(stored, saas_metadata=saas_metadata)
|
||||
for stored, saas_metadata in rows
|
||||
]
|
||||
|
||||
# Calculate next page ID
|
||||
next_page_id = None
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
|
||||
return SharedConversationPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
async def count_shared_conversation_info(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
) -> int:
|
||||
"""Count shared conversations matching the given filters."""
|
||||
from sqlalchemy import func
|
||||
|
||||
query = select(func.count(StoredConversationMetadata.conversation_id))
|
||||
# Only include shared conversations
|
||||
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
query = query.where(StoredConversationMetadata.conversation_version == 'V1')
|
||||
|
||||
query = self._apply_filters(
|
||||
query=query,
|
||||
title__contains=title__contains,
|
||||
created_at__gte=created_at__gte,
|
||||
created_at__lt=created_at__lt,
|
||||
updated_at__gte=updated_at__gte,
|
||||
updated_at__lt=updated_at__lt,
|
||||
)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_shared_conversation_info(
|
||||
self, conversation_id: UUID
|
||||
) -> SharedConversation | None:
|
||||
@@ -169,15 +59,6 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
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."""
|
||||
query = select(StoredConversationMetadata).where(
|
||||
StoredConversationMetadata.conversation_version == 'V1'
|
||||
)
|
||||
# Only include conversations marked as public
|
||||
query = query.where(StoredConversationMetadata.public == True) # noqa: E712
|
||||
return query
|
||||
|
||||
def _public_select_with_saas_metadata(self):
|
||||
"""Create a select query that returns public conversations with SAAS metadata.
|
||||
|
||||
@@ -197,41 +78,6 @@ class SQLSharedConversationInfoService(SharedConversationInfoService):
|
||||
)
|
||||
return query
|
||||
|
||||
def _apply_filters(
|
||||
self,
|
||||
query,
|
||||
title__contains: str | None = None,
|
||||
created_at__gte: datetime | None = None,
|
||||
created_at__lt: datetime | None = None,
|
||||
updated_at__gte: datetime | None = None,
|
||||
updated_at__lt: datetime | None = None,
|
||||
):
|
||||
"""Apply common filters to a query."""
|
||||
if title__contains is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.title.contains(title__contains)
|
||||
)
|
||||
|
||||
if created_at__gte is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.created_at >= created_at__gte
|
||||
)
|
||||
|
||||
if created_at__lt is not None:
|
||||
query = query.where(StoredConversationMetadata.created_at < created_at__lt)
|
||||
|
||||
if updated_at__gte is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.last_updated_at >= updated_at__gte
|
||||
)
|
||||
|
||||
if updated_at__lt is not None:
|
||||
query = query.where(
|
||||
StoredConversationMetadata.last_updated_at < updated_at__lt
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
def _to_shared_conversation(
|
||||
self,
|
||||
stored: StoredConversationMetadata,
|
||||
|
||||
@@ -14,6 +14,7 @@ from storage.conversation_work import ConversationWork
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
from openhands.analytics import get_analytics_service
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.event_store import EventStore
|
||||
@@ -31,6 +32,15 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
config = load_openhands_config()
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
|
||||
# V0 terminal state sets for analytics
|
||||
_TERMINAL_ERROR_STATES = {AgentState.ERROR}
|
||||
_TERMINAL_FINISHED_STATES = {
|
||||
AgentState.FINISHED,
|
||||
AgentState.STOPPED,
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
}
|
||||
_ALL_TERMINAL_STATES = _TERMINAL_ERROR_STATES | _TERMINAL_FINISHED_STATES
|
||||
|
||||
|
||||
async def process_event(
|
||||
user_id: str, conversation_id: str, subpath: str, content: dict
|
||||
@@ -62,6 +72,120 @@ async def process_event(
|
||||
# Load and invoke all active callbacks for this conversation
|
||||
await invoke_conversation_callbacks(conversation_id, event)
|
||||
|
||||
# V0 best-effort analytics for terminal states
|
||||
if event.agent_state in _ALL_TERMINAL_STATES:
|
||||
try:
|
||||
analytics = get_analytics_service()
|
||||
if analytics and user_id:
|
||||
from openhands.analytics import resolve_context
|
||||
|
||||
ctx = await resolve_context(user_id)
|
||||
|
||||
# Look up conversation metadata for cost/token data
|
||||
with session_maker() as meta_session:
|
||||
conv_meta = (
|
||||
meta_session.query(StoredConversationMetadata)
|
||||
.filter(
|
||||
StoredConversationMetadata.conversation_id
|
||||
== conversation_id
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if event.agent_state in _TERMINAL_ERROR_STATES:
|
||||
analytics.track_conversation_errored(
|
||||
distinct_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
error_type='unknown', # V0: error classification not available from AgentState alone
|
||||
error_message=None,
|
||||
llm_model=conv_meta.llm_model
|
||||
if conv_meta and hasattr(conv_meta, 'llm_model')
|
||||
else None,
|
||||
turn_count=None,
|
||||
terminal_state=event.agent_state.value
|
||||
if hasattr(event.agent_state, 'value')
|
||||
else str(event.agent_state),
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
else:
|
||||
analytics.track_conversation_finished(
|
||||
distinct_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
terminal_state=event.agent_state.value
|
||||
if hasattr(event.agent_state, 'value')
|
||||
else str(event.agent_state),
|
||||
turn_count=None,
|
||||
accumulated_cost_usd=conv_meta.accumulated_cost
|
||||
if conv_meta
|
||||
else None,
|
||||
prompt_tokens=conv_meta.prompt_tokens
|
||||
if conv_meta
|
||||
else None,
|
||||
completion_tokens=conv_meta.completion_tokens
|
||||
if conv_meta
|
||||
else None,
|
||||
llm_model=conv_meta.llm_model
|
||||
if conv_meta and hasattr(conv_meta, 'llm_model')
|
||||
else None,
|
||||
trigger=None, # V0: trigger not available in callback context
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
|
||||
# ACTV-01: user activated (first finished conversation only)
|
||||
if event.agent_state == AgentState.FINISHED:
|
||||
try:
|
||||
import uuid as _uuid
|
||||
from datetime import timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import select as sa_select
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
user_uuid = _uuid.UUID(user_id)
|
||||
with session_maker() as act_session:
|
||||
count_result = act_session.execute(
|
||||
sa_select(func.count()).where(
|
||||
StoredConversationMetadataSaas.user_id
|
||||
== user_uuid,
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
!= conversation_id,
|
||||
)
|
||||
)
|
||||
prior_count = count_result.scalar()
|
||||
|
||||
if prior_count == 0:
|
||||
tos_ts = ctx.user.accepted_tos if ctx.user else None
|
||||
if tos_ts is not None:
|
||||
if tos_ts.tzinfo is None:
|
||||
tos_ts = tos_ts.replace(tzinfo=timezone.utc)
|
||||
from datetime import datetime
|
||||
|
||||
time_to_activate_seconds = (
|
||||
datetime.now(timezone.utc) - tos_ts
|
||||
).total_seconds()
|
||||
else:
|
||||
time_to_activate_seconds = None
|
||||
|
||||
analytics.track_user_activated(
|
||||
distinct_id=user_id,
|
||||
conversation_id=conversation_id,
|
||||
time_to_activate_seconds=time_to_activate_seconds,
|
||||
llm_model=conv_meta.llm_model
|
||||
if conv_meta
|
||||
else None,
|
||||
trigger=None, # V0: trigger not available in callback context
|
||||
org_id=ctx.org_id,
|
||||
consented=ctx.consented,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('analytics:user_activated:v0:failed')
|
||||
except Exception:
|
||||
logger.exception('analytics:v0_terminal_state:failed')
|
||||
|
||||
# Update active working seconds if agent state is not Running
|
||||
if event.agent_state != AgentState.RUNNING:
|
||||
event_store = EventStore(conversation_id, file_store, user_id)
|
||||
|
||||
@@ -363,6 +363,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
if api_key_org_id is not None:
|
||||
org_id = api_key_org_id
|
||||
|
||||
# Override with resolver org_id if set (from git org claim resolution)
|
||||
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
|
||||
if resolver_org_id is not None:
|
||||
org_id = resolver_org_id
|
||||
|
||||
# Check if SAAS metadata already exists
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(info.id)
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"""
|
||||
Unified SQLAlchemy declarative base for all models.
|
||||
|
||||
Re-exports the core Base to ensure enterprise and core models share the same
|
||||
metadata registry. This allows foreign key relationships between enterprise
|
||||
models (e.g., ConversationCallback) and core models (e.g., StoredConversationMetadata).
|
||||
|
||||
The core Base now uses SQLAlchemy 2.0 DeclarativeBase for proper type inference
|
||||
with Mapped types, while remaining backward compatible with existing Column()
|
||||
definitions.
|
||||
"""
|
||||
|
||||
from openhands.app_server.utils.sql_utils import Base
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import DECIMAL, Column, DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import DECIMAL, DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from storage.base import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from storage.org import Org
|
||||
|
||||
class BillingSession(Base): # type: ignore
|
||||
|
||||
class BillingSession(Base):
|
||||
"""
|
||||
Represents a Stripe billing session for credit purchases.
|
||||
Tracks the status of payment transactions and associated user information.
|
||||
"""
|
||||
|
||||
__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(
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
org_id: Mapped[UUID | None] = mapped_column(ForeignKey('org.id'), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(
|
||||
'in_progress',
|
||||
'completed',
|
||||
@@ -26,16 +32,16 @@ class BillingSession(Base): # type: ignore
|
||||
),
|
||||
default='in_progress',
|
||||
)
|
||||
price = Column(DECIMAL(19, 4), nullable=False)
|
||||
price_code = Column(String, nullable=False)
|
||||
created_at = Column(
|
||||
price: Mapped[Decimal] = mapped_column(DECIMAL(19, 4), nullable=False)
|
||||
price_code: Mapped[str] = mapped_column(String, nullable=False)
|
||||
created_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
default=lambda: datetime.now(UTC),
|
||||
)
|
||||
updated_at = Column(
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
default=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='billing_sessions')
|
||||
org: Mapped['Org | None'] = relationship('Org', back_populates='billing_sessions')
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import Column, DateTime, Integer, String
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@@ -25,21 +26,33 @@ class DeviceCode(Base):
|
||||
|
||||
__tablename__ = 'device_codes'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
device_code = Column(String(128), unique=True, nullable=False, index=True)
|
||||
user_code = Column(String(16), unique=True, nullable=False, index=True)
|
||||
status = Column(String(32), nullable=False, default=DeviceCodeStatus.PENDING.value)
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
device_code: Mapped[str] = mapped_column(
|
||||
String(128), unique=True, nullable=False, index=True
|
||||
)
|
||||
user_code: Mapped[str] = mapped_column(
|
||||
String(16), unique=True, nullable=False, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default=DeviceCodeStatus.PENDING.value
|
||||
)
|
||||
|
||||
# Keycloak user ID who authorized the device (set during verification)
|
||||
keycloak_user_id = Column(String(255), nullable=True)
|
||||
keycloak_user_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
authorized_at = Column(DateTime(timezone=True), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
authorized_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# Rate limiting fields for RFC 8628 section 3.5 compliance
|
||||
last_poll_time = Column(DateTime(timezone=True), nullable=True)
|
||||
current_interval = Column(Integer, nullable=False, default=5)
|
||||
last_poll_time: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
current_interval: Mapped[int] = mapped_column(nullable=False, default=5)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceCode(user_code='{self.user_code}', status='{self.status}')>"
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
from sqlalchemy import JSON, Column, DateTime, Enum, Integer, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Enum, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class Feedback(Base): # type: ignore
|
||||
class Feedback(Base):
|
||||
__tablename__ = 'feedback'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
version = Column(String, nullable=False)
|
||||
email = Column(String, nullable=False)
|
||||
polarity = Column(
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
version: Mapped[str] = mapped_column(String, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String, nullable=False)
|
||||
polarity: Mapped[str] = mapped_column(
|
||||
Enum('positive', 'negative', name='polarity_enum'), nullable=False
|
||||
)
|
||||
permissions = Column(
|
||||
permissions: Mapped[str] = mapped_column(
|
||||
Enum('public', 'private', name='permissions_enum'), nullable=False
|
||||
)
|
||||
trajectory = Column(JSON, nullable=True)
|
||||
trajectory: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
|
||||
class ConversationFeedback(Base): # type: ignore
|
||||
class ConversationFeedback(Base):
|
||||
__tablename__ = 'conversation_feedback'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
conversation_id = Column(String, nullable=False, index=True)
|
||||
event_id = Column(Integer, nullable=True)
|
||||
rating = Column(Integer, nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
conversation_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
event_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
rating: Mapped[int] = mapped_column(nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ from server.constants import (
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.logger import logger
|
||||
from storage.encrypt_utils import decrypt_legacy_value
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.server.settings import Settings
|
||||
@@ -216,11 +215,18 @@ class LiteLlmManager:
|
||||
None,
|
||||
)
|
||||
|
||||
oss_settings.agent = 'CodeActAgent'
|
||||
# Use the model corresponding to the current user settings version
|
||||
oss_settings.llm_model = get_default_litellm_model()
|
||||
oss_settings.llm_api_key = SecretStr(key)
|
||||
oss_settings.llm_base_url = LITE_LLM_API_URL
|
||||
oss_settings.update(
|
||||
{
|
||||
'agent_settings': {
|
||||
'agent': 'CodeActAgent',
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'api_key': key,
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
return oss_settings
|
||||
|
||||
@staticmethod
|
||||
@@ -354,12 +360,16 @@ class LiteLlmManager:
|
||||
# Check if the database key exists in LiteLLM
|
||||
# If not, generate a new key to prevent verification failures later
|
||||
db_key = None
|
||||
if (
|
||||
user_settings
|
||||
and user_settings.llm_api_key
|
||||
and user_settings.llm_base_url == LITE_LLM_API_URL
|
||||
):
|
||||
db_key = user_settings.llm_api_key
|
||||
llm_base_url = None
|
||||
# agent_settings is a JSON column (dict) on UserSettings
|
||||
llm_cfg = (
|
||||
(user_settings.agent_settings or {}).get('llm', {})
|
||||
if user_settings
|
||||
else {}
|
||||
)
|
||||
llm_base_url = llm_cfg.get('base_url')
|
||||
if llm_base_url == LITE_LLM_API_URL:
|
||||
db_key = llm_cfg.get('api_key')
|
||||
if hasattr(db_key, 'get_secret_value'):
|
||||
db_key = db_key.get_secret_value()
|
||||
|
||||
@@ -392,8 +402,13 @@ class LiteLlmManager:
|
||||
extra={'org_id': org_id, 'user_id': keycloak_user_id},
|
||||
)
|
||||
# Update user_settings with the new key so it gets stored in org_member
|
||||
user_settings.llm_api_key = SecretStr(new_key)
|
||||
user_settings.llm_api_key_for_byor = SecretStr(new_key)
|
||||
# agent_settings is a JSON column (dict) on UserSettings
|
||||
if user_settings.agent_settings is None:
|
||||
user_settings.agent_settings = {}
|
||||
user_settings.agent_settings.setdefault('llm', {})[
|
||||
'api_key'
|
||||
] = new_key
|
||||
user_settings.llm_api_key_for_byor_secret = SecretStr(new_key)
|
||||
|
||||
logger.info(
|
||||
'LiteLlmManager:migrate_lite_llm_entries:complete',
|
||||
@@ -861,13 +876,6 @@ class LiteLlmManager:
|
||||
logger.warning('LiteLLM API configuration not found')
|
||||
return
|
||||
|
||||
try:
|
||||
# Sometimes the key we get is encrypted - attempt to decrypt.
|
||||
key = decrypt_legacy_value(key)
|
||||
except Exception:
|
||||
# The key was not encrypted
|
||||
pass
|
||||
|
||||
payload = {
|
||||
'key': key,
|
||||
}
|
||||
|
||||
@@ -21,14 +21,7 @@ class Org(Base): # type: ignore
|
||||
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
|
||||
@@ -36,7 +29,10 @@ class Org(Base): # type: ignore
|
||||
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)
|
||||
agent_settings = Column(JSON, nullable=False, default=dict)
|
||||
conversation_settings = Column(JSON, nullable=False, default=dict)
|
||||
# encrypted column, don't set directly, set without the underscore
|
||||
_llm_api_key = Column(String, 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
|
||||
@@ -45,7 +41,6 @@ class Org(Base): # type: ignore
|
||||
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)
|
||||
byor_export_enabled = Column(Boolean, nullable=False, default=False)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
@@ -67,12 +62,21 @@ class Org(Base): # type: ignore
|
||||
git_claims = relationship('OrgGitClaim', back_populates='org')
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
# Serialize Pydantic model objects to dicts for JSON columns.
|
||||
from pydantic import BaseModel
|
||||
|
||||
for key in ('agent_settings', 'conversation_settings'):
|
||||
if key in kwargs and isinstance(kwargs[key], BaseModel):
|
||||
kwargs[key] = kwargs[key].model_dump(mode='json')
|
||||
|
||||
# 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 'search_api_key' in kwargs:
|
||||
self.search_api_key = kwargs.pop('search_api_key')
|
||||
if 'sandbox_api_key' in kwargs:
|
||||
@@ -81,6 +85,18 @@ class Org(Base): # type: ignore
|
||||
if kwargs:
|
||||
raise TypeError(f'Unexpected keyword arguments: {list(kwargs.keys())}')
|
||||
|
||||
@property
|
||||
def llm_api_key(self) -> SecretStr | None:
|
||||
if self._llm_api_key:
|
||||
decrypted = decrypt_value(self._llm_api_key)
|
||||
return SecretStr(decrypted)
|
||||
return None
|
||||
|
||||
@llm_api_key.setter
|
||||
def llm_api_key(self, value: str | SecretStr | None):
|
||||
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
|
||||
self._llm_api_key = encrypt_value(raw) if raw else None
|
||||
|
||||
@property
|
||||
def search_api_key(self) -> SecretStr | None:
|
||||
if self._search_api_key:
|
||||
|
||||
@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgAppSettingsStore:
|
||||
@@ -65,8 +67,15 @@ class OrgAppSettingsStore:
|
||||
"""
|
||||
if org.org_version < ORG_SETTINGS_VERSION:
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
org.llm_base_url = LITE_LLM_API_URL
|
||||
org.agent_settings = deep_merge(
|
||||
org.agent_settings,
|
||||
{
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
},
|
||||
)
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.user import User
|
||||
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsStore:
|
||||
@@ -49,7 +50,6 @@ class OrgLLMSettingsStore:
|
||||
) -> Org | None:
|
||||
"""Update organization LLM settings.
|
||||
|
||||
Also propagates relevant settings to all org members.
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
@@ -67,14 +67,16 @@ class OrgLLMSettingsStore:
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Apply updates to org (excludes llm_api_key which is member-only)
|
||||
update_data.apply_to_org(org)
|
||||
|
||||
# Propagate relevant settings to all org members
|
||||
member_updates = update_data.get_member_updates()
|
||||
if member_updates:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
self.db_session, org_id, member_updates
|
||||
if update_data.agent_settings_diff:
|
||||
org.agent_settings = deep_merge(
|
||||
org.agent_settings,
|
||||
update_data.agent_settings_diff,
|
||||
)
|
||||
if update_data.conversation_settings_diff:
|
||||
org.conversation_settings = deep_merge(
|
||||
org.conversation_settings,
|
||||
update_data.conversation_settings_diff,
|
||||
)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
|
||||
@@ -3,7 +3,7 @@ SQLAlchemy model for Organization-Member relationship.
|
||||
"""
|
||||
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy import JSON, UUID, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy import JSON, UUID, Boolean, Column, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
from storage.base import Base
|
||||
from storage.encrypt_utils import decrypt_value, encrypt_value
|
||||
@@ -18,12 +18,11 @@ class OrgMember(Base): # type: ignore
|
||||
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)
|
||||
has_custom_llm_api_key = Column(Boolean, nullable=False, default=False)
|
||||
agent_settings_diff = Column(JSON, nullable=False, default=dict)
|
||||
conversation_settings_diff = Column(JSON, nullable=False, default=dict)
|
||||
status = Column(String, nullable=True)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org = relationship('Org', back_populates='org_members')
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
Store class for managing organization-member relationships.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker
|
||||
from storage.encrypt_utils import encrypt_value
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
|
||||
|
||||
class OrgMemberStore:
|
||||
@@ -28,9 +28,8 @@ class OrgMemberStore:
|
||||
role_id: int,
|
||||
llm_api_key: str,
|
||||
status: Optional[str] = None,
|
||||
llm_model: Optional[str] = None,
|
||||
llm_base_url: Optional[str] = None,
|
||||
max_iterations: Optional[int] = None,
|
||||
agent_settings_diff: Optional[dict[str, Any]] = None,
|
||||
conversation_settings_diff: Optional[dict[str, Any]] = None,
|
||||
) -> OrgMember:
|
||||
"""Add a user to an organization with a specific role."""
|
||||
async with a_session_maker() as session:
|
||||
@@ -40,9 +39,8 @@ class OrgMemberStore:
|
||||
role_id=role_id,
|
||||
llm_api_key=llm_api_key,
|
||||
status=status,
|
||||
llm_model=llm_model,
|
||||
llm_base_url=llm_base_url,
|
||||
max_iterations=max_iterations,
|
||||
agent_settings_diff=dict(agent_settings_diff or {}),
|
||||
conversation_settings_diff=dict(conversation_settings_diff or {}),
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
@@ -149,22 +147,22 @@ class OrgMemberStore:
|
||||
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)
|
||||
def get_kwargs_from_settings(settings: Settings) -> dict[str, Any]:
|
||||
"""Return kwargs for OrgMember construction (keys match column names)."""
|
||||
return {
|
||||
'llm_api_key': settings.agent_settings.llm.api_key,
|
||||
'agent_settings_diff': {},
|
||||
'conversation_settings_diff': {},
|
||||
}
|
||||
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)
|
||||
def get_kwargs_from_user_settings(user_settings: UserSettings) -> dict[str, Any]:
|
||||
"""Return kwargs for OrgMember construction (keys match column names)."""
|
||||
return {
|
||||
'llm_api_key': user_settings.llm_api_key,
|
||||
'agent_settings_diff': dict(user_settings.agent_settings),
|
||||
'conversation_settings_diff': dict(user_settings.conversation_settings),
|
||||
}
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
async def get_org_members_count(
|
||||
@@ -244,21 +242,41 @@ class OrgMemberStore:
|
||||
org_id: UUID,
|
||||
member_settings: OrgMemberLLMSettings,
|
||||
) -> None:
|
||||
"""Update LLM settings for all members of an organization.
|
||||
"""Update shared LLM settings for all members of an organization.
|
||||
|
||||
Args:
|
||||
session: Database session (passed from caller for transaction)
|
||||
org_id: Organization ID
|
||||
member_settings: Typed LLM settings to apply to all members
|
||||
member_settings: Shared settings to apply to all members
|
||||
"""
|
||||
# Build update values from non-None fields
|
||||
values = member_settings.model_dump(exclude_none=True)
|
||||
if not values:
|
||||
return
|
||||
|
||||
# Handle encrypted llm_api_key field - map to _llm_api_key column with encryption
|
||||
if 'llm_api_key' in values:
|
||||
raw_key = values.pop('llm_api_key')
|
||||
values['_llm_api_key'] = encrypt_value(raw_key)
|
||||
result = await session.execute(
|
||||
select(OrgMember).where(OrgMember.org_id == org_id)
|
||||
)
|
||||
org_members = list(result.scalars().all())
|
||||
|
||||
if values:
|
||||
stmt = update(OrgMember).where(OrgMember.org_id == org_id).values(**values)
|
||||
await session.execute(stmt)
|
||||
raw_key = values.pop('llm_api_key', None)
|
||||
agent_settings_diff = values.pop('agent_settings_diff', None)
|
||||
conversation_settings_diff = values.pop('conversation_settings_diff', None)
|
||||
|
||||
for org_member in org_members:
|
||||
if raw_key is not None:
|
||||
org_member.llm_api_key = raw_key
|
||||
|
||||
if agent_settings_diff is not None:
|
||||
org_member.agent_settings_diff = deep_merge(
|
||||
org_member.agent_settings_diff,
|
||||
agent_settings_diff,
|
||||
)
|
||||
|
||||
if conversation_settings_diff is not None:
|
||||
org_member.conversation_settings_diff = deep_merge(
|
||||
org_member.conversation_settings_diff,
|
||||
conversation_settings_diff,
|
||||
)
|
||||
|
||||
for key, value in values.items():
|
||||
setattr(org_member, key, value)
|
||||
|
||||
@@ -25,6 +25,7 @@ from storage.role_store import RoleStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
@@ -107,13 +108,16 @@ class OrgService:
|
||||
Returns:
|
||||
Org: New organization entity (not yet persisted)
|
||||
"""
|
||||
default_agent_settings = AgentSettings()
|
||||
default_agent_settings.llm.model = get_default_litellm_model()
|
||||
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(),
|
||||
agent_settings=default_agent_settings,
|
||||
conversation_settings=ConversationSettings(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -467,42 +471,6 @@ class OrgService:
|
||||
)
|
||||
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,
|
||||
@@ -571,33 +539,6 @@ class OrgService:
|
||||
)
|
||||
raise OrgNameExistsError(update_data.name)
|
||||
|
||||
# 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 = await 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:
|
||||
@@ -607,6 +548,29 @@ class OrgService:
|
||||
)
|
||||
return existing_org
|
||||
|
||||
restricted_fields = {
|
||||
'agent_settings_diff',
|
||||
'conversation_settings_diff',
|
||||
'search_api_key',
|
||||
'sandbox_api_key',
|
||||
}
|
||||
if restricted_fields.intersection(
|
||||
update_dict
|
||||
) and not await OrgService.has_admin_or_owner_role(user_id, org_id):
|
||||
logger.warning(
|
||||
'Insufficient role for restricted organization settings update',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'org_id': str(org_id),
|
||||
'restricted_fields': sorted(
|
||||
restricted_fields.intersection(update_dict)
|
||||
),
|
||||
},
|
||||
)
|
||||
raise PermissionError(
|
||||
'Admin or owner role required to update organization agent settings'
|
||||
)
|
||||
|
||||
# Perform the update
|
||||
try:
|
||||
updated_org = await OrgStore.update_org(org_id, update_dict)
|
||||
|
||||
@@ -22,12 +22,44 @@ from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
|
||||
_ORG_SETTINGS_EXCLUDED_FIELDS = {
|
||||
'id',
|
||||
'name',
|
||||
'contact_name',
|
||||
'contact_email',
|
||||
'org_version',
|
||||
'llm_api_key',
|
||||
}
|
||||
_ORG_SETTINGS_FIELDS = {
|
||||
normalized
|
||||
for column in Org.__table__.columns
|
||||
if (normalized := column.name.lstrip('_')) not in _ORG_SETTINGS_EXCLUDED_FIELDS
|
||||
}
|
||||
|
||||
|
||||
class OrgStore:
|
||||
"""Store for managing organizations."""
|
||||
|
||||
@staticmethod
|
||||
def get_agent_settings_from_org(org: Org) -> AgentSettings:
|
||||
return AgentSettings.model_validate(dict(org.agent_settings))
|
||||
|
||||
@staticmethod
|
||||
def get_conversation_settings_from_org(org: Org) -> ConversationSettings:
|
||||
return ConversationSettings.model_validate(dict(org.conversation_settings))
|
||||
|
||||
@staticmethod
|
||||
def sync_agent_settings(org: Org) -> None:
|
||||
org.agent_settings = dict(org.agent_settings)
|
||||
|
||||
@staticmethod
|
||||
def sync_conversation_settings(org: Org) -> None:
|
||||
org.conversation_settings = dict(org.conversation_settings)
|
||||
|
||||
@staticmethod
|
||||
async def create_org(
|
||||
kwargs: dict,
|
||||
@@ -36,7 +68,16 @@ class OrgStore:
|
||||
async with a_session_maker() as session:
|
||||
org = Org(**kwargs)
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
agent_settings = org.agent_settings or {}
|
||||
org.agent_settings = deep_merge(
|
||||
agent_settings,
|
||||
{
|
||||
'llm': {
|
||||
'model': agent_settings.get('llm', {}).get('model')
|
||||
or get_default_litellm_model()
|
||||
}
|
||||
},
|
||||
)
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
session.add(org)
|
||||
@@ -92,8 +133,12 @@ class OrgStore:
|
||||
org.id,
|
||||
{
|
||||
'org_version': ORG_SETTINGS_VERSION,
|
||||
'default_llm_model': get_default_litellm_model(),
|
||||
'llm_base_url': LITE_LLM_API_URL,
|
||||
'agent_settings_diff': {
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
return org
|
||||
@@ -180,56 +225,43 @@ class OrgStore:
|
||||
|
||||
if 'id' in kwargs:
|
||||
kwargs.pop('id')
|
||||
|
||||
agent_settings_diff = kwargs.pop('agent_settings_diff', None)
|
||||
conversation_settings_diff = kwargs.pop('conversation_settings_diff', None)
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
if agent_settings_diff is not None:
|
||||
org.agent_settings = deep_merge(
|
||||
org.agent_settings,
|
||||
agent_settings_diff,
|
||||
)
|
||||
|
||||
if conversation_settings_diff is not None:
|
||||
org.conversation_settings = deep_merge(
|
||||
org.conversation_settings,
|
||||
conversation_settings_diff,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await 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
|
||||
dumped = settings.model_dump(mode='json', context={'expose_secrets': True})
|
||||
return {
|
||||
field: dumped[field] for field in _ORG_SETTINGS_FIELDS if field in dumped
|
||||
}
|
||||
|
||||
@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 = {
|
||||
field: getattr(user_settings, field)
|
||||
for field in _ORG_SETTINGS_FIELDS
|
||||
if hasattr(user_settings, field)
|
||||
}
|
||||
kwargs['org_version'] = user_settings.user_version
|
||||
return kwargs
|
||||
|
||||
@@ -431,8 +463,17 @@ class OrgStore:
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Apply updates to org
|
||||
llm_settings.apply_to_org(org)
|
||||
if llm_settings.agent_settings_diff is not None:
|
||||
org.agent_settings = deep_merge(
|
||||
org.agent_settings,
|
||||
llm_settings.agent_settings_diff,
|
||||
)
|
||||
if llm_settings.conversation_settings_diff is not None:
|
||||
org.conversation_settings = deep_merge(
|
||||
org.conversation_settings,
|
||||
llm_settings.conversation_settings_diff,
|
||||
)
|
||||
|
||||
# Propagate relevant settings to all org members
|
||||
member_updates = llm_settings.get_member_updates()
|
||||
|
||||
@@ -34,10 +34,17 @@ class SaasConversationStore(ConversationStore):
|
||||
session_maker: sessionmaker
|
||||
org_id: UUID | None = None # will be fetched automatically
|
||||
|
||||
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
session_maker: sessionmaker,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
self.session_maker = session_maker
|
||||
self.resolver_org_id = resolver_org_id
|
||||
|
||||
def _select_by_id(self, session, conversation_id: str):
|
||||
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
|
||||
@@ -103,6 +110,13 @@ class SaasConversationStore(ConversationStore):
|
||||
|
||||
stored_metadata = StoredConversationMetadata(**kwargs)
|
||||
|
||||
# Override with resolver org_id if set (from git org claim resolution),
|
||||
# same pattern as V1's save_app_conversation_info in
|
||||
# saas_app_conversation_info_injector.py
|
||||
org_id = self.org_id
|
||||
if self.resolver_org_id is not None:
|
||||
org_id = self.resolver_org_id
|
||||
|
||||
def _save_metadata():
|
||||
with self.session_maker() as session:
|
||||
# Save the main conversation metadata
|
||||
@@ -122,13 +136,13 @@ class SaasConversationStore(ConversationStore):
|
||||
saas_metadata = StoredConversationMetadataSaas(
|
||||
conversation_id=stored_metadata.conversation_id,
|
||||
user_id=UUID(self.user_id),
|
||||
org_id=self.org_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
session.add(saas_metadata)
|
||||
else:
|
||||
# Validate
|
||||
expected_user_id = UUID(self.user_id)
|
||||
expected_org_id = self.org_id
|
||||
expected_org_id = org_id
|
||||
|
||||
if saas_metadata.user_id != expected_user_id:
|
||||
raise ValueError(
|
||||
@@ -240,3 +254,19 @@ class SaasConversationStore(ConversationStore):
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker)
|
||||
|
||||
@classmethod
|
||||
async def get_resolver_instance(
|
||||
cls,
|
||||
config: OpenHandsConfig,
|
||||
user_id: str,
|
||||
resolver_org_id: UUID | None = None,
|
||||
) -> 'SaasConversationStore':
|
||||
"""Get a store for resolver conversations with explicit org routing.
|
||||
|
||||
Unlike get_instance, this accepts a resolver_org_id that overrides
|
||||
the user's default org when saving conversation metadata.
|
||||
"""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import hashlib
|
||||
import uuid
|
||||
from base64 import b64decode, b64encode
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.constants import LITE_LLM_API_URL
|
||||
from server.logger import logger
|
||||
from sqlalchemy import select, update
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker
|
||||
from storage.encrypt_utils import encrypt_value
|
||||
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_member_store import OrgMemberStore
|
||||
from storage.org_store import OrgStore
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
@@ -26,6 +24,7 @@ from storage.user_store import UserStore
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
from openhands.utils.llm import is_openhands_model
|
||||
|
||||
|
||||
@@ -33,7 +32,6 @@ from openhands.utils.llm import is_openhands_model
|
||||
class SaasSettingsStore(SettingsStore):
|
||||
user_id: str
|
||||
config: OpenHandsConfig
|
||||
ENCRYPT_VALUES = ['llm_api_key', 'llm_api_key_for_byor', 'search_api_key']
|
||||
|
||||
async def _get_user_settings_by_keycloak_id_async(
|
||||
self, keycloak_user_id: str, session=None
|
||||
@@ -69,6 +67,19 @@ class SaasSettingsStore(SettingsStore):
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
def _get_effective_llm_api_key(
|
||||
org: Org,
|
||||
org_member: OrgMember,
|
||||
) -> SecretStr | None:
|
||||
if org_member.has_custom_llm_api_key:
|
||||
return org_member.llm_api_key
|
||||
return org.llm_api_key or org_member.llm_api_key
|
||||
|
||||
@staticmethod
|
||||
def _get_persisted_agent_settings(item: Settings) -> dict[str, Any]:
|
||||
return item.agent_settings.model_dump(mode='json')
|
||||
|
||||
async def load(self) -> Settings | None:
|
||||
user = await UserStore.get_user_by_id(self.user_id)
|
||||
if not user:
|
||||
@@ -81,7 +92,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
if om.org_id == org_id:
|
||||
org_member = om
|
||||
break
|
||||
if not org_member or not org_member.llm_api_key:
|
||||
if not org_member:
|
||||
return None
|
||||
org = await OrgStore.get_org_by_id_async(org_id)
|
||||
if not org:
|
||||
@@ -89,6 +100,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
|
||||
)
|
||||
return None
|
||||
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
|
||||
member_agent_settings_diff = dict(org_member.agent_settings_diff)
|
||||
|
||||
kwargs = {
|
||||
**{
|
||||
normalized: getattr(org, c.name)
|
||||
@@ -106,26 +120,36 @@ class SaasSettingsStore(SettingsStore):
|
||||
if (normalized := c.name.lstrip('_')) in Settings.model_fields
|
||||
},
|
||||
}
|
||||
kwargs['llm_api_key'] = org_member.llm_api_key
|
||||
if org_member.max_iterations:
|
||||
kwargs['max_iterations'] = org_member.max_iterations
|
||||
if org_member.llm_model:
|
||||
kwargs['llm_model'] = org_member.llm_model
|
||||
if org_member.llm_api_key_for_byor:
|
||||
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
|
||||
if org_member.llm_base_url:
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
# MCP config is user-specific (stored on org_member, not org)
|
||||
if org_member.mcp_config is not None:
|
||||
kwargs['mcp_config'] = org_member.mcp_config
|
||||
merged_agent_settings = deep_merge(
|
||||
org_agent_settings.model_dump(mode='json'),
|
||||
member_agent_settings_diff,
|
||||
)
|
||||
effective_llm_api_key = self._get_effective_llm_api_key(org, org_member)
|
||||
if effective_llm_api_key is not None:
|
||||
merged_agent_settings.setdefault('llm', {})['api_key'] = (
|
||||
effective_llm_api_key.get_secret_value()
|
||||
if isinstance(effective_llm_api_key, SecretStr)
|
||||
else effective_llm_api_key
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'No effective LLM API key found for user {self.user_id} '
|
||||
f'in org {org_id} (org key and member key are both unset)'
|
||||
)
|
||||
kwargs['agent_settings'] = merged_agent_settings
|
||||
org_conversation = OrgStore.get_conversation_settings_from_org(org)
|
||||
member_conversation_diff = dict(org_member.conversation_settings_diff)
|
||||
kwargs['conversation_settings'] = deep_merge(
|
||||
org_conversation.model_dump(mode='json'),
|
||||
member_conversation_diff,
|
||||
)
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
if kwargs.get('sandbox_grouping_strategy') is None:
|
||||
kwargs.pop('sandbox_grouping_strategy', None)
|
||||
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
return Settings(**kwargs)
|
||||
|
||||
async def store(self, item: Settings):
|
||||
async with a_session_maker() as session:
|
||||
@@ -170,7 +194,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
if om.org_id == org_id:
|
||||
org_member = om
|
||||
break
|
||||
if not org_member or not org_member.llm_api_key:
|
||||
if not org_member:
|
||||
return None
|
||||
|
||||
result = await session.execute(select(Org).filter(Org.id == org_id))
|
||||
@@ -181,56 +205,88 @@ class SaasSettingsStore(SettingsStore):
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if we need to generate an LLM key.
|
||||
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
|
||||
llm_model = item.agent_settings.llm.model
|
||||
llm_base_url = item.agent_settings.llm.base_url
|
||||
normalized_llm_base_url = llm_base_url.rstrip('/') if llm_base_url else None
|
||||
normalized_managed_base_url = LITE_LLM_API_URL.rstrip('/')
|
||||
uses_managed_llm_key = (
|
||||
normalized_llm_base_url == normalized_managed_base_url
|
||||
or (normalized_llm_base_url is None and is_openhands_model(llm_model))
|
||||
)
|
||||
|
||||
if uses_managed_llm_key:
|
||||
await self._ensure_api_key(
|
||||
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
|
||||
item, str(org_id), openhands_type=is_openhands_model(llm_model)
|
||||
)
|
||||
|
||||
effective_agent_settings_diff = self._get_persisted_agent_settings(item)
|
||||
org.agent_settings = deep_merge(
|
||||
OrgStore.get_agent_settings_from_org(org).model_dump(mode='json'),
|
||||
effective_agent_settings_diff,
|
||||
)
|
||||
|
||||
effective_conversation_diff = item.conversation_settings.model_dump(
|
||||
mode='json'
|
||||
)
|
||||
org.conversation_settings = deep_merge(
|
||||
OrgStore.get_conversation_settings_from_org(org).model_dump(
|
||||
mode='json'
|
||||
),
|
||||
effective_conversation_diff,
|
||||
)
|
||||
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
for model in (user, org, org_member):
|
||||
for key, value in kwargs.items():
|
||||
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
|
||||
if key == 'mcp_config' and model is org:
|
||||
continue
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
kwargs.pop('agent_settings', None)
|
||||
kwargs.pop('conversation_settings', None)
|
||||
|
||||
# Map Settings fields to Org fields with 'default_' prefix
|
||||
# The generic loop above doesn't update these because Org uses
|
||||
# 'default_llm_model' not 'llm_model', etc.
|
||||
# Use exclude_unset to only update explicitly-set fields (allows clearing with null)
|
||||
settings_data = item.model_dump(exclude_unset=True)
|
||||
if 'llm_model' in settings_data:
|
||||
org.default_llm_model = settings_data['llm_model']
|
||||
if 'llm_base_url' in settings_data:
|
||||
org.default_llm_base_url = settings_data['llm_base_url']
|
||||
if 'max_iterations' in settings_data:
|
||||
org.default_max_iterations = settings_data['max_iterations']
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(user, key):
|
||||
setattr(user, key, value)
|
||||
if hasattr(org, key) and key not in {
|
||||
'llm_api_key',
|
||||
'agent_settings',
|
||||
'conversation_settings',
|
||||
}:
|
||||
setattr(org, key, value)
|
||||
|
||||
# Propagate LLM settings to all org members
|
||||
# This ensures all members see the same LLM configuration when an admin saves
|
||||
# Note: Concurrent saves by multiple admins will result in last-write-wins.
|
||||
# Consider adding optimistic locking if this becomes a problem.
|
||||
member_update_values: dict = {}
|
||||
if item.llm_model is not None:
|
||||
member_update_values['llm_model'] = item.llm_model
|
||||
if item.llm_base_url is not None:
|
||||
member_update_values['llm_base_url'] = item.llm_base_url
|
||||
if item.max_iterations is not None:
|
||||
member_update_values['max_iterations'] = item.max_iterations
|
||||
if item.llm_api_key is not None:
|
||||
member_update_values['_llm_api_key'] = encrypt_value(
|
||||
item.llm_api_key.get_secret_value()
|
||||
)
|
||||
current_member_llm_api_key = item.agent_settings.llm.api_key
|
||||
org_default_llm_api_key = org.llm_api_key
|
||||
org_default_llm_api_key_raw = (
|
||||
org_default_llm_api_key.get_secret_value()
|
||||
if org_default_llm_api_key
|
||||
else None
|
||||
)
|
||||
current_member_llm_api_key_raw = (
|
||||
current_member_llm_api_key.get_secret_value()
|
||||
if current_member_llm_api_key
|
||||
else None
|
||||
)
|
||||
|
||||
if member_update_values:
|
||||
stmt = (
|
||||
update(OrgMember)
|
||||
.where(OrgMember.org_id == org_id)
|
||||
.values(**member_update_values)
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session,
|
||||
org_id,
|
||||
OrgMemberLLMSettings(
|
||||
agent_settings_diff=effective_agent_settings_diff,
|
||||
conversation_settings_diff=effective_conversation_diff,
|
||||
llm_api_key=(
|
||||
current_member_llm_api_key_raw
|
||||
if not uses_managed_llm_key
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
if uses_managed_llm_key and current_member_llm_api_key is not None:
|
||||
# Managed/proxy key — store on this member but mark as org-managed
|
||||
org_member.llm_api_key = current_member_llm_api_key
|
||||
org_member.has_custom_llm_api_key = False
|
||||
elif current_member_llm_api_key_raw is not None:
|
||||
# BYOR: member supplied their own (non-managed) API key
|
||||
org_member.llm_api_key = current_member_llm_api_key
|
||||
org_member.has_custom_llm_api_key = True
|
||||
elif org_default_llm_api_key_raw is not None:
|
||||
# No member key, falling back to org default
|
||||
org_member.has_custom_llm_api_key = False
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -243,52 +299,6 @@ class SaasSettingsStore(SettingsStore):
|
||||
logger.debug(f'saas_settings_store.get_instance::{user_id}')
|
||||
return SaasSettingsStore(user_id, 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():
|
||||
try:
|
||||
if value is None:
|
||||
continue
|
||||
if self._should_encrypt(key):
|
||||
if isinstance(value, SecretStr):
|
||||
value = fernet.decrypt(
|
||||
b64decode(value.get_secret_value().encode())
|
||||
).decode()
|
||||
else:
|
||||
value = fernet.decrypt(b64decode(value.encode())).decode()
|
||||
kwargs[key] = value
|
||||
except binascii.Error:
|
||||
pass # Key is in legacy format...
|
||||
|
||||
def _encrypt_kwargs(self, kwargs: dict):
|
||||
fernet = self._fernet()
|
||||
for key, value in kwargs.items():
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
if isinstance(value, dict):
|
||||
self._encrypt_kwargs(value)
|
||||
continue
|
||||
|
||||
if self._should_encrypt(key):
|
||||
if isinstance(value, SecretStr):
|
||||
value = b64encode(
|
||||
fernet.encrypt(value.get_secret_value().encode())
|
||||
).decode()
|
||||
else:
|
||||
value = b64encode(fernet.encrypt(value.encode())).decode()
|
||||
kwargs[key] = value
|
||||
|
||||
def _fernet(self):
|
||||
if not self.config.jwt_secret:
|
||||
raise ValueError('jwt_secret must be defined on config')
|
||||
jwt_secret = self.config.jwt_secret.get_secret_value()
|
||||
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
|
||||
return Fernet(fernet_key)
|
||||
|
||||
async def _ensure_api_key(
|
||||
self, item: Settings, org_id: str, openhands_type: bool = False
|
||||
) -> None:
|
||||
@@ -298,9 +308,11 @@ class SaasSettingsStore(SettingsStore):
|
||||
is valid in LiteLLM. If valid, reuses it. Otherwise, generates a new key.
|
||||
"""
|
||||
|
||||
llm_api_key = item.agent_settings.llm.api_key
|
||||
|
||||
# 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(),
|
||||
if llm_api_key and not await LiteLlmManager.verify_existing_key(
|
||||
llm_api_key.get_secret_value(),
|
||||
self.user_id,
|
||||
org_id,
|
||||
openhands_type=openhands_type,
|
||||
@@ -323,7 +335,7 @@ class SaasSettingsStore(SettingsStore):
|
||||
None,
|
||||
)
|
||||
|
||||
item.llm_api_key = SecretStr(generated_key)
|
||||
item.agent_settings.llm.api_key = SecretStr(generated_key)
|
||||
logger.info(
|
||||
'saas_settings_store:store:generated_openhands_key',
|
||||
extra={'user_id': self.user_id},
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import DECIMAL, Column, DateTime, Enum, Integer, String
|
||||
from sqlalchemy import DECIMAL, DateTime, Enum, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class SubscriptionAccess(Base): # type: ignore
|
||||
class SubscriptionAccess(Base):
|
||||
"""
|
||||
Represents a user's subscription access record.
|
||||
Tracks subscription status, duration, payment information, and cancellation status.
|
||||
@@ -12,8 +14,8 @@ class SubscriptionAccess(Base): # type: ignore
|
||||
|
||||
__tablename__ = 'subscription_access'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
status = Column(
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(
|
||||
'ACTIVE',
|
||||
'DISABLED',
|
||||
@@ -22,22 +24,30 @@ class SubscriptionAccess(Base): # type: ignore
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id = Column(String, nullable=False, index=True)
|
||||
start_at = Column(DateTime(timezone=True), nullable=True)
|
||||
end_at = Column(DateTime(timezone=True), nullable=True)
|
||||
amount_paid = Column(DECIMAL(19, 4), nullable=True)
|
||||
stripe_invoice_payment_id = Column(String, nullable=False)
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
stripe_subscription_id = Column(String, nullable=True, index=True)
|
||||
created_at = Column(
|
||||
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
start_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
end_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
amount_paid: Mapped[Decimal | None] = mapped_column(DECIMAL(19, 4), nullable=True)
|
||||
stripe_invoice_payment_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
stripe_subscription_id: Mapped[str | None] = mapped_column(
|
||||
String, nullable=True, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
default=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at = Column(
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
onupdate=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ class User(Base): # type: ignore
|
||||
git_user_email = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
disabled_skills = Column(JSON, nullable=True)
|
||||
onboarding_completed = Column(Boolean, nullable=True, default=False)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import SecretStr
|
||||
from server.constants import DEFAULT_BILLING_MARGIN
|
||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Identity, Integer, String
|
||||
from storage.base import Base
|
||||
from storage.encrypt_utils import decrypt_legacy_value, encrypt_legacy_value
|
||||
|
||||
|
||||
class UserSettings(Base): # type: ignore
|
||||
@@ -8,17 +12,11 @@ class UserSettings(Base): # type: ignore
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
keycloak_user_id = Column(String, nullable=True, index=True)
|
||||
language = Column(String, nullable=True)
|
||||
agent = Column(String, nullable=True)
|
||||
max_iterations = Column(Integer, nullable=True)
|
||||
security_analyzer = Column(String, nullable=True)
|
||||
confirmation_mode = Column(Boolean, nullable=True, default=False)
|
||||
llm_model = Column(String, nullable=True)
|
||||
# Deprecated (v0): API keys now live on Org / OrgMember.
|
||||
# Kept for backward-compat during migration; do not use in new code.
|
||||
llm_api_key = Column(String, nullable=True)
|
||||
llm_api_key_for_byor = Column(String, nullable=True)
|
||||
llm_base_url = Column(String, nullable=True)
|
||||
remote_runtime_resource_factor = Column(Integer, nullable=True)
|
||||
enable_default_condenser = Column(Boolean, nullable=False, default=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
user_consents_to_analytics = Column(Boolean, nullable=True)
|
||||
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
|
||||
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
|
||||
@@ -30,6 +28,7 @@ class UserSettings(Base): # type: ignore
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
user_version = Column(Integer, nullable=False, default=0)
|
||||
accepted_tos = Column(DateTime, nullable=True)
|
||||
# Deprecated (v0): mcp_config now lives inside AgentSettings on Org / OrgMember.
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
disabled_skills = Column(JSON, nullable=True)
|
||||
search_api_key = Column(String, nullable=True)
|
||||
@@ -41,6 +40,38 @@ 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)
|
||||
agent_settings = Column(JSON, nullable=False, default=dict)
|
||||
conversation_settings = Column(JSON, nullable=False, default=dict)
|
||||
|
||||
@property
|
||||
def llm_api_key_for_byor_secret(self) -> SecretStr | None:
|
||||
raw = self.llm_api_key_for_byor
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return SecretStr(decrypt_legacy_value(raw))
|
||||
except Exception:
|
||||
return SecretStr(raw)
|
||||
|
||||
@llm_api_key_for_byor_secret.setter
|
||||
def llm_api_key_for_byor_secret(self, value: str | SecretStr | None) -> None:
|
||||
if value is None:
|
||||
self.llm_api_key_for_byor = None
|
||||
return
|
||||
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
|
||||
self.llm_api_key_for_byor = encrypt_legacy_value(raw)
|
||||
|
||||
already_migrated = Column(
|
||||
Boolean, nullable=True, default=False
|
||||
) # False = not migrated, True = migrated
|
||||
|
||||
def to_settings(self):
|
||||
from openhands.sdk.settings import AgentSettings, ConversationSettings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
return Settings(
|
||||
agent_settings=AgentSettings.model_validate(self.agent_settings or {}),
|
||||
conversation_settings=ConversationSettings.model_validate(
|
||||
self.conversation_settings or {}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Store class for managing users."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
@@ -24,11 +25,14 @@ from storage.encrypt_utils import (
|
||||
)
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.role_store import RoleStore
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.sdk.settings import AGENT_SETTINGS_SCHEMA_VERSION
|
||||
|
||||
# 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
|
||||
@@ -82,6 +86,9 @@ class UserStore:
|
||||
)
|
||||
user.email = user_info.get('email')
|
||||
user.email_verified = user_info.get('email_verified')
|
||||
# SaaS consent is implicit via Terms of Service — new SaaS users default to consented
|
||||
if 'saas' in (os.environ.get('OPENHANDS_CONFIG_CLS', '')).lower():
|
||||
user.user_consents_to_analytics = True
|
||||
session.add(user)
|
||||
|
||||
role = await RoleStore.get_role_by_name('owner')
|
||||
@@ -91,9 +98,6 @@ class UserStore:
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
org_member_kwargs = OrgMemberStore.get_kwargs_from_settings(settings)
|
||||
# avoid setting org member llm fields to use org defaults on user creation
|
||||
del org_member_kwargs['llm_model']
|
||||
del org_member_kwargs['llm_base_url']
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
@@ -233,10 +237,15 @@ class UserStore:
|
||||
org_kwargs = OrgStore.get_kwargs_from_user_settings(decrypted_user_settings)
|
||||
org_kwargs.pop('id', None)
|
||||
|
||||
# if user has custom settings, set org defaults to current version
|
||||
# If the user has custom settings, keep the org defaults minimal.
|
||||
if custom_settings:
|
||||
org_kwargs['default_llm_model'] = get_default_litellm_model()
|
||||
org_kwargs['llm_base_url'] = LITE_LLM_API_URL
|
||||
org_kwargs['agent_settings'] = {
|
||||
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
}
|
||||
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
|
||||
|
||||
for key, value in org_kwargs.items():
|
||||
@@ -276,12 +285,10 @@ class UserStore:
|
||||
org_member_kwargs = OrgMemberStore.get_kwargs_from_user_settings(
|
||||
decrypted_user_settings
|
||||
)
|
||||
|
||||
# if the user did not have custom settings in the old model,
|
||||
# then use the org defaults by not setting org_member fields
|
||||
if not custom_settings:
|
||||
del org_member_kwargs['llm_model']
|
||||
del org_member_kwargs['llm_base_url']
|
||||
org_member_kwargs['agent_settings_diff'] = (
|
||||
OrgStore.get_agent_settings_from_org(org).model_dump(mode='json')
|
||||
)
|
||||
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
@@ -749,6 +756,65 @@ class UserStore:
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def mark_onboarding_completed(user_id: str) -> Optional[User]:
|
||||
"""Mark the user's onboarding as completed.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
User: The updated user object, or None if user not found
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id)).with_for_update()
|
||||
)
|
||||
user = result.scalars().first()
|
||||
if not user:
|
||||
logger.warning(
|
||||
'mark_onboarding_completed:user_not_found',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return None
|
||||
|
||||
user.onboarding_completed = True
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
logger.info(
|
||||
'mark_onboarding_completed:success',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
async def get_first_owner_in_org(org_id: UUID) -> Optional[User]:
|
||||
"""Get the first owner in an organization who accepted the Terms of Service.
|
||||
|
||||
This user is considered the super admin for that org in self-hosted deployments.
|
||||
The super admin is identified as the owner with the earliest accepted_tos timestamp.
|
||||
|
||||
Args:
|
||||
org_id: The organization UUID
|
||||
|
||||
Returns:
|
||||
User: The first owner to accept TOS in this org, or None if not found.
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(User)
|
||||
.join(OrgMember, OrgMember.user_id == User.id)
|
||||
.join(Role, Role.id == OrgMember.role_id)
|
||||
.filter(
|
||||
OrgMember.org_id == org_id,
|
||||
Role.name == 'owner',
|
||||
User.accepted_tos.isnot(None),
|
||||
)
|
||||
.order_by(User.accepted_tos.asc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@staticmethod
|
||||
async def backfill_contact_name(user_id: str, user_info: dict) -> None:
|
||||
"""Update contact_name on the personal org if it still has a username-style value.
|
||||
@@ -951,44 +1017,30 @@ class UserStore:
|
||||
Returns:
|
||||
A new UserSettings object populated from the entities
|
||||
"""
|
||||
# Mapping from OrgMember fields to corresponding Org "default_" fields
|
||||
org_member_to_org_default = {
|
||||
'llm_model': 'default_llm_model',
|
||||
'llm_base_url': 'default_llm_base_url',
|
||||
'max_iterations': 'default_max_iterations',
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
member_agent_settings_diff = dict(org_member.agent_settings_diff)
|
||||
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
|
||||
agent_settings = {
|
||||
**org_agent_settings.model_dump(mode='json'),
|
||||
**member_agent_settings_diff,
|
||||
}
|
||||
|
||||
def get_value_with_org_fallback(field_name: str, org_member_value):
|
||||
"""Get value from OrgMember, falling back to Org default if None."""
|
||||
if org_member_value is not None:
|
||||
return org_member_value
|
||||
org_default_field = org_member_to_org_default.get(field_name)
|
||||
if org_default_field and hasattr(org, org_default_field):
|
||||
return getattr(org, org_default_field)
|
||||
return None
|
||||
|
||||
# Get values from OrgMember with Org fallback for fields with default_ prefix
|
||||
llm_model = get_value_with_org_fallback('llm_model', org_member.llm_model)
|
||||
llm_base_url = get_value_with_org_fallback(
|
||||
'llm_base_url', org_member.llm_base_url
|
||||
)
|
||||
max_iterations = get_value_with_org_fallback(
|
||||
'max_iterations', org_member.max_iterations
|
||||
)
|
||||
member_conversation_settings_diff = dict(org_member.conversation_settings_diff)
|
||||
org_conversation_settings = OrgStore.get_conversation_settings_from_org(org)
|
||||
conversation_settings = {
|
||||
**org_conversation_settings.model_dump(mode='json'),
|
||||
**member_conversation_settings_diff,
|
||||
}
|
||||
|
||||
return UserSettings(
|
||||
keycloak_user_id=user_id,
|
||||
# OrgMember fields
|
||||
llm_api_key=org_member.llm_api_key.get_secret_value()
|
||||
if org_member.llm_api_key
|
||||
else None,
|
||||
llm_api_key_for_byor=org_member.llm_api_key_for_byor.get_secret_value()
|
||||
if org_member.llm_api_key_for_byor
|
||||
else None,
|
||||
llm_model=llm_model,
|
||||
llm_base_url=llm_base_url,
|
||||
max_iterations=max_iterations,
|
||||
# User fields
|
||||
accepted_tos=user.accepted_tos,
|
||||
enable_sound_notifications=user.enable_sound_notifications,
|
||||
language=user.language,
|
||||
@@ -997,18 +1049,12 @@ class UserStore:
|
||||
email_verified=user.email_verified,
|
||||
git_user_name=user.git_user_name,
|
||||
git_user_email=user.git_user_email,
|
||||
# Org fields
|
||||
agent=org.agent,
|
||||
security_analyzer=org.security_analyzer,
|
||||
confirmation_mode=org.confirmation_mode,
|
||||
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
|
||||
enable_default_condenser=org.enable_default_condenser,
|
||||
billing_margin=org.billing_margin,
|
||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters,
|
||||
sandbox_base_container_image=org.sandbox_base_container_image,
|
||||
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
|
||||
user_version=org.org_version,
|
||||
mcp_config=org.mcp_config,
|
||||
search_api_key=org.search_api_key.get_secret_value()
|
||||
if org.search_api_key
|
||||
else None,
|
||||
@@ -1018,7 +1064,9 @@ class UserStore:
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
v1_enabled=org.v1_enabled,
|
||||
condenser_max_size=org.condenser_max_size,
|
||||
sandbox_grouping_strategy=org.sandbox_grouping_strategy,
|
||||
agent_settings=agent_settings,
|
||||
conversation_settings=conversation_settings,
|
||||
already_migrated=False,
|
||||
)
|
||||
|
||||
@@ -1036,15 +1084,17 @@ class UserStore:
|
||||
Returns:
|
||||
True if user has custom settings, False if using old defaults
|
||||
"""
|
||||
# Normalize values
|
||||
user_model = (
|
||||
user_settings.llm_model.strip() or None if user_settings.llm_model else None
|
||||
)
|
||||
user_base_url = (
|
||||
user_settings.llm_base_url.strip() or None
|
||||
if user_settings.llm_base_url
|
||||
else None
|
||||
)
|
||||
persisted_agent_settings = user_settings.agent_settings or {}
|
||||
llm_settings = persisted_agent_settings.get('llm', {})
|
||||
if isinstance(llm_settings, dict):
|
||||
user_model = llm_settings.get('model')
|
||||
user_base_url = llm_settings.get('base_url')
|
||||
else:
|
||||
user_model = None
|
||||
user_base_url = None
|
||||
|
||||
user_model = user_model.strip() or None if user_model else None
|
||||
user_base_url = user_base_url.strip() or None if user_base_url else None
|
||||
|
||||
# Custom base_url = definitely custom settings (BYOK)
|
||||
if user_base_url and user_base_url != LITE_LLM_API_URL:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
@@ -37,6 +38,20 @@ from storage.stored_conversation_metadata_saas import (
|
||||
from storage.stored_offline_token import StoredOfflineToken
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def allow_short_context_windows():
|
||||
old = os.environ.get('ALLOW_SHORT_CONTEXT_WINDOWS')
|
||||
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = 'true'
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if old is None:
|
||||
os.environ.pop('ALLOW_SHORT_CONTEXT_WINDOWS', None)
|
||||
else:
|
||||
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = old
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -172,7 +187,6 @@ def add_minimal_fixtures(session_maker):
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -88,6 +88,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
@@ -144,6 +145,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
@@ -200,6 +202,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
view.previous_comments = []
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)
|
||||
|
||||
@@ -206,7 +206,7 @@ def new_conversation_view(
|
||||
sample_webhook_payload, sample_user_auth, sample_jira_user, sample_jira_workspace
|
||||
):
|
||||
"""JiraNewConversationView instance for testing"""
|
||||
return JiraNewConversationView(
|
||||
view = JiraNewConversationView(
|
||||
payload=sample_webhook_payload,
|
||||
saas_user_auth=sample_user_auth,
|
||||
jira_user=sample_jira_user,
|
||||
@@ -215,6 +215,8 @@ def new_conversation_view(
|
||||
conversation_id='conv-123',
|
||||
_decrypted_api_key='decrypted_key',
|
||||
)
|
||||
view.v1_enabled = False
|
||||
return view
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -202,14 +202,10 @@ class TestStartJob:
|
||||
)
|
||||
jira_manager._send_comment = AsyncMock()
|
||||
|
||||
with patch(
|
||||
'integrations.jira.jira_manager.register_callback_processor'
|
||||
) as mock_register:
|
||||
await jira_manager.start_job(new_conversation_view)
|
||||
await jira_manager.start_job(new_conversation_view)
|
||||
|
||||
new_conversation_view.create_or_update_conversation.assert_called_once()
|
||||
mock_register.assert_called_once()
|
||||
jira_manager._send_comment.assert_called_once()
|
||||
new_conversation_view.create_or_update_conversation.assert_called_once()
|
||||
jira_manager._send_comment.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_job_missing_settings_error(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user