Compare commits

..

201 Commits

Author SHA1 Message Date
openhands 9e9a0bbe87 Optimize Playwright install by removing redundant --with-deps flag
The --with-deps flag causes Playwright to install system dependencies via apt-get,
but these dependencies are already being installed manually in the lines above.
This removal avoids redundant package installation and significantly speeds up
the Docker build process.

Added libcups2t64/libcups2 to the manual dependency list as it's required by
Playwright but wasn't previously included.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 06:30:47 +00:00
openhands c9af5edad9 Optimize Playwright install by removing redundant --with-deps flag
The --with-deps flag causes Playwright to install system dependencies via apt-get,
but these dependencies (libnss3, libnspr4, libatk-bridge2.0-0, etc.) are already
being installed manually in the lines above. This removal avoids redundant package
installation and significantly speeds up the Docker build process.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 06:11:04 +00:00
chuckbutkus 0c7ce4ad48 V1 Changes to Support Path Based Routing (#13120)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 22:37:37 -05:00
Rohit Malhotra 4dab34e7b0 fix(enterprise): fix type errors - missing returns and async interface (#13145)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 00:37:22 +00:00
Rohit Malhotra f8bbd352a9 Fix typing: make Message a dict instead of dict | str (#13144)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-03 00:30:22 +00:00
Tim O'Farrell 17347a95f8 Make load_org_token and store_org_token async in TokenManager (#13147)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 17:08:21 -07:00
Graham Neubig 01ef87aaaa Add logging when sandbox is assigned to conversation (#13143)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 17:36:49 -05:00
Hiep Le 8059c18b57 fix(backend): update planning agent to direct users to the build button instead of asking ready to proceed (#13139) 2026-03-03 03:31:29 +07:00
Tim O'Farrell c82ee4c7db refactor(enterprise): use async database sessions in feedback routes (#13137)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 13:17:44 -07:00
Tim O'Farrell 7fdb423f99 feat(enterprise): convert DeviceCodeStore to async (#13136)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 12:56:41 -07:00
dependabot[bot] 530065dfa7 chore(deps): bump pillow from 12.1.0 to 12.1.1 in uv lock and enterprise poetry lock (#13101)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 13:56:13 -06:00
Hiep Le a4cd2d81a5 fix(backend): use run_coroutine_threadsafe for conversation update callbacks (#13134) 2026-03-03 02:07:32 +07:00
Tim O'Farrell 003b430e96 Refactor: Migrate remaining enterprise modules to async database sessions (#13124)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 11:52:00 -07:00
Graham Neubig d63565186e Add Claude Opus 4.6 model support (#12767)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: neubig <neubig@users.noreply.github.com>
2026-03-02 13:12:48 -05:00
Hiep Le 5f42d03ec5 fix(backend): jira cloud integration does not work (#13123) 2026-03-02 22:05:29 +07:00
Mohammed Abdulai 62241e2e00 Fix: OSS suggested tasks empty state (#12563)
Co-authored-by: Mohammed Abdulai <nurud43@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-02 18:45:29 +07:00
Neha Prasad f5197bd76a fix: prevent double scrollbar when profile avatar popover is shown (#13115)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-02 18:14:04 +07:00
Tim O'Farrell e1408f7b15 Add timeout to Keycloak operations and convert OfflineTokenStore to async (#13096)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 01:48:45 -07:00
Shruti1128 d6b8d80026 Remove unused subscription-related frontend code (#12557) 2026-03-01 21:14:00 +01:00
Hiep Le 1e6a92b454 feat(backend): organizations llm settings api (org project) (#13108) 2026-03-02 00:06:37 +07:00
Hiep Le b4a3e5db2f feat(backend): saas – organizations app settings api (#13022) 2026-03-01 23:26:39 +07:00
Chris Bagwell f9d553d0bb Pass container port instead of host port to Docker (#12595)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-02-28 17:45:16 +01:00
Tim O'Farrell f6f6c1ab25 refactor: use SQL filtering and pagination in VerifiedModelStore (#13068)
Co-authored-by: bittoby <brianwhitedev1996@gmail.com>
Co-authored-by: statxc <statxc@user.noreply.github.com>
Co-authored-by: bittoby <bittoby@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 07:37:11 -07:00
Hiep Le c511a89426 feat(frontend): display Bitbucket signup disabled message on login page (#13100) 2026-02-28 19:26:16 +07:00
HeyItsChloe 1f82ff04d9 feat(frontend): SaaS NUE profile questions /Onboarding flow (#13029)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-28 13:27:22 +07:00
HeyItsChloe eec17311c7 fix(frontend): bitbucket icon color (#13106)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 12:12:45 +07:00
Nelson Spence c34fdf4b37 fix(security): extend action type coverage in security check (#12870)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-02-28 05:44:29 +01:00
Engel Nyst 25076ee44c chore: mark security/ and resolver/ as Legacy V0 (#13062)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 04:22:47 +01:00
aivong-openhands baaec8473a Fix CVE-2024-23342: Replace python-jose with jwcrypto (#13012)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-27 22:15:23 +00:00
dependabot[bot] 402fa47422 chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5 in /frontend in the security-all group across 1 directory (#13098)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 14:15:41 -06:00
dependabot[bot] 8dde385843 chore(deps): bump pypdf from 6.7.2 to 6.7.3 (#13099)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-27 13:32:02 -06:00
aivong-openhands a905e35531 Fix CVE-2026-25990: Update pillow to 12.1.1 (#13024)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-27 12:37:25 -06:00
dependabot[bot] 1f185173b7 chore(deps): bump pypdf to 6.7.2 (#13072)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-02-27 08:35:25 -06:00
dependabot[bot] ddc7a78723 chore(deps): bump rollup from 4.57.0 to 4.59.0 in /frontend in the security-all group across 1 directory (#13078)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 08:29:19 -06:00
Dream a29ed4d926 feat(frontend): display Agent Skills and Commands in slash menu (#12982)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-27 20:46:38 +07:00
Hiep Le b8ab4bb44e feat(backend): allow owners to edit owners and admins to edit admins (org project) (#13095) 2026-02-27 16:01:05 +07:00
Hiep Le ddd544f8d6 feat(backend): saas users app settings api (#13021) 2026-02-27 13:01:03 +07:00
mamoodi 3804b66e32 Remove blank issue creation (#13090) 2026-02-26 16:56:13 -05:00
John-Mason P. Shackelford b97adf392a docs: Add plugin launch flow design document (#13084)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-02-26 14:03:40 -05:00
Saurya Velagapudi dcb584913a Fix CVE-2026-26007: Update cryptography to 46.0.5 (#13059)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-26 09:45:54 -06:00
dependabot[bot] d2fd54a083 chore(deps): bump the security-all group across 1 directory with 2 updates (#13069)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-02-26 09:39:44 -06:00
Tim O'Farrell 112d863287 refactor: Remove unused Google Sheets integration from UserVerifier (#13076)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 08:07:38 -07:00
Hiep Le c8680caec3 fix: db migration (#13074) 2026-02-26 21:20:08 +07:00
sp.wack d4b9fb1d03 fix(backend): user email capture (#12902)
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 17:29:30 +04:00
Tim O'Farrell 409df1287d Fix api key access (#13064)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 06:22:23 -07:00
BitToby a92bfe6cc0 feat: add database-backed verified models for dynamic model managemen… (#12833)
Co-authored-by: statxc <statxc@user.noreply.github.com>
Co-authored-by: bittoby <bittoby@users.noreply.github.com>
2026-02-26 06:17:18 -07:00
Hiep Le f93e3254d3 refactor(frontend): remove feature flag (planning agent) (#12880) 2026-02-26 18:44:26 +07:00
Tim O'Farrell 0476d57451 fix: properly extract redirect URL from OAuth state in keycloak_offline_callback (#13063)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-26 03:23:40 -07:00
Hiep Le a4cd21e155 refactor(frontend): hide microagent management ui (#13060) 2026-02-26 17:13:37 +07:00
aivong-openhands 7f3af371d1 protobuf pypdf uv lock updates (#13045) 2026-02-25 14:19:16 -06:00
aivong-openhands 1421794c1b Fix CVE-2026-26007: Update cryptography to 46.0.5 (#13009)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-25 14:18:16 -06:00
aivong-openhands 2fc689457c Fix CVE-2026-24486: Update python-multipart to 0.0.22 (#13015)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-25 10:15:58 -06:00
Tim O'Farrell 3161b365a8 Add sandbox_id field to conversation endpoints (#13044)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-25 14:29:42 +00:00
aivong-openhands 18ab56ef4e Fix CVE-2026-23490: Update pyasn1 to 0.6.2 (#13013)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-25 08:06:41 -06:00
Tim O'Farrell a9c0df778c Make logs quieter. (#13042) 2026-02-25 05:11:13 -07:00
MkDev11 51b989b5f8 feat: Allow attaching/changing repository for existing conversations (#12671)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-25 18:09:12 +07:00
aivong-openhands dc039d81d6 Fix CVE-2026-27199: Update werkzeug to 3.1.6 (#13028)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 18:26:28 -06:00
aivong-openhands 8e4559b14a Fix CVE-2025-61765: Update python-socketio to 5.14.0 (#13027)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 18:15:04 -06:00
aivong-openhands b84f352b63 Fix CVE-2026-0994: Update protobuf to 5.29.6 (#13011)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 17:25:40 -06:00
aivong-openhands a0dba6124a Fix CVE-2026-27026: Update pypdf to at least 6.7.1 (#13025)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 15:25:06 -06:00
aivong-openhands 951739f3eb Fix CVE-2025-53000: Update nbconvert to 7.17.0 (#13010)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-02-24 14:46:31 -06:00
aivong-openhands 0f1ad46a47 Fix CVE-2025-62727: Update starlette to 0.49.1 (#13016)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-02-24 10:55:32 -06:00
sp.wack 5367bef43a fix: detect team/org-level budget errors in error banner (#13003) 2026-02-24 20:55:11 +04:00
Tim O'Farrell 3afeccfe7f fix: prevent token refresh deadlock with double-checked locking and timeouts (#13020)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-24 08:13:57 -07:00
Tim O'Farrell 0677c035ff Optimize get_sandbox_by_session_api_key with hash lookup (#13019)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-24 13:55:21 +00:00
Hiep Le 68165b52d9 feat(backend): add pagination and email filtering for organization members (#12999) 2026-02-24 16:02:24 +07:00
Dream dcc8217317 feat(frontend): add mutateWithToast utility for standardized mutation toast handling (#12433)
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-24 15:06:44 +07:00
jpelletier1 d1410949ff Experiment - Add 'Add Team Members' button to Avatar menu in SaaS mode (#12647)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-23 23:06:57 +04:00
Tim O'Farrell a6c0d80fe1 Fix: Logout on 401 error in useGitUser; downgrade provider error to warning (#12935)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-23 10:15:54 -07:00
Tim O'Farrell 0efb1db85d Bumped SDK to 1.11.5 (#13002) 2026-02-23 09:31:31 -07:00
Hiep Le 8e0f74c92c fix(backend): ensure members are removed from the corresponding litellm team when removed from an organization (#12996) 2026-02-23 18:45:31 +07:00
Hiep Le 6e1ba3d836 fix(backend): update current_org_id when removing a member from an organization (#12995) 2026-02-23 18:21:37 +07:00
Hiep Le 0ec97893d1 fix(backend): unable to delete an organization after inviting at least one member (#12993) 2026-02-23 18:21:10 +07:00
Tim O'Farrell ddb809bc43 Add webhook endpoint authentication bypass and admin context unfiltered data access (#12956)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-23 09:28:49 +00:00
Alona 872f2b87f2 fix: add retry logic with exponential backoff to send_welcome_email (#12450)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-02-20 20:42:00 +00:00
Graham Neubig ee86005a3a Align PR review workflow with software-agent-sdk (#12963)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 21:02:32 +01:00
Graham Neubig d4aa30580b Migrate PR review workflow to use extensions action (#12917)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 18:44:02 +00:00
Tim O'Farrell 2f0e879129 Fix session_maker to accept kwargs for backward compatibility (#12960)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 10:56:44 -07:00
sp.wack 3bc2ef954e fix(backend): config values (#12944) 2026-02-20 17:53:35 +04:00
Ray Myers 32ab2a24c6 Remove enterprise-preview job and workflow (#12350)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 03:36:14 +01:00
Engel Nyst a6e148d1e6 refactor: use consolidated pr-review action (#12801)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-20 02:08:42 +01:00
Manrique Vargas 3fc977eddd fix(mcp): skip conversation link when conversation_id is None (#12941)
Signed-off-by: machov <mv1742@nyu.edu>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-02-19 21:41:26 +00:00
John-Mason P. Shackelford 89a6890269 Fix URL encoding in Jira OAuth authorization URLs (#12399)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2026-02-19 21:40:29 +00:00
Hiep Le 8927ac2230 fix(backend): organization members now see correct shared credit balance (#12942) 2026-02-20 01:34:53 +07:00
Rohit Malhotra f3429e33ca Fix Resend sync to respect deleted users (#12904)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-19 17:43:15 +00:00
Tim O'Farrell 7cd219792b Add type hints and use model objects in api_keys.py endpoints (#12939)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-19 08:40:46 -07:00
Hiep Le 2aabe2ed8c fix(backend): add organization filtering to V1 conversation queries (#12923) 2026-02-19 20:39:28 +07:00
Tim O'Farrell 731a9a813e More readable logs for local debugging (#12926) 2026-02-19 02:27:57 -07:00
Tim O'Farrell 123e556fed Added endpoint for readiness probe (#12927) 2026-02-19 02:27:35 -07:00
Chujiang 6676cae249 fix: add missing type hints and improve test logging (#12810)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 00:58:39 +01:00
Clay Arnold fede37b496 fix: add claude-opus-4-6 to temperature/top_p guard (#12874)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 00:33:17 +01:00
Hiep Le 3bcd6f18df fix(backend): set user email fields from user_info during create_user (#12921) 2026-02-19 02:06:20 +07:00
Rohit Malhotra 0da18440c2 Mention free MiniMax usage and drop free credits (#12918)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-18 13:54:05 -05:00
Hiep Le ac76e10048 refactor(backend): include current_org_id in organization list response (#12915) 2026-02-18 20:35:40 +07:00
Hiep Le b98bae8b5f refactor(backend): rename orgmemberresponse.role_name to role (#12914) 2026-02-18 20:23:07 +07:00
Tim O'Farrell 516721d1ee fix: add default uuid4 to event_callback_result primary key (#12908)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-18 05:57:13 -07:00
Hiep Le 4d6f66ca28 feat: add user invitation logic (#12883)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-18 13:24:19 +07:00
chuckbutkus b18568da0b Feature/permission based authorization (#12906)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-18 01:17:39 -05:00
mamoodi 83dd3c169c Release 1.4.0 (#12897) 2026-02-17 13:09:29 -05:00
Tim O'Farrell 35bddb14f1 fix: preserve import order in clean_proactive_convo_table.py (#12901)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-17 17:52:54 +00:00
Tim O'Farrell e8425218e2 Remove alembic errors dumped into logs by cron jobs (#12900) 2026-02-17 17:22:54 +00:00
Rohit Malhotra 0a879fa781 Grant free credits after minimum purchase (#12899)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-17 11:00:42 -05:00
Hiep Le 41e142bbab fix(backend): system prompt override (planning agent) (#12893) 2026-02-17 16:15:26 +07:00
Engel Nyst b06b9eedac fix: wire suggested task prompts for V1 (#12787)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 23:57:32 +01:00
Tim O'Farrell a9afafa991 Default model for new users is minimax (#12889) 2026-02-16 12:24:30 -07:00
mamoodi 663ace4b39 Add saas-rel* branch pattern to ghcr-build workflow (#12888)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-16 12:27:37 -05:00
Hiep Le 2d085a6e0a fix(frontend): add auto-scroll when new messages arrive in chat (#12885) 2026-02-16 23:46:14 +07:00
Hiep Le 8b7112abe8 refactor(frontend): hide planning preview component when plan content is empty (#12879) 2026-02-16 18:35:20 +07:00
Hiep Le 34547ba947 fix(backend): enable byor key export after purchasing credits (#12862) 2026-02-16 17:02:06 +07:00
Graham Neubig 5f958ab60d fix: suppress alembic INFO logs before import to prevent Datadog misclassification (#12691)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 14:32:39 -05:00
Hiep Le d7656bf1c9 refactor(backend): rename user role to member across the system (#12853) 2026-02-13 00:45:47 +07:00
Tim O'Farrell 2bc107564c Support list_files and get_trajectory for nested conversation managers (#12850)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-12 10:39:00 -07:00
Tim O'Farrell 85eb1e1504 Check event types before making expensive API calls in GitHub webhook handler (#12819)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 09:33:59 -07:00
OpenHands Bot cd235cc8c7 Bump SDK packages to v1.11.4 (#12839)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-02-11 10:55:46 -07:00
Graham Neubig 40f52dfabc Use lowercase minimax-m2.5 for consistency (#12840)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-12 01:29:17 +08:00
Hiep Le bab7bf85e8 fix(backend): prevent org deletion from setting current_org_id to NULL (#12817) 2026-02-12 00:15:21 +07:00
Hiep Le c856537f65 refactor(backend): update the patch organization api to support organization name updates (#12834) 2026-02-12 00:08:43 +07:00
Graham Neubig 736f5b2255 Add MiniMax-M2.5 model support (#12835)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-11 16:57:22 +00:00
chuckbutkus c1d9d11772 Log all exceptions in get_user() when authentication fails (#12836)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-11 11:49:13 -05:00
sp.wack 85244499fe fix(frontend): performance and loading state bugs (#12821) 2026-02-11 15:34:52 +00:00
Hiep Le c55084e223 fix(backend): read RECAPTCHA_SITE_KEY from environment in V1 web client config (#12830) 2026-02-11 18:59:52 +07:00
Tim O'Farrell e3bb75deb4 fix(enterprise): use poetry.lock for reproducible dependency builds (#12820)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-02-11 04:51:12 -07:00
Hiep Le 1948200762 chore: update sdk to the latest version (#12811) 2026-02-11 12:57:08 +07:00
Tim O'Farrell affe0af361 Add debug logging for sandbox startup health checks (#12814)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-10 07:23:43 -07:00
Hiep Le f20c956196 feat(backend): implement org member patch api (#12800) 2026-02-10 20:01:24 +07:00
Alexander Grattan 4a089a3a0d fix(docs): update Gray Swan API links and onboarding instructions in security README (#12809) 2026-02-10 10:14:49 +00:00
Hiep Le aa0b2d0b74 feat(backend): add api for switching between orgs (#12799) 2026-02-10 14:22:52 +07:00
Hiep Le bef9b80b9d fix(frontend): add missing border radius to conversation loading on first load (#12796) 2026-02-09 21:36:07 +07:00
Graham Neubig c4a90b1f89 Fix Resend ValidationError by adding email validation (#12511)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-08 09:47:39 -05:00
sp.wack 0d13c57d9f feat(backend): org get me route (#12760)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-07 16:11:25 +07:00
Graham Neubig b3422f1275 Add PR Review by OpenHands workflow (#12784)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 17:26:16 -05:00
Xingyao Wang f139a9970b feat: add SANDBOX_STARTUP_GRACE_SECONDS env var for configurable startup timeout (#12741)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-07 06:12:29 +08:00
Jamie Chicago 54d156122c Add automated PR review workflow using OpenHands (#12698)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2026-02-06 19:02:55 +00:00
Tim O'Farrell ac072bf686 feat(frontend): change alert banner from solid background to border style (#12783)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 18:05:29 +00:00
Hiep Le a53812c029 feat(backend): develop delete /api/organizations/{orgid}/members/{userid} api (#12734) 2026-02-07 00:50:47 +07:00
Tim O'Farrell 1d1c0925b5 refactor: Move check_byor_export_enabled to OrgService and add tests (PR #12753 followup) (#12782)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 17:03:03 +00:00
Hiep Le 872f41e3c0 feat(backend): implement get /api/organizations/{orgId}/members api (#12735) 2026-02-06 23:47:30 +07:00
Tim O'Farrell d43ff82534 feat: Add BYOR export flag to org for LLM key access control (#12753)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-06 09:30:12 -07:00
huangkevin-apr 8cd8c011b2 fix(a11y): Add aria-label to Sidebar component (#12728) 2026-02-06 22:32:52 +07:00
Tim O'Farrell 5c68b10983 (Frontend) Migrate to new /api/v1/web-client/config endpoint (#12479)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-06 08:31:40 -07:00
Graham Neubig a97fad1976 fix: Add PostHog error tracking for V1 AgentErrorEvent and ConversationErrorEvent (#12543)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 09:51:01 -05:00
Graham Neubig 4c3542a91c fix: use appropriate log level for webhook installation results (#12493)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 09:01:37 -05:00
Tim O'Farrell f460057f58 chore: add deprecation notices to all runtime directory files (#12772)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 05:15:02 -07:00
MkDev11 4fa2ad0f47 fix: add exponential backoff retry for env var export when bash session is busy (#12748)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
2026-02-06 05:07:17 -07:00
Hiep Le dd8be12809 feat(backend): return is_personal field in OrgResponse (#12777) 2026-02-06 19:01:06 +07:00
Tim O'Farrell 89475095d9 Preload callback processor class to prevent Pydantic Deserialization Error (#12776) 2026-02-06 04:29:28 -07:00
Tim O'Farrell 05d5f8848a Fix V1 GitHub conversations failing to clone repository (#12775)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-06 03:08:55 -07:00
Hiep Le ee2885eb0b feat: store plan.md file in appropriate configuration folders (#12713) 2026-02-06 16:09:39 +07:00
Tim O'Farrell 545257f870 Refactor: Add LLM provider utilities and improve API base URL detection (#12766)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-05 14:22:32 -07:00
Hiep Le b23ab33a01 chore: update sdk to the latest version (#12762) 2026-02-06 00:25:11 +07:00
sp.wack a9ede73391 fix(backend): resolve missing email and display name for user identity tracking (#12719) 2026-02-05 16:50:33 +00:00
chuckbutkus 634c2439b4 Fix key gen again (#12752)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-05 11:45:10 -05:00
Hiep Le a1989a40b3 refactor(frontend): remove border and border radius from ConversationLoading (#12756) 2026-02-05 21:50:07 +07:00
Saurya Velagapudi e38f1283ea feat(recaptcha): add user_id and email to assessment log (#12749)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 17:58:44 -08:00
Tim O'Farrell 07eb791735 Remove flaky test_bash_session.py (#12739)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 11:55:22 -07:00
Saurya Velagapudi c355c4819f feat(recaptcha): add assessment name to logging and AssessmentResult (#12744)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-04 09:30:02 -08:00
sp.wack 9d8e4c44cc fix(frontend): fix cross-domain PostHog tracking param names and persist bootstrap IDs across OAuth redirects (#12729) 2026-02-04 16:15:53 +04:00
Hiep Le 25cc55e558 chore: update sdk to the latest version (#12737) 2026-02-04 01:20:13 +07:00
chuckbutkus 0e825c38d7 APP-443: Fix key generation (#12726)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-02-03 17:50:40 +00:00
Graham Neubig ce04e70b5b fix: BYOR to OpenHands provider switch auth error (#12725)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-03 09:26:56 -07:00
Tim O'Farrell 7b0589ad40 Add GitHub and Replicated app slugs to web client config (#12724) 2026-02-02 15:53:52 -07:00
mamoodi 682465a862 Release 1.3 (#12715) 2026-02-02 16:17:01 -05:00
Tim O'Farrell 1bb4c844d4 Fix runtime status error on conversation resume (#12718)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-02 21:04:45 +00:00
Neha Prasad d6c11fe517 fix selected repo disappearing in pen repository dropdown (#12702)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2026-02-02 19:07:48 +00:00
Tim O'Farrell b088d4857e Improve batch_get_app_conversations UUID handling (#12711)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-02 07:25:07 -07:00
Tim O'Farrell 0f05898d55 Deprecate V0 endpoints now handled by agent server (#12710)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-02 07:25:00 -07:00
Hiep Le d1f0a01a57 feat(frontend): add silent WebSocket recovery for V1 conversations (#12677) 2026-02-02 19:53:33 +07:00
Hiep Le f5a9d28999 feat(frontend): add shine text effect to Plan Preview during streaming (planning agent) (#12676) 2026-02-02 14:31:31 +07:00
Graham Neubig afa0417608 Remove evaluation directory and benchmarking dependencies (#12666)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-01 11:39:29 -08:00
chuckbutkus e688fba761 Fix check for already migrated (#12700) 2026-01-30 19:31:46 -05:00
Tim O'Farrell d1ec5cbdf6 Fix litellm migration max_budget issue and add logging (#12697)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 16:35:31 -07:00
Jamie Chicago f42625f789 Improve issue templates with best practices and clear expectations (#12632)
Co-authored-by: jamiechicago312 <jamiechicago312@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-01-30 21:27:44 +01:00
Graham Neubig fe28519677 feat(frontend): add gpt-5.2 and gpt-5.2-codex models, remove gpt-5 models (#12639)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 12:15:47 -05:00
Tim O'Farrell e62ceafa4a Cleaner Logs (#12579)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 15:25:36 +00:00
Tim O'Farrell 0b8c69fad2 Fix for issue where stats are not updated for conversations (#12688)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-30 08:12:38 -07:00
Hiep Le 37d9b672a4 fix(frontend): prevent duplicate API calls when sub-conversation becomes ready (planning agent) (#12673) 2026-01-30 15:54:51 +07:00
Hiep Le c8b867a634 fix(frontend): persist selected agent mode across page refresh (planning agent) (#12672) 2026-01-30 15:39:36 +07:00
Hiep Le 59834beba7 fix(frontend): prevent duplicate sub-conversation creation on page refresh (planning agent) (#12645) 2026-01-30 15:25:41 +07:00
Hiep Le d2eced9cff feat(frontend): handle the build button for the planning agent (planning agent) (#12644) 2026-01-30 15:25:16 +07:00
Hiep Le 7836136ff8 feat(frontend): disable the build button while the agent is running (planning agent) (#12643) 2026-01-30 15:13:05 +07:00
chuckbutkus fdb04dfe5d Add GitLab provider check to schedule_gitlab_repo_sync (#12680)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 23:56:36 -05:00
Tim O'Farrell 3d4cb89441 fix(frontend): Support V1 conversations in MetricsModal (#12678)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 15:03:06 -07:00
Tim O'Farrell 9fb9efd3d2 Refactor LiteLLM key updates to use generic user key API (#12664)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 09:53:01 -07:00
Engel Nyst 5511c01c2e chore: clarify draft guidance in PR template (#12670)
Co-authored-by: smolpaws <engel@enyst.org>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2026-01-29 14:17:35 +00:00
Engel Nyst 02825fb5bb Cleanup CLI directory (#12669) 2026-01-29 15:14:50 +01:00
HeyItsChloe 876e773589 chore(frontend): convo tab only renders active/selected tab (#12570)
Co-authored-by: Chloe <chloe@openhands.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-29 16:47:41 +07:00
MkDev11 9e1ae86191 fix: hide Notes from task list when notes are empty (#12668)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
2026-01-29 16:13:23 +07:00
Hiep Le df47b7b79d feat(frontend): ensure the planner tab opens when the view button is selected (#12621) 2026-01-29 13:12:09 +07:00
chuckbutkus 7d1c105b55 Move settings to dict to after openhands (#12663) 2026-01-28 23:45:01 +00:00
Tim O'Farrell db6a9e8895 Fix broken key migration by decrypting legacy encrypted keys before LiteLLM update (#12657) 2026-01-28 15:09:50 -07:00
Hiep Le d76ac44dc3 refactor(frontend): reduce heading text size for plan preview content (#12620) 2026-01-29 00:30:40 +07:00
MkDev11 c483c80a3c feat: add host network support for V1 DockerSandboxService (#12445)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-28 09:55:38 -07:00
chuckbutkus 570ab904f6 Fix UserSettings creation from Org tables (#12635)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
2026-01-28 09:35:05 -07:00
sp.wack 00a74731ae chore: refresh frontend lockfile (#12619) 2026-01-28 13:03:42 +04:00
Tim O'Farrell 102095affb Add downgrade script and methods for reverting user migration (#12629)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: chuckbutkus <chuck@all-hands.dev>
2026-01-27 14:41:34 -07:00
John-Mason P. Shackelford b6ce45b474 feat(app_server): start conversations with remote plugins via REST API (#12338)
- Add `PluginSpec` model with plugin configuration parameters extending SDK's `PluginSource`
- Extend app-conversations API to accept plugins specification in `AppConversationStartRequest`
- Propagate plugin source, ref, and repo_path to agent server's `StartConversationRequest`
- Include plugin parameters in initial conversation message for agent context

Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-27 16:26:38 -05:00
Tim O'Farrell 11c87caba4 fix(backend): fix callback state not persisting due to dual-session conflict (#12627)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-27 10:02:48 -07:00
Abhay Mishra b8a608c45e Fix: branch/repo dropdown reset on click (#12501)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-01-27 16:08:08 +07:00
Tim O'Farrell 8a446787be Migrate SDK to 1.10.0 (#12614) 2026-01-27 04:26:06 +00:00
Tim O'Farrell 353124e171 Bump SDK to 1.10.0 (#12613) 2026-01-27 03:50:30 +00:00
chuckbutkus e9298c89bd Fix org migration (#12612)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-01-26 20:55:55 -05:00
924 changed files with 46752 additions and 73776 deletions
+116 -21
View File
@@ -5,52 +5,113 @@ labels: ['bug']
body:
- type: markdown
attributes:
value: Thank you for taking the time to fill out this bug report. Please provide as much information as possible
to help us understand and address the issue effectively.
value: |
## Thank you for reporting a bug! 🐛
**Please fill out all required fields.** Issues missing critical information (version, installation method, reproduction steps, etc.) will be delayed or closed until complete details are provided.
Clear, detailed reports help us resolve issues faster.
- type: checkboxes
attributes:
label: Is there an existing issue for the same bug? (If one exists, thumbs up or comment on the issue instead).
description: Please check if an issue already exists for the bug you encountered.
label: Is there an existing issue for the same bug?
description: Please search existing issues before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
options:
- label: I have checked the existing issues.
- label: I have searched existing issues and this is not a duplicate.
required: true
- type: textarea
id: bug-description
attributes:
label: Describe the bug and reproduction steps
description: Provide a description of the issue along with any reproduction steps.
label: Bug Description
description: Clearly describe what went wrong. Be specific and concise.
placeholder: Example - "When I run a Python task, OpenHands crashes after 30 seconds with a connection timeout error."
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Example - "OpenHands should execute the Python script and return results."
validations:
required: false
- type: textarea
id: actual-behavior
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Example - "Connection timed out after 30 seconds, task failed with error code 500."
validations:
required: false
- type: textarea
id: reproduction-steps
attributes:
label: Steps to Reproduce
description: Provide clear, step-by-step instructions to reproduce the bug.
placeholder: |
1. Install OpenHands using Docker
2. Configure with Claude 3.5 Sonnet
3. Run command: `openhands run "write a python script"`
4. Wait 30 seconds
5. Error appears
validations:
required: false
- type: dropdown
id: installation
attributes:
label: OpenHands Installation
label: OpenHands Installation Method
description: How are you running OpenHands?
options:
- Docker command in README
- GitHub resolver
- CLI (uv tool install)
- CLI (executable binary)
- CLI (Docker)
- Local GUI (Docker web interface)
- OpenHands Cloud (app.all-hands.dev)
- SDK (Python library)
- Development workflow
- CLI
- app.all-hands.dev
- Other
default: 0
validations:
required: false
- type: input
id: installation-other
attributes:
label: If you selected "Other", please specify
description: Describe your installation method
placeholder: ex. Custom Kubernetes deployment, pip install from source, etc.
- type: input
id: openhands-version
attributes:
label: OpenHands Version
description: What version of OpenHands are you using?
placeholder: ex. 0.9.8, main, etc.
description: What version are you using? Find this in settings or by running `openhands --version`
placeholder: ex. 0.9.8, main, commit hash, etc.
validations:
required: false
- type: checkboxes
id: version-confirmation
attributes:
label: Version Confirmation
description: Bugs on older versions may already be fixed. Please upgrade before submitting.
options:
- label: "I have confirmed this bug exists on the LATEST version of OpenHands"
required: false
- type: input
id: model-name
attributes:
label: Model Name
description: What model are you using?
placeholder: ex. gpt-4o, claude-3-5-sonnet, openrouter/deepseek-r1, etc.
description: Which LLM model are you using?
placeholder: ex. gpt-4o, claude-3-5-sonnet-20241022, openrouter/deepseek-r1, etc.
validations:
required: false
- type: dropdown
id: os
@@ -60,12 +121,46 @@ body:
- MacOS
- Linux
- WSL on Windows
- Windows (Docker Desktop)
- Other
validations:
required: false
- type: input
id: browser
attributes:
label: Browser (if using web UI)
description: |
If applicable, which browser and version?
placeholder: ex. Chrome 131, Firefox 133, Safari 17.2
- type: textarea
id: logs
attributes:
label: Logs and Error Messages
description: |
**Paste relevant logs, error messages, or stack traces.** Use code blocks (```) for formatting.
LLM logs are in `logs/llm/default/`. Include timestamps if errors occurred at a specific time.
placeholder: |
```
Paste error logs here
```
- type: textarea
id: additional-context
attributes:
label: Logs, Errors, Screenshots, and Additional Context
description: Please provide any additional information you think might help. If you want to share the chat history
you can click the thumbs-down (👎) button above the input field and you will get a shareable link
(you can also click thumbs up when things are going well of course!). LLM logs will be stored in the
`logs/llm/default` folder. Please add any additional context about the problem here.
label: Screenshots and Additional Context
description: |
Add screenshots, videos, runtime environment, or other context that helps explain the issue.
💡 **Share conversation history:** In the OpenHands chat UI, click the 👎 or 👍 button (above the message input) to generate a shareable link to your conversation.
placeholder: Drag and drop screenshots here, paste links, or add additional context.
- type: markdown
attributes:
value: |
---
**Note:** Issues with incomplete information may be closed or deprioritized. Maintainers and community members have limited bandwidth and prioritize well-documented bugs that are easier to reproduce and fix. Thank you for your understanding!
+2
View File
@@ -0,0 +1,2 @@
# disable blank issue creation
blank_issues_enabled: false
-17
View File
@@ -1,17 +0,0 @@
---
name: Feature Request or Enhancement
about: Suggest an idea for an OpenHands feature or enhancement
title: ''
labels: 'enhancement'
assignees: ''
---
**What problem or use case are you trying to solve?**
**Describe the UX or technical implementation you have in mind**
**Additional context**
### If you find this feature request or enhancement useful, make sure to add a 👍 to the issue
+105
View File
@@ -0,0 +1,105 @@
name: Feature Request or Enhancement
description: Suggest a new feature or improvement for OpenHands
title: '[Feature]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
## Thank you for suggesting a feature! 💡
**Please provide detailed information.** Vague or low-effort requests may be closed. Well-documented feature requests with strong community support are more likely to be added to the roadmap.
- type: checkboxes
attributes:
label: Is there an existing feature request for this?
description: Please search existing issues and feature requests before creating a new one. If found, react or comment to the duplicate issue instead of making a new one.
options:
- label: I have searched existing issues and feature requests, and this is not a duplicate.
required: true
- type: textarea
id: problem-statement
attributes:
label: Problem or Use Case
description: What problem are you trying to solve? What use case would this feature enable?
placeholder: |
Example - "As a developer working on large codebases, I need to search across multiple files simultaneously. Currently, I have to search file-by-file which is time-consuming and inefficient."
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: Describe your ideal solution. What should this feature do? How should it work?
placeholder: |
Example - "Add a global search feature that allows searching across all files in the workspace. Results should show file name, line number, and context around matches. Include regex support and filtering options."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds? What are their limitations?
placeholder: Example - "I tried using grep in the terminal, but it's not integrated with the UI and doesn't provide click-to-navigate functionality."
- type: dropdown
id: priority
attributes:
label: Priority / Severity
description: How important is this feature to your workflow?
options:
- "Critical - Blocking my work, no workaround available"
- "High - Significant impact on productivity"
- "Medium - Would improve experience"
- "Low - Nice to have"
default: 2
validations:
required: true
- type: dropdown
id: scope
attributes:
label: Estimated Scope
description: To the best of your knowledge, how complex do you think this feature would be to implement?
options:
- "Small - UI tweak, config option, or minor change"
- "Medium - New feature with moderate complexity"
- "Large - Significant feature requiring architecture changes"
- "Unknown - Not sure about the technical complexity"
default: 3
- type: dropdown
id: feature-area
attributes:
label: Feature Area
description: Which part of OpenHands does this feature relate to? If you select "Other", please specify the area in the Additional Context section below.
options:
- "Agent / AI behavior"
- "User Interface / UX"
- "CLI / Command-line interface"
- "File system / Workspace management"
- "Configuration / Settings"
- "Integrations (GitHub, GitLab, etc.)"
- "Performance / Optimization"
- "Documentation"
- "Other"
validations:
required: true
- type: textarea
id: technical-details
attributes:
label: Technical Implementation Ideas (Optional)
description: If you have technical expertise, share implementation ideas, API suggestions, or relevant technical details.
placeholder: |
Example - "Could use ripgrep library for fast search. Expose results via /api/search endpoint. Frontend can use virtualized list for rendering large result sets."
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context, screenshots, mockups, or examples that help illustrate this feature request.
placeholder: Drag and drop screenshots, mockups, or links here.
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- Ideally you should open a PR when it is ready for review. Draft PRs will not be reviewed -->
<!-- If you are still working on the PR, please mark it as draft. Maintainers will review PRs marked ready for review, which leads to lost time if your PR is actually not ready yet. Keep the PR marked as draft until it is finally ready for review -->
## Summary of PR
-29
View File
@@ -1,29 +0,0 @@
# Feature branch preview for enterprise code
name: Enterprise Preview
# Run on PRs labeled
on:
pull_request:
types: [labeled]
# Match ghcr-build.yml, but don't interrupt it.
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: false
jobs:
# This must happen for the PR Docker workflow when the label is present,
# and also if it's added after the fact. Thus, it exists in both places.
enterprise-preview:
name: Enterprise preview
if: github.event.label.name == 'deploy'
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
# This should match the version in ghcr-build.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
+1 -15
View File
@@ -9,6 +9,7 @@ on:
push:
branches:
- main
- "saas-rel-*"
tags:
- "*"
pull_request:
@@ -239,21 +240,6 @@ jobs:
# Add build attestations for better security
sbom: true
enterprise-preview:
name: Enterprise preview
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_enterprise]
steps:
# This should match the version in enterprise-preview.yml
- name: Trigger remote job
run: |
curl --fail-with-body -sS -X POST \
-H "Authorization: Bearer ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
# "All Runtime Tests Passed" is a required job for PRs to merge
# We can remove this once the config changes
runtime_tests_check_success:
@@ -0,0 +1,48 @@
---
name: PR Review by OpenHands
on:
# TEMPORARY MITIGATION (Clinejection hardening)
#
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
# workflow is fully hardened for untrusted execution.
pull_request:
types: [opened, ready_for_review, labeled, review_requested]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-review:
# Note: fork PRs will not have access to repository secrets under `pull_request`.
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
if: |
github.event.pull_request.head.repo.full_name == github.repository &&
(
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
github.event.action == 'ready_for_review' ||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
(
github.event.action == 'review_requested' &&
(
github.event.requested_reviewer.login == 'openhands-agent' ||
github.event.requested_reviewer.login == 'all-hands-bot'
)
)
)
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
runs-on: ubuntu-24.04
steps:
- name: Run PR Review
uses: OpenHands/extensions/plugins/pr-review@main
with:
llm-model: litellm_proxy/claude-sonnet-4-5-20250929
llm-base-url: https://llm-proxy.app.all-hands.dev
review-style: roasted
llm-api-key: ${{ secrets.LLM_API_KEY }}
github-token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
lmnr-api-key: ${{ secrets.LMNR_SKILLS_API_KEY }}
@@ -0,0 +1,85 @@
---
name: PR Review Evaluation
# This workflow evaluates how well PR review comments were addressed.
# It runs when a PR is closed to assess review effectiveness.
#
# Security note: pull_request_target is safe here because:
# 1. Only triggers on PR close (not on code changes)
# 2. Does not checkout PR code - only downloads artifacts from trusted workflow runs
# 3. Runs evaluation scripts from the extensions repo, not from the PR
on:
pull_request_target:
types: [closed]
permissions:
contents: read
pull-requests: read
jobs:
evaluate:
runs-on: ubuntu-24.04
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO_NAME: ${{ github.repository }}
PR_MERGED: ${{ github.event.pull_request.merged }}
steps:
- name: Download review trace artifact
id: download-trace
uses: dawidd6/action-download-artifact@v6
continue-on-error: true
with:
workflow: pr-review-by-openhands.yml
name: pr-review-trace-${{ github.event.pull_request.number }}
path: trace-info
search_artifacts: true
if_no_artifact_found: warn
- name: Check if trace file exists
id: check-trace
run: |
if [ -f "trace-info/laminar_trace_info.json" ]; then
echo "trace_exists=true" >> $GITHUB_OUTPUT
echo "Found trace file for PR #$PR_NUMBER"
else
echo "trace_exists=false" >> $GITHUB_OUTPUT
echo "No trace file found for PR #$PR_NUMBER - skipping evaluation"
fi
# 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
with:
repository: OpenHands/extensions
path: extensions
- name: Set up Python
if: steps.check-trace.outputs.trace_exists == 'true'
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install dependencies
if: steps.check-trace.outputs.trace_exists == 'true'
run: pip install lmnr
- name: Run evaluation
if: steps.check-trace.outputs.trace_exists == 'true'
env:
# Script expects LMNR_PROJECT_API_KEY; org secret is named LMNR_SKILLS_API_KEY
LMNR_PROJECT_API_KEY: ${{ secrets.LMNR_SKILLS_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python extensions/plugins/pr-review/scripts/evaluate_review.py \
--trace-file trace-info/laminar_trace_info.json
- name: Upload evaluation logs
uses: actions/upload-artifact@v5
if: always() && steps.check-trace.outputs.trace_exists == 'true'
with:
name: pr-review-evaluation-${{ github.event.pull_request.number }}
path: '*.log'
retention-days: 30
+2 -1
View File
@@ -6,11 +6,12 @@ Thanks for your interest in contributing to OpenHands! We welcome and appreciate
To understand the codebase, please refer to the README in each module:
- [frontend](./frontend/README.md)
- [evaluation](./evaluation/README.md)
- [openhands](./openhands/README.md)
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
## Setting up Your Development Environment
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
+1 -1
View File
@@ -200,7 +200,7 @@ Here's a guide to the important documentation files in the repository:
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [/evaluation/README.md](./evaluation/README.md): Documentation for the evaluation framework and benchmarks
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
+1 -1
View File
@@ -54,7 +54,7 @@ The experience will be familiar to anyone who has used Devin or Jules.
### OpenHands Cloud
This is a deployment of OpenHands GUI, running on hosted infrastructure.
You can try it with a free $10 credit by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
You can try it for free using the Minimax model by [signing in with your GitHub or GitLab account](https://app.all-hands.dev).
OpenHands Cloud comes with source-available features and integrations:
- Integrations with Slack, Jira, and Linear
-6
View File
@@ -440,12 +440,6 @@ type = "noop"
#temperature = 0.1
#max_input_tokens = 1024
#################################### Eval ####################################
# Configuration for the evaluation, please refer to the specific evaluation
# plugin for the available options
##############################################################################
########################### Kubernetes #######################################
# Kubernetes configuration when using the Kubernetes runtime
##############################################################################
+17 -6
View File
@@ -23,12 +23,23 @@ RUN apt-get update && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install Python packages with security fixes
RUN /app/.venv/bin/pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy google-cloud-recaptcha-enterprise && \
# Update packages with known CVE fixes
/app/.venv/bin/pip install --upgrade \
"mcp>=1.10.0" \
"pillow>=11.3.0"
# Install poetry and export before importing current code.
RUN /app/.venv/bin/pip install poetry poetry-plugin-export
# Install Python dependencies from poetry.lock for reproducible builds
# Copy lock files first for better Docker layer caching
COPY --chown=openhands:openhands enterprise/pyproject.toml enterprise/poetry.lock /tmp/enterprise/
RUN cd /tmp/enterprise && \
# Export only main dependencies with hashes for supply chain security
/app/.venv/bin/poetry export --only main -o requirements.txt && \
# Remove the local path dependency (openhands-ai is already in base image)
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
# Install pinned dependencies from lock file
/app/.venv/bin/pip install -r requirements.txt && \
# Cleanup - return to /app before removing /tmp/enterprise
cd /app && \
rm -rf /tmp/enterprise && \
/app/.venv/bin/pip uninstall -y poetry poetry-plugin-export
WORKDIR /app
COPY --chown=openhands:openhands --chmod=770 enterprise .
@@ -0,0 +1,131 @@
# Plugin Launch Flow
This document describes how plugins are launched in OpenHands Saas / Enterprise, from the plugin directory through to agent execution.
## Architecture Overview
```
Plugin Directory ──▶ Frontend /launch ──▶ App Server ──▶ Agent Server ──▶ SDK
(external) (modal) (API) (in sandbox) (plugin loading)
```
| Component | Responsibility |
|-----------|---------------|
| **Plugin Directory** | Index plugins, present to user, construct launch URLs |
| **Frontend** | Display confirmation modal, collect parameters, call API |
| **App Server** | Validate request, pass plugin specs to agent server |
| **Agent Server** | Run inside sandbox, delegate plugin loading to SDK |
| **SDK** | Fetch plugins, load contents, merge skills/hooks/MCP into agent |
## User Experience
### Plugin Directory
The plugin directory presents users with a catalog of available plugins. For each plugin, users see:
- Plugin name and description (from `plugin.json`)
- Author and version information
- A "Launch" button
When a user clicks "Launch", the plugin directory:
1. Reads the plugin's `entry_command` to know which slash command to invoke
2. Determines what parameters the plugin accepts (if any)
3. Redirects to OpenHands with this information encoded in the URL
### Parameter Collection
If a plugin requires user input (API keys, configuration values, etc.), the frontend displays a form modal before starting the conversation. Parameters are passed in the launch URL and rendered as form fields based on their type:
- **String values** → Text input
- **Number values** → Number input
- **Boolean values** → Checkbox
Only primitive types are supported. Complex types (arrays, objects) are not currently supported for parameter input.
The user fills in required values, then clicks "Start Conversation" to proceed.
## Launch Flow
1. **Plugin Directory** (external) constructs a launch URL to the OpenHands app server when user clicks "Launch":
```
/launch?plugins=BASE64_JSON&message=/city-weather:now%20Tokyo
```
The `plugins` parameter includes any parameter definitions with default values:
```json
[{
"source": "github:owner/repo",
"repo_path": "plugins/my-plugin",
"parameters": {"api_key": "", "timeout": 30, "debug": false}
}]
```
2. **OpenHands Frontend** (`/launch` route, [PR #12699](https://github.com/OpenHands/OpenHands/pull/12699)) displays modal with parameter form, collects user input
3. **OpenHands App Server** ([PR #12338](https://github.com/OpenHands/OpenHands/pull/12338)) receives the API call:
```
POST /api/v1/app-conversations
{
"plugins": [{"source": "github:owner/repo", "repo_path": "plugins/city-weather"}],
"initial_message": {"content": [{"type": "text", "text": "/city-weather:now Tokyo"}]}
}
```
Call stack:
- `AppConversationRouter` receives request with `PluginSpec` list
- `LiveStatusAppConversationService._finalize_conversation_request()` converts `PluginSpec` → `PluginSource`
- Creates `StartConversationRequest(plugins=sdk_plugins, ...)` and sends to agent server
4. **Agent Server** (inside sandbox, [SDK PR #1651](https://github.com/OpenHands/software-agent-sdk/pull/1651)) stores specs, defers loading:
Call stack:
- `ConversationService.start_conversation()` receives `StartConversationRequest`
- Creates `StoredConversation` with plugin specs
- Creates `LocalConversation(plugins=request.plugins, ...)`
- Plugin loading deferred until first `run()` or `send_message()`
5. **SDK** fetches and loads plugins on first use:
Call stack:
- `LocalConversation._ensure_plugins_loaded()` triggered by first message
- For each plugin spec:
- `Plugin.fetch(source, ref, repo_path)` → clones/caches git repo
- `Plugin.load(path)` → parses `plugin.json`, loads commands/skills/hooks
- `plugin.add_skills_to(context)` → merges skills into agent
- `plugin.add_mcp_config_to(config)` → merges MCP servers
6. **Agent** receives message, `/city-weather:now` triggers the skill
## Key Design Decisions
### Plugin Loading in Sandbox
Plugins load **inside the sandbox** because:
- Plugin hooks and scripts need isolated execution
- MCP servers run inside the sandbox
- Skills may reference sandbox filesystem
### Entry Command Handling
The `entry_command` field in `plugin.json` allows plugin authors to declare a default command:
```json
{
"name": "city-weather",
"entry_command": "now"
}
```
This flows through the system:
1. Plugin author declares `entry_command` in plugin.json
2. Plugin directory reads it when indexing
3. Plugin directory includes `/city-weather:now` in the launch URL's `message` parameter
4. Message passes through to agent as `initial_message`
The SDK exposes this field but does not auto-invoke it—callers control the initial message.
## Related
- [OpenHands PR #12338](https://github.com/OpenHands/OpenHands/pull/12338) - App server plugin support
- [OpenHands PR #12699](https://github.com/OpenHands/OpenHands/pull/12699) - Frontend `/launch` route
- [SDK PR #1651](https://github.com/OpenHands/software-agent-sdk/pull/1651) - Agent server plugin loading
- [SDK PR #1647](https://github.com/OpenHands/software-agent-sdk/pull/1647) - Plugin.fetch() for remote plugin fetching
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python
"""
This script can be removed once orgs is established - probably after Feb 15 2026
Downgrade script for migrated users.
This script identifies users who have been migrated (already_migrated=True)
and reverts them back to the pre-migration state.
Usage:
# Dry run - just list the users that would be downgraded
python downgrade_migrated_users.py --dry-run
# Downgrade a specific user by their keycloak_user_id
python downgrade_migrated_users.py --user-id <user_id>
# Downgrade all migrated users (with confirmation)
python downgrade_migrated_users.py --all
# Downgrade all migrated users without confirmation (dangerous!)
python downgrade_migrated_users.py --all --no-confirm
"""
import argparse
import asyncio
import sys
# Add the enterprise directory to the path
sys.path.insert(0, '/workspace/project/OpenHands/enterprise')
from server.logger import logger
from sqlalchemy import select, text
from storage.database import session_maker
from storage.user_settings import UserSettings
from storage.user_store import UserStore
def get_migrated_users() -> list[str]:
"""Get list of keycloak_user_ids for users who have been migrated.
This includes:
1. Users with already_migrated=True in user_settings (migrated users)
2. Users in the 'user' table who don't have a user_settings entry (new sign-ups)
"""
with session_maker() as session:
# Get users from user_settings with already_migrated=True
migrated_result = session.execute(
select(UserSettings.keycloak_user_id).where(
UserSettings.already_migrated.is_(True)
)
)
migrated_users = {row[0] for row in migrated_result.fetchall() if row[0]}
# Get users from the 'user' table (new sign-ups won't have user_settings)
# These are users who signed up after the migration was deployed
new_signup_result = session.execute(
text("""
SELECT CAST(u.id AS VARCHAR)
FROM "user" u
WHERE NOT EXISTS (
SELECT 1 FROM user_settings us
WHERE us.keycloak_user_id = CAST(u.id AS VARCHAR)
)
""")
)
new_signups = {row[0] for row in new_signup_result.fetchall() if row[0]}
# Combine both sets
all_users = migrated_users | new_signups
return list(all_users)
async def downgrade_user(user_id: str) -> bool:
"""Downgrade a single user.
Args:
user_id: The keycloak_user_id to downgrade
Returns:
True if successful, False otherwise
"""
try:
result = await UserStore.downgrade_user(user_id)
if result:
print(f'✓ Successfully downgraded user: {user_id}')
return True
else:
print(f'✗ Failed to downgrade user: {user_id}')
return False
except Exception as e:
print(f'✗ Error downgrading user {user_id}: {e}')
logger.exception(
'downgrade_script:error',
extra={'user_id': user_id, 'error': str(e)},
)
return False
async def main():
parser = argparse.ArgumentParser(
description='Downgrade migrated users back to pre-migration state'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Just list users that would be downgraded, without making changes',
)
parser.add_argument(
'--user-id',
type=str,
help='Downgrade a specific user by keycloak_user_id',
)
parser.add_argument(
'--all',
action='store_true',
help='Downgrade all migrated users',
)
parser.add_argument(
'--no-confirm',
action='store_true',
help='Skip confirmation prompt (use with caution!)',
)
args = parser.parse_args()
# Get list of migrated users
migrated_users = get_migrated_users()
print(f'\nFound {len(migrated_users)} migrated user(s).')
if args.dry_run:
print('\n--- DRY RUN MODE ---')
print('The following users would be downgraded:')
for user_id in migrated_users:
print(f' - {user_id}')
print('\nNo changes were made.')
return
if args.user_id:
# Downgrade a specific user
if args.user_id not in migrated_users:
print(f'\nUser {args.user_id} is not in the migrated users list.')
print('Either the user was not migrated, or the user_id is incorrect.')
return
print(f'\nDowngrading user: {args.user_id}')
if not args.no_confirm:
confirm = input('Are you sure? (yes/no): ')
if confirm.lower() != 'yes':
print('Cancelled.')
return
success = await downgrade_user(args.user_id)
if success:
print('\nDowngrade completed successfully.')
else:
print('\nDowngrade failed. Check logs for details.')
sys.exit(1)
elif args.all:
# Downgrade all migrated users
if not migrated_users:
print('\nNo migrated users to downgrade.')
return
print(f'\n⚠️ About to downgrade {len(migrated_users)} user(s).')
if not args.no_confirm:
print('\nThis will:')
print(' - Revert LiteLLM team/user budget settings')
print(' - Delete organization entries')
print(' - Delete user entries in the new schema')
print(' - Reset the already_migrated flag')
print('\nUsers to downgrade:')
for user_id in migrated_users[:10]: # Show first 10
print(f' - {user_id}')
if len(migrated_users) > 10:
print(f' ... and {len(migrated_users) - 10} more')
confirm = input('\nType "yes" to proceed: ')
if confirm.lower() != 'yes':
print('Cancelled.')
return
print('\nStarting downgrade...\n')
success_count = 0
fail_count = 0
for user_id in migrated_users:
success = await downgrade_user(user_id)
if success:
success_count += 1
else:
fail_count += 1
print('\n--- Summary ---')
print(f'Successful: {success_count}')
print(f'Failed: {fail_count}')
if fail_count > 0:
sys.exit(1)
else:
parser.print_help()
print('\nPlease specify --dry-run, --user-id, or --all')
if __name__ == '__main__':
asyncio.run(main())
+5 -3
View File
@@ -28,9 +28,11 @@ class SaaSExperimentManager(ExperimentManager):
return agent
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
)
# Skip experiment for planning agents which require their specialized prompt
if agent.system_prompt_filename != 'system_prompt_planning.j2':
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
)
return agent
@@ -145,11 +145,7 @@ class GithubManager(Manager):
).get('body', ''):
return False
if GithubFactory.is_eligible_for_conversation_starter(
message
) and self._user_has_write_access_to_repo(installation_id, repo_name, username):
await GithubFactory.trigger_conversation_starter(message)
# Check event types before making expensive API calls (e.g., _user_has_write_access_to_repo)
if not (
GithubFactory.is_labeled_issue(message)
or GithubFactory.is_issue_comment(message)
@@ -159,8 +155,17 @@ class GithubManager(Manager):
return False
logger.info(f'[GitHub] Checking permissions for {username} in {repo_name}')
user_has_write_access = self._user_has_write_access_to_repo(
installation_id, repo_name, username
)
return self._user_has_write_access_to_repo(installation_id, repo_name, username)
if (
GithubFactory.is_eligible_for_conversation_starter(message)
and user_has_write_access
):
await GithubFactory.trigger_conversation_starter(message)
return user_has_write_access
async def receive_message(self, message: Message):
self._confirm_incoming_source_type(message)
@@ -188,14 +193,20 @@ class GithubManager(Manager):
github_view.installation_id
)
# Store the installation token
self.token_manager.store_org_token(
await self.token_manager.store_org_token(
github_view.installation_id, installation_token
)
# Add eyes reaction to acknowledge we've read the request
self._add_reaction(github_view, 'eyes', installation_token)
await self.start_job(github_view)
async def send_message(self, message: Message, github_view: ResolverViewInterface):
async def send_message(self, message: str, github_view: ResolverViewInterface):
"""Send a message to GitHub.
Args:
message: The message content to send (plain text string)
github_view: The GitHub view object containing issue/PR/comment info
"""
installation_token = self.token_manager.load_org_token(
github_view.installation_id
)
@@ -203,14 +214,12 @@ class GithubManager(Manager):
logger.warning('Missing installation token')
return
outgoing_message = message.message
if isinstance(github_view, GithubInlinePRComment):
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
pr = repo.get_pull(github_view.issue_number)
pr.create_review_comment_reply(
comment_id=github_view.comment_id, body=outgoing_message
comment_id=github_view.comment_id, body=message
)
elif (
@@ -221,7 +230,7 @@ class GithubManager(Manager):
with Github(auth=Auth.Token(installation_token)) as github_client:
repo = github_client.get_repo(github_view.full_repo_name)
issue = repo.get_issue(number=github_view.issue_number)
issue.create_comment(outgoing_message)
issue.create_comment(message)
else:
logger.warning('Unsupported location')
@@ -240,7 +249,7 @@ class GithubManager(Manager):
)
try:
msg_info = None
msg_info: str = ''
try:
user_info = github_view.user_info
@@ -356,15 +365,13 @@ class GithubManager(Manager):
msg_info = get_session_expired_message(user_info.username)
msg = self.create_outgoing_message(msg_info)
await self.send_message(msg, github_view)
await self.send_message(msg_info, github_view)
except Exception:
logger.exception('[Github]: Error starting job')
msg = self.create_outgoing_message(
msg='Uh oh! There was an unexpected error starting the job :('
await self.send_message(
'Uh oh! There was an unexpected error starting the job :(', github_view
)
await self.send_message(msg, github_view)
try:
await self.data_collector.save_data(github_view)
@@ -14,7 +14,6 @@ from integrations.solvability.models.summary import SolvabilitySummary
from integrations.utils import ENABLE_SOLVABILITY_ANALYSIS
from pydantic import ValidationError
from server.config import get_config
from storage.database import session_maker
from storage.saas_settings_store import SaasSettingsStore
from openhands.core.config import LLMConfig
@@ -90,7 +89,6 @@ async def summarize_issue_solvability(
# Grab the user's information so we can load their LLM configuration
store = SaasSettingsStore(
user_id=github_view.user_info.keycloak_user_id,
session_maker=session_maker,
config=get_config(),
)
@@ -24,7 +24,6 @@ from jinja2 import Environment
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.database import session_maker
from storage.org_store import OrgStore
from storage.proactive_conversation_store import ProactiveConversationStore
from storage.saas_secrets_store import SaasSecretsStore
@@ -153,9 +152,7 @@ class GithubIssue(ResolverViewInterface):
return user_instructions, conversation_instructions
async def _get_user_secrets(self):
secrets_store = SaasSecretsStore(
self.user_info.keycloak_user_id, session_maker, get_config()
)
secrets_store = SaasSecretsStore(self.user_info.keycloak_user_id, get_config())
user_secrets = await secrets_store.load()
return user_secrets.custom_secrets if user_secrets else None
@@ -121,12 +121,11 @@ class GitlabManager(Manager):
# Check if the user has write access to the repository
return has_write_access
async def send_message(self, message: Message, gitlab_view: ResolverViewInterface):
"""
Send a message to GitLab based on the view type.
async def send_message(self, message: str, gitlab_view: ResolverViewInterface):
"""Send a message to GitLab based on the view type.
Args:
message: The message to send
message: The message content to send (plain text string)
gitlab_view: The GitLab view object containing issue/PR/comment info
"""
keycloak_user_id = gitlab_view.user_info.keycloak_user_id
@@ -138,8 +137,6 @@ class GitlabManager(Manager):
external_auth_id=keycloak_user_id
)
outgoing_message = message.message
if isinstance(gitlab_view, GitlabInlineMRComment) or isinstance(
gitlab_view, GitlabMRComment
):
@@ -147,7 +144,7 @@ class GitlabManager(Manager):
gitlab_view.project_id,
gitlab_view.issue_number,
gitlab_view.discussion_id,
message.message,
message,
)
elif isinstance(gitlab_view, GitlabIssueComment):
@@ -155,14 +152,14 @@ class GitlabManager(Manager):
gitlab_view.project_id,
gitlab_view.issue_number,
gitlab_view.discussion_id,
outgoing_message,
message,
)
elif isinstance(gitlab_view, GitlabIssue):
await gitlab_service.reply_to_issue(
gitlab_view.project_id,
gitlab_view.issue_number,
None, # no discussion id, issue is tagged
outgoing_message,
message,
)
else:
logger.warning(
@@ -262,12 +259,10 @@ class GitlabManager(Manager):
msg_info = get_session_expired_message(user_info.username)
# Send the acknowledgment message
msg = self.create_outgoing_message(msg_info)
await self.send_message(msg, gitlab_view)
await self.send_message(msg_info, gitlab_view)
except Exception as e:
logger.exception(f'[GitLab] Error starting job: {str(e)}')
msg = self.create_outgoing_message(
msg='Uh oh! There was an unexpected error starting the job :('
await self.send_message(
'Uh oh! There was an unexpected error starting the job :(', gitlab_view
)
await self.send_message(msg, gitlab_view)
@@ -6,7 +6,6 @@ from integrations.utils import HOST, get_oh_labels, has_exact_mention
from jinja2 import Environment
from server.auth.token_manager import TokenManager
from server.config import get_config
from storage.database import session_maker
from storage.saas_secrets_store import SaasSecretsStore
from openhands.core.logger import openhands_logger as logger
@@ -78,9 +77,7 @@ class GitlabIssue(ResolverViewInterface):
return user_instructions, conversation_instructions
async def _get_user_secrets(self):
secrets_store = SaasSecretsStore(
self.user_info.keycloak_user_id, session_maker, get_config()
)
secrets_store = SaasSecretsStore(self.user_info.keycloak_user_id, get_config())
user_secrets = await secrets_store.load()
return user_secrets.custom_secrets if user_secrets else None
@@ -449,3 +446,5 @@ class GitlabFactory:
previous_comments=[],
is_mr=True,
)
raise ValueError(f'Unhandled GitLab webhook event: {message}')
@@ -167,17 +167,15 @@ async def install_webhook_on_resource(
scopes=SCOPES,
)
logger.info(
'Creating new webhook',
extra={
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
},
)
log_extra = {
'webhook_id': webhook_id,
'status': status,
'resource_id': resource_id,
'resource_type': resource_type,
}
if status == WebhookStatus.RATE_LIMITED:
logger.warning('Rate limited while creating webhook', extra=log_extra)
raise BreakLoopException()
if webhook_id:
@@ -191,9 +189,8 @@ async def install_webhook_on_resource(
'webhook_uuid': webhook_uuid, # required to identify which webhook installation is sending payload
},
)
logger.info(
f'Installed webhook for {webhook.user_id} on {resource_type}:{resource_id}'
)
logger.info('Created new webhook', extra=log_extra)
else:
logger.error('Failed to create webhook', extra=log_extra)
return webhook_id, status
+13 -5
View File
@@ -341,17 +341,25 @@ class JiraManager(Manager):
async def send_message(
self,
message: Message,
message: str,
issue_key: str,
jira_cloud_id: str,
svc_acc_email: str,
svc_acc_api_key: str,
):
"""Send a comment to a Jira issue."""
"""Send a comment to a Jira issue.
Args:
message: The message content to send (plain text string)
issue_key: The Jira issue key (e.g., 'PROJ-123')
jira_cloud_id: The Jira Cloud ID
svc_acc_email: Service account email for authentication
svc_acc_api_key: Service account API key for authentication
"""
url = (
f'{JIRA_CLOUD_API_URL}/{jira_cloud_id}/rest/api/2/issue/{issue_key}/comment'
)
data = {'body': message.message}
data = {'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
@@ -366,7 +374,7 @@ class JiraManager(Manager):
view.jira_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg),
msg,
issue_key=view.payload.issue_key,
jira_cloud_id=view.jira_workspace.jira_cloud_id,
svc_acc_email=view.jira_workspace.svc_acc_email,
@@ -388,7 +396,7 @@ class JiraManager(Manager):
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(
self.create_outgoing_message(msg=error_msg),
error_msg,
issue_key=payload.issue_key,
jira_cloud_id=workspace.jira_cloud_id,
svc_acc_email=workspace.svc_acc_email,
@@ -212,8 +212,6 @@ class JiraPayloadParser:
missing.append('issue.id')
if not issue_key:
missing.append('issue.key')
if not user_email:
missing.append('user.emailAddress')
if not display_name:
missing.append('user.displayName')
if not account_id:
@@ -418,7 +418,7 @@ class JiraDcManager(Manager):
jira_dc_view.jira_dc_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg_info),
msg_info,
issue_key=jira_dc_view.job_context.issue_key,
base_api_url=jira_dc_view.job_context.base_api_url,
svc_acc_api_key=api_key,
@@ -456,12 +456,19 @@ class JiraDcManager(Manager):
return title, description
async def send_message(
self, message: Message, issue_key: str, base_api_url: str, svc_acc_api_key: str
self, message: str, issue_key: str, base_api_url: str, svc_acc_api_key: str
):
"""Send message/comment to Jira DC issue."""
"""Send message/comment to Jira DC issue.
Args:
message: The message content to send (plain text string)
issue_key: The Jira issue key (e.g., 'PROJ-123')
base_api_url: The base API URL for the Jira DC instance
svc_acc_api_key: Service account API key for authentication
"""
url = f'{base_api_url}/rest/api/2/issue/{issue_key}/comment'
headers = {'Authorization': f'Bearer {svc_acc_api_key}'}
data = {'body': message.message}
data = {'body': message}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()
@@ -481,7 +488,7 @@ class JiraDcManager(Manager):
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(
self.create_outgoing_message(msg=error_msg),
error_msg,
issue_key=job_context.issue_key,
base_api_url=job_context.base_api_url,
svc_acc_api_key=api_key,
@@ -502,7 +509,7 @@ class JiraDcManager(Manager):
)
await self.send_message(
self.create_outgoing_message(msg=comment_msg),
comment_msg,
issue_key=jira_dc_view.job_context.issue_key,
base_api_url=jira_dc_view.job_context.base_api_url,
svc_acc_api_key=api_key,
@@ -19,7 +19,7 @@ class JiraDcViewInterface(ABC):
conversation_id: str
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
pass
@@ -36,7 +36,7 @@ class JiraDcNewConversationView(JiraDcViewInterface):
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
instructions_template = jinja_env.get_template('jira_dc_instructions.j2')
@@ -61,7 +61,7 @@ class JiraDcNewConversationView(JiraDcViewInterface):
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
@@ -113,7 +113,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('jira_dc_existing_conversation.j2')
@@ -167,7 +167,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = self._get_instructions(jinja_env)
_, user_msg = await self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
@@ -408,7 +408,7 @@ class LinearManager(Manager):
linear_view.linear_workspace.svc_acc_api_key
)
await self.send_message(
self.create_outgoing_message(msg=msg_info),
msg_info,
linear_view.job_context.issue_id,
api_key,
)
@@ -473,8 +473,14 @@ class LinearManager(Manager):
return title, description
async def send_message(self, message: Message, issue_id: str, api_key: str):
"""Send message/comment to Linear issue."""
async def send_message(self, message: str, issue_id: str, api_key: str):
"""Send message/comment to Linear issue.
Args:
message: The message content to send (plain text string)
issue_id: The Linear issue ID to comment on
api_key: The Linear API key for authentication
"""
query = """
mutation CommentCreate($input: CommentCreateInput!) {
commentCreate(input: $input) {
@@ -485,7 +491,7 @@ class LinearManager(Manager):
}
}
"""
variables = {'input': {'issueId': issue_id, 'body': message.message}}
variables = {'input': {'issueId': issue_id, 'body': message}}
return await self._query_api(query, variables, api_key)
async def _send_error_comment(
@@ -498,9 +504,7 @@ class LinearManager(Manager):
try:
api_key = self.token_manager.decrypt_text(workspace.svc_acc_api_key)
await self.send_message(
self.create_outgoing_message(msg=error_msg), issue_id, api_key
)
await self.send_message(error_msg, issue_id, api_key)
except Exception as e:
logger.error(f'[Linear] Failed to send error comment: {str(e)}')
@@ -517,7 +521,7 @@ class LinearManager(Manager):
)
await self.send_message(
self.create_outgoing_message(msg=comment_msg),
comment_msg,
linear_view.job_context.issue_id,
api_key,
)
@@ -19,7 +19,7 @@ class LinearViewInterface(ABC):
conversation_id: str
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Get initial instructions for the conversation."""
pass
@@ -33,7 +33,7 @@ class LinearNewConversationView(LinearViewInterface):
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
instructions_template = jinja_env.get_template('linear_instructions.j2')
@@ -58,7 +58,7 @@ class LinearNewConversationView(LinearViewInterface):
provider_tokens = await self.saas_user_auth.get_provider_tokens()
user_secrets = await self.saas_user_auth.get_secrets()
instructions, user_msg = self._get_instructions(jinja_env)
instructions, user_msg = await self._get_instructions(jinja_env)
try:
agent_loop_info = await create_new_conversation(
@@ -110,7 +110,7 @@ class LinearExistingConversationView(LinearViewInterface):
selected_repo: str | None
conversation_id: str
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_msg_template = jinja_env.get_template('linear_existing_conversation.j2')
@@ -164,7 +164,7 @@ class LinearExistingConversationView(LinearViewInterface):
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
_, user_msg = self._get_instructions(jinja_env)
_, user_msg = await self._get_instructions(jinja_env)
user_message_event = MessageAction(content=user_msg)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_message_event)
+7 -5
View File
@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from typing import Any
from integrations.models import Message, SourceType
@@ -12,14 +13,15 @@ class Manager(ABC):
raise NotImplementedError
@abstractmethod
def send_message(self, message: Message):
"Send message to integration from Openhands server"
def send_message(self, message: str, *args: Any, **kwargs: Any):
"""Send message to integration from OpenHands server.
Args:
message: The message content to send (plain text string).
"""
raise NotImplementedError
@abstractmethod
def start_job(self):
"Kick off a job with openhands agent"
raise NotImplementedError
def create_outgoing_message(self, msg: str | dict, ephemeral: bool = False):
return Message(source=SourceType.OPENHANDS, message=msg, ephemeral=ephemeral)
+10 -1
View File
@@ -1,4 +1,5 @@
from enum import Enum
from typing import Any
from pydantic import BaseModel
@@ -16,8 +17,16 @@ class SourceType(str, Enum):
class Message(BaseModel):
"""Message model for incoming webhook payloads from integrations.
Note: This model is intended for INCOMING messages only.
For outgoing messages (e.g., sending comments to GitHub/GitLab),
pass strings directly to the send_message methods instead of
wrapping them in a Message object.
"""
source: SourceType
message: str | dict
message: dict[str, Any]
ephemeral: bool = False
+19 -4
View File
@@ -1,6 +1,6 @@
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
from openhands.integrations.service_types import ProviderType
from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth
@@ -14,6 +14,7 @@ class ResolverUserContext(UserContext):
saas_user_auth: UserAuth,
):
self.saas_user_auth = saas_user_auth
self._provider_handler: ProviderHandler | None = None
async def get_user_id(self) -> str | None:
return await self.saas_user_auth.get_user_id()
@@ -29,12 +30,26 @@ class ResolverUserContext(UserContext):
return UserInfo(id=user_id)
async def _get_provider_handler(self) -> ProviderHandler:
"""Get or create a ProviderHandler for git operations."""
if self._provider_handler is None:
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens is None:
raise ValueError('No provider tokens available')
user_id = await self.saas_user_auth.get_user_id()
self._provider_handler = ProviderHandler(
provider_tokens=provider_tokens, external_auth_id=user_id
)
return self._provider_handler
async def get_authenticated_git_url(
self, repository: str, is_optional: bool = False
) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
provider_handler = await self._get_provider_handler()
url = await provider_handler.get_authenticated_git_url(
repository, is_optional=is_optional
)
return url
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token string from git_provider_tokens
+33 -23
View File
@@ -1,4 +1,5 @@
import re
from typing import Any
import jwt
from integrations.manager import Manager
@@ -22,7 +23,8 @@ from server.constants import SLACK_CLIENT_ID
from server.utils.conversation_callback_utils import register_callback_processor
from slack_sdk.oauth import AuthorizeUrlGenerator
from slack_sdk.web.async_client import AsyncWebClient
from storage.database import session_maker
from sqlalchemy import select
from storage.database import a_session_maker
from storage.slack_user import SlackUser
from openhands.core.logger import openhands_logger as logger
@@ -63,12 +65,11 @@ class SlackManager(Manager):
) -> tuple[SlackUser | None, UserAuth | None]:
# We get the user and correlate them back to a user in OpenHands - if we can
slack_user = None
with session_maker() as session:
slack_user = (
session.query(SlackUser)
.filter(SlackUser.slack_user_id == slack_user_id)
.first()
async with a_session_maker() as session:
result = await session.execute(
select(SlackUser).where(SlackUser.slack_user_id == slack_user_id)
)
slack_user = result.scalar_one_or_none()
# slack_view.slack_to_openhands_user = slack_user # attach user auth info to view
@@ -202,9 +203,7 @@ class SlackManager(Manager):
msg = self.login_link.format(link)
logger.info('slack_not_yet_authenticated')
await self.send_message(
self.create_outgoing_message(msg, ephemeral=True), slack_view
)
await self.send_message(msg, slack_view, ephemeral=True)
return
if not await self.is_job_requested(message, slack_view):
@@ -212,27 +211,40 @@ class SlackManager(Manager):
await self.start_job(slack_view)
async def send_message(self, message: Message, slack_view: SlackViewInterface):
async def send_message(
self,
message: str | dict[str, Any],
slack_view: SlackViewInterface,
ephemeral: bool = False,
):
"""Send a message to Slack.
Args:
message: The message content. Can be a string (for simple text) or
a dict with 'text' and 'blocks' keys (for structured messages).
slack_view: The Slack view object containing channel/thread info.
ephemeral: If True, send as an ephemeral message visible only to the user.
"""
client = AsyncWebClient(token=slack_view.bot_access_token)
if message.ephemeral and isinstance(message.message, str):
if ephemeral and isinstance(message, str):
await client.chat_postEphemeral(
channel=slack_view.channel_id,
markdown_text=message.message,
markdown_text=message,
user=slack_view.slack_user_id,
thread_ts=slack_view.thread_ts,
)
elif message.ephemeral and isinstance(message.message, dict):
elif ephemeral and isinstance(message, dict):
await client.chat_postEphemeral(
channel=slack_view.channel_id,
user=slack_view.slack_user_id,
thread_ts=slack_view.thread_ts,
text=message.message['text'],
blocks=message.message['blocks'],
text=message['text'],
blocks=message['blocks'],
)
else:
await client.chat_postMessage(
channel=slack_view.channel_id,
markdown_text=message.message,
markdown_text=message,
thread_ts=slack_view.message_ts,
)
@@ -279,10 +291,7 @@ class SlackManager(Manager):
repos, slack_view.message_ts, slack_view.thread_ts
),
}
await self.send_message(
self.create_outgoing_message(repo_selection_msg, ephemeral=True),
slack_view,
)
await self.send_message(repo_selection_msg, slack_view, ephemeral=True)
return False
@@ -368,9 +377,10 @@ class SlackManager(Manager):
except StartingConvoException as e:
msg_info = str(e)
await self.send_message(self.create_outgoing_message(msg_info), slack_view)
await self.send_message(msg_info, slack_view)
except Exception:
logger.exception('[Slack]: Error starting job')
msg = 'Uh oh! There was an unexpected error starting the job :('
await self.send_message(self.create_outgoing_message(msg), slack_view)
await self.send_message(
'Uh oh! There was an unexpected error starting the job :(', slack_view
)
+1 -1
View File
@@ -24,7 +24,7 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
v1_enabled: bool
@abstractmethod
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
pass
+11 -7
View File
@@ -75,7 +75,7 @@ class SlackUnkownUserView(SlackViewInterface):
team_id: str
v1_enabled: bool
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
raise NotImplementedError
async def create_or_update_conversation(self, jinja_env: Environment):
@@ -118,7 +118,7 @@ class SlackNewConversationView(SlackViewInterface):
return block['user_id']
return ''
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"""Instructions passed when conversation is first initialized"""
user_info: SlackUser = self.slack_to_openhands_user
@@ -242,7 +242,9 @@ class SlackNewConversationView(SlackViewInterface):
self, jinja: Environment, provider_tokens, user_secrets
) -> None:
"""Create conversation using the legacy V0 system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
user_instructions, conversation_instructions = await self._get_instructions(
jinja
)
# Determine git provider from repository
git_provider = None
@@ -273,7 +275,9 @@ class SlackNewConversationView(SlackViewInterface):
async def _create_v1_conversation(self, jinja: Environment) -> None:
"""Create conversation using the new V1 app conversation system."""
user_instructions, conversation_instructions = self._get_instructions(jinja)
user_instructions, conversation_instructions = await self._get_instructions(
jinja
)
# Create the initial message request
initial_message = SendMessageRequest(
@@ -346,7 +350,7 @@ class SlackNewConversationFromRepoFormView(SlackNewConversationView):
class SlackUpdateExistingConversationView(SlackNewConversationView):
slack_conversation: SlackConversation
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
client = WebClient(token=self.bot_access_token)
result = client.conversations_replies(
channel=self.channel_id,
@@ -401,7 +405,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
if not agent_state or agent_state == AgentState.LOADING:
raise StartingConvoException('Conversation is still starting')
instructions, _ = self._get_instructions(jinja)
instructions, _ = await self._get_instructions(jinja)
user_msg = MessageAction(content=instructions)
await conversation_manager.send_event_to_conversation(
self.conversation_id, event_to_dict(user_msg)
@@ -469,7 +473,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
agent_server_url = get_agent_server_url_from_sandbox(running_sandbox)
# 4. Prepare the message content
user_msg, _ = self._get_instructions(jinja)
user_msg, _ = await self._get_instructions(jinja)
# 5. Create the message request
send_message_request = SendMessageRequest(
+2 -2
View File
@@ -42,11 +42,11 @@ async def store_repositories_in_db(repos: list[Repository], user_id: str) -> Non
try:
# Store repositories in the repos table
repo_store = RepositoryStore.get_instance(config)
repo_store.store_projects(stored_repos)
await repo_store.store_projects(stored_repos)
# Store user-repository mappings in the user-repos table
user_repo_store = UserRepositoryMapStore.get_instance(config)
user_repo_store.store_user_repo_mappings(user_repos)
await user_repo_store.store_user_repo_mappings(user_repos)
logger.info(f'Saved repos for user {user_id}')
except Exception:
+31 -32
View File
@@ -3,8 +3,8 @@ from uuid import UUID
import stripe
from server.constants import STRIPE_API_KEY
from server.logger import logger
from sqlalchemy.orm import Session
from storage.database import session_maker
from sqlalchemy import select
from storage.database import a_session_maker
from storage.org import Org
from storage.org_store import OrgStore
from storage.stripe_customer import StripeCustomer
@@ -15,12 +15,10 @@ stripe.api_key = STRIPE_API_KEY
async def find_customer_id_by_org_id(org_id: UUID) -> str | None:
with session_maker() as session:
stripe_customer = (
session.query(StripeCustomer)
.filter(StripeCustomer.org_id == org_id)
.first()
)
async with a_session_maker() as session:
stmt = select(StripeCustomer).where(StripeCustomer.org_id == org_id)
result = await session.execute(stmt)
stripe_customer = result.scalar_one_or_none()
if stripe_customer:
return stripe_customer.stripe_customer_id
@@ -74,7 +72,7 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
)
# Save the stripe customer in the local db
with session_maker() as session:
async with a_session_maker() as session:
session.add(
StripeCustomer(
keycloak_user_id=user_id,
@@ -82,7 +80,7 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
stripe_customer_id=customer.id,
)
)
session.commit()
await session.commit()
logger.info(
'created_customer',
@@ -108,26 +106,27 @@ async def has_payment_method_by_user_id(user_id: str) -> bool:
return bool(payment_methods.data)
async def migrate_customer(session: Session, user_id: str, org: Org):
stripe_customer = (
session.query(StripeCustomer)
.filter(StripeCustomer.keycloak_user_id == user_id)
.first()
)
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
async def migrate_customer(user_id: str, org: Org):
async with a_session_maker() as session:
result = await session.execute(
select(StripeCustomer).where(StripeCustomer.keycloak_user_id == user_id)
)
stripe_customer = result.scalar_one_or_none()
if stripe_customer is None:
return
stripe_customer.org_id = org.id
customer = await stripe.Customer.modify_async(
id=stripe_customer.stripe_customer_id,
email=org.contact_email,
metadata={'user_id': '', 'org_id': str(org.id)},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
logger.info(
'migrated_customer',
extra={
'user_id': user_id,
'org_id': str(org.id),
'stripe_customer_id': customer.id,
},
)
await session.commit()
+1 -1
View File
@@ -38,7 +38,7 @@ class ResolverViewInterface(SummaryExtractionTracker):
is_public_repo: bool
raw_payload: dict
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
"Instructions passed when conversation is first initialized"
raise NotImplementedError()
+9 -4
View File
@@ -1,10 +1,15 @@
import logging
import os
from logging.config import fileConfig
from alembic import context
from google.cloud.sql.connector import Connector
from sqlalchemy import create_engine
from storage.base import Base
# Suppress alembic.runtime.plugins INFO logs during import to prevent non-JSON logs in production
# These plugin setup messages would otherwise appear before logging is configured
logging.getLogger('alembic.runtime.plugins').setLevel(logging.WARNING)
from alembic import context # noqa: E402
from google.cloud.sql.connector import Connector # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from storage.base import Base # noqa: E402
target_metadata = Base.metadata
@@ -0,0 +1,46 @@
"""Add byor_export_enabled flag to org table.
Revision ID: 091
Revises: 090
Create Date: 2025-01-15 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '091'
down_revision: Union[str, None] = '090'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add byor_export_enabled column to org table with default false
op.add_column(
'org',
sa.Column(
'byor_export_enabled',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)
# Set byor_export_enabled to true for orgs that have completed billing sessions
op.execute(
sa.text("""
UPDATE org SET byor_export_enabled = TRUE
WHERE id IN (
SELECT DISTINCT org_id FROM billing_sessions
WHERE status = 'completed' AND org_id IS NOT NULL
)
""")
)
def downgrade() -> None:
op.drop_column('org', 'byor_export_enabled')
@@ -0,0 +1,29 @@
"""Rename 'user' role to 'member' in role table.
Revision ID: 092
Revises: 091
Create Date: 2025-02-12 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '092'
down_revision: Union[str, None] = '091'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Rename 'user' role to 'member' for clarity
# This avoids confusion between the 'user' role and the 'user' entity/account
op.execute(sa.text("UPDATE role SET name = 'member' WHERE name = 'user'"))
def downgrade() -> None:
# Revert 'member' role back to 'user'
op.execute(sa.text("UPDATE role SET name = 'user' WHERE name = 'member'"))
@@ -0,0 +1,37 @@
"""Add pending_free_credits flag to org table.
Revision ID: 093
Revises: 092
Create Date: 2025-02-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '093'
down_revision: Union[str, None] = '092'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add pending_free_credits column to org table with default false.
# New orgs will have this set to TRUE at creation time.
# Existing orgs default to FALSE (not eligible - they already got $10 at signup).
op.add_column(
'org',
sa.Column(
'pending_free_credits',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)
def downgrade() -> None:
op.drop_column('org', 'pending_free_credits')
@@ -0,0 +1,110 @@
"""create org_invitation table
Revision ID: 094
Revises: 093
Create Date: 2026-02-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '094'
down_revision: Union[str, None] = '093'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create org_invitation table
op.create_table(
'org_invitation',
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
sa.Column('token', sa.String(64), nullable=False),
sa.Column('org_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('email', sa.String(255), nullable=False),
sa.Column('role_id', sa.Integer, nullable=False),
sa.Column('inviter_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column(
'status',
sa.String(20),
nullable=False,
server_default=sa.text("'pending'"),
),
sa.Column(
'created_at',
sa.DateTime,
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column('expires_at', sa.DateTime, nullable=False),
sa.Column('accepted_at', sa.DateTime, nullable=True),
sa.Column('accepted_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
# Foreign key constraints
sa.ForeignKeyConstraint(
['org_id'],
['org.id'],
name='org_invitation_org_fkey',
ondelete='CASCADE',
),
sa.ForeignKeyConstraint(
['role_id'],
['role.id'],
name='org_invitation_role_fkey',
),
sa.ForeignKeyConstraint(
['inviter_id'],
['user.id'],
name='org_invitation_inviter_fkey',
),
sa.ForeignKeyConstraint(
['accepted_by_user_id'],
['user.id'],
name='org_invitation_accepter_fkey',
),
)
# Create indexes
op.create_index(
'ix_org_invitation_token',
'org_invitation',
['token'],
unique=True,
)
op.create_index(
'ix_org_invitation_org_id',
'org_invitation',
['org_id'],
)
op.create_index(
'ix_org_invitation_email',
'org_invitation',
['email'],
)
op.create_index(
'ix_org_invitation_status',
'org_invitation',
['status'],
)
# Composite index for checking pending invitations
op.create_index(
'ix_org_invitation_org_email_status',
'org_invitation',
['org_id', 'email', 'status'],
)
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_org_invitation_org_email_status', table_name='org_invitation')
op.drop_index('ix_org_invitation_status', table_name='org_invitation')
op.drop_index('ix_org_invitation_email', table_name='org_invitation')
op.drop_index('ix_org_invitation_org_id', table_name='org_invitation')
op.drop_index('ix_org_invitation_token', table_name='org_invitation')
# Drop table
op.drop_table('org_invitation')
@@ -0,0 +1,37 @@
"""Drop pending_free_credits column from org table.
Revision ID: 095
Revises: 094
Create Date: 2025-02-18 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '095'
down_revision: Union[str, None] = '094'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Drop the pending_free_credits column from org table.
# This column was used for tracking free credit eligibility but is no longer needed.
op.drop_column('org', 'pending_free_credits')
def downgrade() -> None:
# Re-add pending_free_credits column with default false.
op.add_column(
'org',
sa.Column(
'pending_free_credits',
sa.Boolean,
nullable=False,
server_default=sa.text('false'),
),
)
@@ -0,0 +1,67 @@
"""Create resend_synced_users table.
Revision ID: 096
Revises: 095
Create Date: 2025-02-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '096'
down_revision: Union[str, None] = '095'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create resend_synced_users table for tracking users synced to Resend audiences."""
op.create_table(
'resend_synced_users',
sa.Column(
'id',
sa.UUID(as_uuid=True),
nullable=False,
primary_key=True,
),
sa.Column('email', sa.String(), nullable=False),
sa.Column('audience_id', sa.String(), nullable=False),
sa.Column(
'synced_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column('keycloak_user_id', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'email', 'audience_id', name='uq_resend_synced_email_audience'
),
)
# Create index on email for fast lookups
op.create_index(
'ix_resend_synced_users_email',
'resend_synced_users',
['email'],
)
# Create index on audience_id for filtering by audience
op.create_index(
'ix_resend_synced_users_audience_id',
'resend_synced_users',
['audience_id'],
)
def downgrade() -> None:
"""Drop resend_synced_users table."""
op.drop_index(
'ix_resend_synced_users_audience_id', table_name='resend_synced_users'
)
op.drop_index('ix_resend_synced_users_email', table_name='resend_synced_users')
op.drop_table('resend_synced_users')
@@ -0,0 +1,41 @@
"""Add session_api_key_hash to v1_remote_sandbox table
Revision ID: 097
Revises: 096
Create Date: 2025-02-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '097'
down_revision: Union[str, None] = '096'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add session_api_key_hash column to v1_remote_sandbox table."""
op.add_column(
'v1_remote_sandbox',
sa.Column('session_api_key_hash', sa.String(), nullable=True),
)
op.create_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
'v1_remote_sandbox',
['session_api_key_hash'],
unique=False,
)
def downgrade() -> None:
"""Remove session_api_key_hash column from v1_remote_sandbox table."""
op.drop_index(
op.f('ix_v1_remote_sandbox_session_api_key_hash'),
table_name='v1_remote_sandbox',
)
op.drop_column('v1_remote_sandbox', 'session_api_key_hash')
@@ -0,0 +1,92 @@
"""Create verified_models table.
Revision ID: 098
Revises: 097
Create Date: 2026-02-26 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '098'
down_revision: Union[str, None] = '097'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create verified_models table and seed with current model list."""
op.create_table(
'verified_models',
sa.Column('id', sa.Integer, sa.Identity(), primary_key=True),
sa.Column('model_name', sa.String(255), nullable=False),
sa.Column('provider', sa.String(100), nullable=False),
sa.Column(
'is_enabled',
sa.Boolean(),
nullable=False,
server_default=sa.text('true'),
),
sa.Column(
'created_at',
sa.DateTime(),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.Column(
'updated_at',
sa.DateTime(),
nullable=False,
server_default=sa.text('CURRENT_TIMESTAMP'),
),
sa.UniqueConstraint(
'model_name', 'provider', name='uq_verified_model_provider'
),
)
op.create_index(
'ix_verified_models_provider',
'verified_models',
['provider'],
)
op.create_index(
'ix_verified_models_is_enabled',
'verified_models',
['is_enabled'],
)
# Seed with current openhands provider models
models = [
('claude-opus-4-5-20251101', 'openhands'),
('claude-sonnet-4-5-20250929', 'openhands'),
('gpt-5.2-codex', 'openhands'),
('gpt-5.2', 'openhands'),
('minimax-m2.5', 'openhands'),
('gemini-3-pro-preview', 'openhands'),
('gemini-3-flash-preview', 'openhands'),
('deepseek-chat', 'openhands'),
('devstral-medium-2512', 'openhands'),
('kimi-k2-0711-preview', 'openhands'),
('qwen3-coder-480b', 'openhands'),
]
for model_name, provider in models:
op.execute(
sa.text(
"""
INSERT INTO verified_models (model_name, provider)
VALUES (:model_name, :provider)
"""
).bindparams(model_name=model_name, provider=provider)
)
def downgrade() -> None:
"""Drop verified_models table."""
op.drop_index('ix_verified_models_is_enabled', table_name='verified_models')
op.drop_index('ix_verified_models_provider', table_name='verified_models')
op.drop_table('verified_models')
+195 -200
View File
@@ -1540,66 +1540,61 @@ files = [
[[package]]
name = "cryptography"
version = "46.0.3"
version = "46.0.5"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"]
files = [
{file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
{file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
{file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
{file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
{file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
{file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
{file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
{file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
{file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
{file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
{file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
{file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
{file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
{file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
{file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
{file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
{file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
{file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
{file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
{file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
{file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
{file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
{file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"},
{file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"},
{file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"},
{file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"},
{file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"},
{file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"},
{file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"},
{file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"},
{file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"},
{file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"},
{file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"},
{file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"},
{file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"},
{file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"},
{file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"},
{file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"},
{file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"},
{file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"},
{file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"},
{file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"},
]
[package.dependencies]
@@ -1612,7 +1607,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -5754,14 +5749,14 @@ test = ["flaky", "ipykernel (>=6.19.3)", "ipython", "ipywidgets", "nbconvert (>=
[[package]]
name = "nbconvert"
version = "7.16.6"
description = "Converting Jupyter Notebooks (.ipynb files) to other formats. Output formats include asciidoc, html, latex, markdown, pdf, py, rst, script. nbconvert can be used both as a Python library (`import nbconvert`) or as a command line tool (invoked as `jupyter nbconvert ...`)."
version = "7.17.0"
description = "Convert Jupyter Notebooks (.ipynb files) to other formats."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b"},
{file = "nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582"},
{file = "nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518"},
{file = "nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78"},
]
[package.dependencies]
@@ -5781,8 +5776,8 @@ pygments = ">=2.4.1"
traitlets = ">=5.1"
[package.extras]
all = ["flaky", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (==5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"]
docs = ["ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (==5.0.2)", "sphinxcontrib-spelling"]
all = ["flaky", "intersphinx-registry", "ipykernel", "ipython", "ipywidgets (>=7.5)", "myst-parser", "nbsphinx (>=0.2.12)", "playwright", "pydata-sphinx-theme", "pyqtwebengine (>=5.15)", "pytest (>=7)", "sphinx (>=5.0.2)", "sphinxcontrib-spelling", "tornado (>=6.1)"]
docs = ["intersphinx-registry", "ipykernel", "ipython", "myst-parser", "nbsphinx (>=0.2.12)", "pydata-sphinx-theme", "sphinx (>=5.0.2)", "sphinxcontrib-spelling"]
qtpdf = ["pyqtwebengine (>=5.15)"]
qtpng = ["pyqtwebengine (>=5.15)"]
serve = ["tornado (>=6.1)"]
@@ -6102,14 +6097,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.9.1"
version = "1.11.5"
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.9.1-py3-none-any.whl", hash = "sha256:ea1457760505b9ebfe6aabea08dedd010ce93aeb93edb450f00e25a0d056a723"},
{file = "openhands_agent_server-1.9.1.tar.gz", hash = "sha256:d92a29a9d5aa94207519a5f8daad7c0a3d6641d5cba9f763f25aa4e85713fa0f"},
{file = "openhands_agent_server-1.11.5-py3-none-any.whl", hash = "sha256:8bae7063f232791d58a5c31919f58b557f7cce60e6295773985c7dadc556cb9e"},
{file = "openhands_agent_server-1.11.5.tar.gz", hash = "sha256:b61366d727c61ab9b7fcd66faab53f230f8ef0928c1177a388d2c5c4be6ebbd0"},
]
[package.dependencies]
@@ -6126,7 +6121,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "1.2.1"
version = "1.4.0"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6168,33 +6163,33 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.2"
openhands-agent-server = "1.9.1"
openhands-sdk = "1.9.1"
openhands-tools = "1.9.1"
openhands-agent-server = "1.11.5"
openhands-sdk = "1.11.5"
openhands-tools = "1.11.5"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
pathspec = ">=0.12.1"
pexpect = "*"
pg8000 = ">=1.31.5"
pillow = ">=11.3"
pillow = ">=12.1.1"
playwright = ">=1.55"
poetry = ">=2.1.2"
prompt-toolkit = ">=3.0.50"
protobuf = ">=5,<6"
protobuf = ">=5.29.6,<6"
psutil = "*"
pybase62 = ">=1"
pygithub = ">=2.5"
pyjwt = ">=2.9"
pylatexenc = "*"
pypdf = ">=6"
pypdf = ">=6.7.2"
python-docx = "*"
python-dotenv = "*"
python-frontmatter = ">=1.1"
python-jose = {version = ">=3.3", extras = ["cryptography"]}
python-json-logger = ">=3.2.1"
python-multipart = "*"
python-multipart = ">=0.0.22"
python-pptx = "*"
python-socketio = "5.13"
python-socketio = "5.14"
pythonnet = "*"
pyyaml = ">=6.0.2"
qtconsole = ">=5.6.1"
@@ -6205,7 +6200,7 @@ setuptools = ">=78.1.1"
shellingham = ">=1.5.4"
sqlalchemy = {version = ">=2.0.40", extras = ["asyncio"]}
sse-starlette = ">=3.0.2"
starlette = ">=0.48"
starlette = ">=0.49.1"
tenacity = ">=8.5,<10"
termcolor = "*"
toml = "*"
@@ -6225,14 +6220,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.9.1"
version = "1.11.5"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.9.1-py3-none-any.whl", hash = "sha256:0e732dfe0d91289536ea0410db9554d5a5b0326f60e547ea7a9d8ddab5fe93e4"},
{file = "openhands_sdk-1.9.1.tar.gz", hash = "sha256:c6ba33f85efa4c2ec63eb1040cbe82839662bcbcf323654ed071a9ad38ce7994"},
{file = "openhands_sdk-1.11.5-py3-none-any.whl", hash = "sha256:f949cd540cbecc339d90fb0cca2a5f29e1b62566b82b5aee82ef40f259d14e60"},
{file = "openhands_sdk-1.11.5.tar.gz", hash = "sha256:dd6225876b7b8dbb6c608559f2718c3d0bf44d0bb741e990b185c6cdc5150c5a"},
]
[package.dependencies]
@@ -6253,14 +6248,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.9.1"
version = "1.11.5"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.9.1-py3-none-any.whl", hash = "sha256:411819657e00ffac5d5b1ba9adc6eb65a0a17cbefb5e3e1a34bb132ff61c59f2"},
{file = "openhands_tools-1.9.1.tar.gz", hash = "sha256:331608994cce22b662038a2fed0bf7d2c1bb8dc27b1fc0a12a646e9bd76e0843"},
{file = "openhands_tools-1.11.5-py3-none-any.whl", hash = "sha256:1e981e1e7f3544184fe946cee8eb6bd287010cdef77d83ebac945c9f42df3baf"},
{file = "openhands_tools-1.11.5.tar.gz", hash = "sha256:d7b1163f6505a51b07147e7d8972062c129ecc46571a71f28d5470355e06650e"},
]
[package.dependencies]
@@ -6851,103 +6846,103 @@ scramp = ">=1.4.5"
[[package]]
name = "pillow"
version = "12.1.0"
version = "12.1.1"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
groups = ["main", "test"]
files = [
{file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"},
{file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"},
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8"},
{file = "pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1"},
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda"},
{file = "pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7"},
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a"},
{file = "pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef"},
{file = "pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09"},
{file = "pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91"},
{file = "pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea"},
{file = "pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3"},
{file = "pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0"},
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451"},
{file = "pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e"},
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84"},
{file = "pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0"},
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b"},
{file = "pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18"},
{file = "pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64"},
{file = "pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75"},
{file = "pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304"},
{file = "pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b"},
{file = "pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551"},
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208"},
{file = "pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5"},
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661"},
{file = "pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17"},
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670"},
{file = "pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616"},
{file = "pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7"},
{file = "pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d"},
{file = "pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179"},
{file = "pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0"},
{file = "pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587"},
{file = "pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac"},
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b"},
{file = "pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea"},
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c"},
{file = "pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc"},
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644"},
{file = "pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c"},
{file = "pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171"},
{file = "pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a"},
{file = "pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45"},
{file = "pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d"},
{file = "pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82"},
{file = "pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4"},
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0"},
{file = "pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b"},
{file = "pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65"},
{file = "pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0"},
{file = "pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796"},
{file = "pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd"},
{file = "pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13"},
{file = "pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e"},
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643"},
{file = "pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5"},
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de"},
{file = "pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9"},
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a"},
{file = "pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a"},
{file = "pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030"},
{file = "pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94"},
{file = "pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4"},
{file = "pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2"},
{file = "pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14"},
{file = "pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8"},
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924"},
{file = "pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef"},
{file = "pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988"},
{file = "pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6"},
{file = "pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a"},
{file = "pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19"},
{file = "pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9"},
{file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"},
{file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"},
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"},
{file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"},
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"},
{file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"},
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"},
{file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"},
{file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"},
{file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"},
{file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"},
{file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"},
{file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"},
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"},
{file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"},
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"},
{file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"},
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"},
{file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"},
{file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"},
{file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"},
{file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"},
{file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"},
{file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"},
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"},
{file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"},
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"},
{file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"},
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"},
{file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"},
{file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"},
{file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"},
{file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"},
{file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"},
{file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"},
{file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"},
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"},
{file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"},
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"},
{file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"},
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"},
{file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"},
{file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"},
{file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"},
{file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"},
{file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"},
{file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"},
{file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"},
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"},
{file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"},
{file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"},
{file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"},
{file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"},
{file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"},
{file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"},
{file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"},
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"},
{file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"},
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"},
{file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"},
{file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"},
{file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"},
{file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"},
{file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"},
{file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"},
{file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"},
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"},
{file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"},
{file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"},
{file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"},
{file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"},
{file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"},
{file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"},
]
[package.extras]
@@ -7323,23 +7318,23 @@ testing = ["google-api-core (>=1.31.5)"]
[[package]]
name = "protobuf"
version = "5.29.5"
version = "5.29.6"
description = ""
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"},
{file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"},
{file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"},
{file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"},
{file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"},
{file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"},
{file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"},
{file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"},
{file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"},
{file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"},
{file = "protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1"},
{file = "protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda"},
{file = "protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269"},
{file = "protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6"},
{file = "protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9"},
{file = "protobuf-5.29.6-cp38-cp38-win32.whl", hash = "sha256:36ade6ff88212e91aef4e687a971a11d7d24d6948a66751abc1b3238648f5d05"},
{file = "protobuf-5.29.6-cp38-cp38-win_amd64.whl", hash = "sha256:831e2da16b6cc9d8f1654c041dd594eda43391affd3c03a91bea7f7f6da106d6"},
{file = "protobuf-5.29.6-cp39-cp39-win32.whl", hash = "sha256:cb4c86de9cd8a7f3a256b9744220d87b847371c6b2f10bde87768918ef33ba49"},
{file = "protobuf-5.29.6-cp39-cp39-win_amd64.whl", hash = "sha256:76e07e6567f8baf827137e8d5b8204b6c7b6488bbbff1bf0a72b383f77999c18"},
{file = "protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86"},
{file = "protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723"},
]
[[package]]
@@ -7562,14 +7557,14 @@ typing-extensions = ">=4.15.0"
[[package]]
name = "pyasn1"
version = "0.6.1"
version = "0.6.2"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
]
[[package]]
@@ -11578,20 +11573,20 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.6.0"
version = "6.7.3"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.6.0-py3-none-any.whl", hash = "sha256:bca9091ef6de36c7b1a81e09327c554b7ce51e88dad68f5890c2b4a4417f1fd7"},
{file = "pypdf-6.6.0.tar.gz", hash = "sha256:4c887ef2ea38d86faded61141995a3c7d068c9d6ae8477be7ae5de8a8e16592f"},
{file = "pypdf-6.7.3-py3-none-any.whl", hash = "sha256:cd25ac508f20b554a9fafd825186e3ba29591a69b78c156783c5d8a2d63a1c0a"},
{file = "pypdf-6.7.3.tar.gz", hash = "sha256:eca55c78d0ec7baa06f9288e2be5c4e8242d5cbb62c7a4b94f2716f8e50076d2"},
]
[package.extras]
crypto = ["cryptography"]
cryptodome = ["PyCryptodome"]
dev = ["black", "flit", "pip-tools", "pre-commit", "pytest-cov", "pytest-socket", "pytest-timeout", "pytest-xdist", "wheel"]
dev = ["flit", "pip-tools", "pre-commit", "pytest-cov", "pytest-socket", "pytest-timeout", "pytest-xdist", "wheel"]
docs = ["myst_parser", "sphinx", "sphinx_rtd_theme"]
full = ["Pillow (>=8.0.0)", "cryptography"]
image = ["Pillow (>=8.0.0)"]
@@ -11886,14 +11881,14 @@ requests-toolbelt = ">=0.6.0"
[[package]]
name = "python-multipart"
version = "0.0.21"
version = "0.0.22"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090"},
{file = "python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92"},
{file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"},
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
]
[[package]]
@@ -11916,14 +11911,14 @@ XlsxWriter = ">=0.5.7"
[[package]]
name = "python-socketio"
version = "5.13.0"
version = "5.14.0"
description = "Socket.IO server and client for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf"},
{file = "python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029"},
{file = "python_socketio-5.14.0-py3-none-any.whl", hash = "sha256:7de5ad8a55efc33e17897f6cf91d20168d3d259f98c38d38e2940af83136d6f8"},
{file = "python_socketio-5.14.0.tar.gz", hash = "sha256:d057737f658b3948392ff452a5c865c5ccc969859c37cf095a73393ce755f98e"},
]
[package.dependencies]
@@ -14917,4 +14912,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 = "b5cbb1e25176845ac9f95650a802667e2f8be1a536e3e55a9269b5af5a42e3fc"
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
+6
View File
@@ -44,6 +44,12 @@ httpx = "*"
scikit-learn = "^1.7.0"
shap = "^0.48.0"
google-cloud-recaptcha-enterprise = "^1.24.0"
# Dependencies previously only in Dockerfile, now managed via poetry.lock
prometheus-client = "^0.24.0"
pandas = "^2.2.0"
numpy = "^2.2.0"
mcp = "^1.10.0"
pillow = "^12.1.1"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"
+31
View File
@@ -38,15 +38,28 @@ 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.org_invitations import ( # noqa: E402
accept_router as invitation_accept_router,
)
from server.routes.org_invitations import ( # noqa: E402
invitation_router,
)
from server.routes.orgs import org_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.sharing.shared_conversation_router import ( # noqa: E402
router as shared_conversation_router,
)
from server.sharing.shared_event_router import ( # noqa: E402
router as shared_event_router,
)
from server.verified_models.verified_model_router import ( # noqa: E402
api_router as verified_models_router,
)
from server.verified_models.verified_model_router import ( # noqa: E402
override_llm_models_dependency,
)
from openhands.server.app import app as base_app # noqa: E402
from openhands.server.listen_socket import sio # noqa: E402
@@ -70,6 +83,7 @@ base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
base_app.include_router(oauth_device_router) # Add OAuth 2.0 Device Flow routes
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
base_app.include_router(user_app_settings_router) # Add routes for user app settings
base_app.include_router(
billing_router
) # Add routes for credit management and Stripe payment integration
@@ -78,8 +92,15 @@ base_app.include_router(shared_event_router)
# Add GitHub integration router only if GITHUB_APP_CLIENT_ID is set
if GITHUB_APP_CLIENT_ID:
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
from integrations.github.github_v1_callback_processor import ( # noqa: E402
GithubV1CallbackProcessor,
)
from server.routes.integration.github import github_integration_router # noqa: E402
# Bludgeon mypy into not deleting my import
logger.debug(f'Loaded {GithubV1CallbackProcessor.__name__}')
base_app.include_router(
github_integration_router
) # Add additional route for integration webhook events
@@ -92,6 +113,16 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(
verified_models_router
) # Add routes for verified models management
# Override the default LLM models implementation with SaaS version
# This must happen after all routers are included
override_llm_models_dependency(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)
add_debugging_routes(
base_app
+6
View File
@@ -38,3 +38,9 @@ class ExpiredError(AuthError):
"""Error when a token has expired (Usually the refresh token)"""
pass
class TokenRefreshError(AuthError):
"""Error when token refresh fails due to timeout or lock contention"""
pass
+1 -27
View File
@@ -1,7 +1,5 @@
import os
from server.auth.sheets_client import GoogleSheetsClient
from openhands.core.logger import openhands_logger as logger
@@ -9,12 +7,9 @@ class UserVerifier:
def __init__(self) -> None:
logger.debug('Initializing UserVerifier')
self.file_users: list[str] | None = None
self.sheets_client: GoogleSheetsClient | None = None
self.spreadsheet_id: str | None = None
# Initialize from environment variables
self._init_file_users()
self._init_sheets_client()
def _init_file_users(self) -> None:
"""Load users from text file if configured."""
@@ -36,23 +31,11 @@ class UserVerifier:
except Exception:
logger.exception(f'Error reading user list file {waitlist}')
def _init_sheets_client(self) -> None:
"""Initialize Google Sheets client if configured."""
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
if not sheet_id:
logger.debug('GITHUB_USERS_SHEET_ID not configured')
return
logger.debug('Initializing Google Sheets integration')
self.sheets_client = GoogleSheetsClient()
self.spreadsheet_id = sheet_id
def is_active(self) -> bool:
if os.getenv('DISABLE_WAITLIST', '').lower() == 'true':
logger.info('Waitlist disabled via DISABLE_WAITLIST env var')
return False
return bool(self.file_users or (self.sheets_client and self.spreadsheet_id))
return bool(self.file_users)
def is_user_allowed(self, username: str) -> bool:
"""Check if user is allowed based on file and/or sheet configuration."""
@@ -63,15 +46,6 @@ class UserVerifier:
return True
logger.debug(f'User {username} not found in text file allowlist')
if self.sheets_client and self.spreadsheet_id:
sheet_users = [
u.lower() for u in self.sheets_client.get_usernames(self.spreadsheet_id)
]
if username.lower() in sheet_users:
logger.debug(f'User {username} found in Google Sheets allowlist')
return True
logger.debug(f'User {username} not found in Google Sheets allowlist')
logger.debug(f'User {username} not found in any allowlist')
return False
+306
View File
@@ -0,0 +1,306 @@
"""
Permission-based authorization dependencies for API endpoints.
This module provides FastAPI dependencies for checking user permissions
within organizations. It uses a permission-based authorization model where
roles (owner, admin, member) are mapped to specific permissions.
Permissions are defined in the Permission enum and mapped to roles via
ROLE_PERMISSIONS. This allows fine-grained access control while maintaining
the familiar role-based hierarchy.
Usage:
from server.auth.authorization import (
Permission,
require_permission,
)
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
# Only users with VIEW_LLM_SETTINGS permission can access
...
@router.patch('/{org_id}/settings')
async def update_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.EDIT_LLM_SETTINGS)),
):
# Only users with EDIT_LLM_SETTINGS permission can access
...
"""
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
class Permission(str, Enum):
"""Permissions that can be assigned to roles."""
# Secrets
MANAGE_SECRETS = 'manage_secrets'
# MCP
MANAGE_MCP = 'manage_mcp'
# Integrations
MANAGE_INTEGRATIONS = 'manage_integrations'
# Application Settings
MANAGE_APPLICATION_SETTINGS = 'manage_application_settings'
# API Keys
MANAGE_API_KEYS = 'manage_api_keys'
# LLM Settings
VIEW_LLM_SETTINGS = 'view_llm_settings'
EDIT_LLM_SETTINGS = 'edit_llm_settings'
# Billing
VIEW_BILLING = 'view_billing'
ADD_CREDITS = 'add_credits'
# Organization Members
INVITE_USER_TO_ORGANIZATION = 'invite_user_to_organization'
CHANGE_USER_ROLE_MEMBER = 'change_user_role:member'
CHANGE_USER_ROLE_ADMIN = 'change_user_role:admin'
CHANGE_USER_ROLE_OWNER = 'change_user_role:owner'
# Organization Management
VIEW_ORG_SETTINGS = 'view_org_settings'
CHANGE_ORGANIZATION_NAME = 'change_organization_name'
DELETE_ORGANIZATION = 'delete_organization'
# Temporary permissions until we finish the API updates.
EDIT_ORG_SETTINGS = 'edit_org_settings'
class RoleName(str, Enum):
"""Role names used in the system."""
OWNER = 'owner'
ADMIN = 'admin'
MEMBER = 'member'
# Permission mappings for each role
ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
RoleName.OWNER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
Permission.CHANGE_USER_ROLE_OWNER,
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
# Organization Management (Owner only)
Permission.CHANGE_ORGANIZATION_NAME,
Permission.DELETE_ORGANIZATION,
]
),
RoleName.ADMIN: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
Permission.VIEW_LLM_SETTINGS,
Permission.EDIT_LLM_SETTINGS,
Permission.VIEW_BILLING,
Permission.ADD_CREDITS,
# Organization Members
Permission.INVITE_USER_TO_ORGANIZATION,
Permission.CHANGE_USER_ROLE_MEMBER,
Permission.CHANGE_USER_ROLE_ADMIN,
# Organization Management
Permission.VIEW_ORG_SETTINGS,
Permission.EDIT_ORG_SETTINGS,
]
),
RoleName.MEMBER: frozenset(
[
# Settings (Full access)
Permission.MANAGE_SECRETS,
Permission.MANAGE_MCP,
Permission.MANAGE_INTEGRATIONS,
Permission.MANAGE_APPLICATION_SETTINGS,
Permission.MANAGE_API_KEYS,
# Settings (View only)
Permission.VIEW_ORG_SETTINGS,
Permission.VIEW_LLM_SETTINGS,
]
),
}
def get_user_org_role(user_id: str, org_id: UUID | None) -> Role | None:
"""
Get the user's role in an organization (synchronous version).
Args:
user_id: User ID (string that will be converted to UUID)
org_id: Organization ID, or None to use the user's current organization
Returns:
Role object if user is a member, None otherwise
"""
from uuid import UUID as parse_uuid
if org_id is None:
org_member = OrgMemberStore.get_org_member_for_current_org(parse_uuid(user_id))
else:
org_member = OrgMemberStore.get_org_member(org_id, parse_uuid(user_id))
if not org_member:
return None
return RoleStore.get_role_by_id(org_member.role_id)
async def get_user_org_role_async(user_id: str, org_id: UUID | None) -> Role | None:
"""
Get the user's role in an organization (async version).
Args:
user_id: User ID (string that will be converted to UUID)
org_id: Organization ID, or None to use the user's current organization
Returns:
Role object if user is a member, None otherwise
"""
from uuid import UUID as parse_uuid
if org_id is None:
org_member = await OrgMemberStore.get_org_member_for_current_org_async(
parse_uuid(user_id)
)
else:
org_member = await OrgMemberStore.get_org_member_async(
org_id, parse_uuid(user_id)
)
if not org_member:
return None
return await RoleStore.get_role_by_id_async(org_member.role_id)
def get_role_permissions(role_name: str) -> frozenset[Permission]:
"""
Get the permissions for a role.
Args:
role_name: Name of the role
Returns:
Set of permissions for the role
"""
try:
role_enum = RoleName(role_name)
return ROLE_PERMISSIONS.get(role_enum, frozenset())
except ValueError:
return frozenset()
def has_permission(user_role: Role, permission: Permission) -> bool:
"""
Check if a role has a specific permission.
Args:
user_role: User's Role object
permission: Permission to check
Returns:
True if the role has the permission
"""
permissions = get_role_permissions(user_role.name)
return permission in permissions
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
async def get_settings(
org_id: UUID,
user_id: str = Depends(require_permission(Permission.VIEW_LLM_SETTINGS)),
):
...
Args:
permission: The permission required to access the endpoint
Returns:
Dependency function that validates permission and returns user_id
"""
async def permission_checker(
org_id: UUID | None = None,
user_id: str | None = Depends(get_user_id),
) -> str:
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='User not authenticated',
)
user_role = await get_user_org_role_async(user_id, org_id)
if not user_role:
logger.warning(
'User not a member of organization',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='User is not a member of this organization',
)
if not has_permission(user_role, permission):
logger.warning(
'Insufficient permissions',
extra={
'user_id': user_id,
'org_id': str(org_id),
'user_role': user_role.name,
'required_permission': permission.value,
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'Requires {permission.value} permission',
)
return user_id
return permission_checker
+3 -4
View File
@@ -1,5 +1,4 @@
from storage.blocked_email_domain_store import BlockedEmailDomainStore
from storage.database import session_maker
from openhands.core.logger import openhands_logger as logger
@@ -23,7 +22,7 @@ class DomainBlocker:
logger.debug(f'Error extracting domain from email: {email}', exc_info=True)
return None
def is_domain_blocked(self, email: str) -> bool:
async def is_domain_blocked(self, email: str) -> bool:
"""Check if email domain is blocked by querying the database directly via SQL.
Supports blocking:
@@ -45,7 +44,7 @@ class DomainBlocker:
try:
# Query database directly via SQL to check if domain is blocked
is_blocked = self.store.is_domain_blocked(domain)
is_blocked = await self.store.is_domain_blocked(domain)
if is_blocked:
logger.warning(f'Email domain {domain} is blocked for email: {email}')
@@ -63,5 +62,5 @@ class DomainBlocker:
# Initialize store and domain blocker
_store = BlockedEmailDomainStore(session_maker=session_maker)
_store = BlockedEmailDomainStore()
domain_blocker = DomainBlocker(store=_store)
+1 -77
View File
@@ -1,87 +1,11 @@
import os
from integrations.github.github_service import SaaSGitHubService
from pydantic import SecretStr
from server.auth.sheets_client import GoogleSheetsClient
from server.auth.auth_utils import user_verifier
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.github_types import GitHubUser
class UserVerifier:
def __init__(self) -> None:
logger.debug('Initializing UserVerifier')
self.file_users: list[str] | None = None
self.sheets_client: GoogleSheetsClient | None = None
self.spreadsheet_id: str | None = None
# Initialize from environment variables
self._init_file_users()
self._init_sheets_client()
def _init_file_users(self) -> None:
"""Load users from text file if configured"""
waitlist = os.getenv('GITHUB_USER_LIST_FILE')
if not waitlist:
logger.debug('GITHUB_USER_LIST_FILE not configured')
return
if not os.path.exists(waitlist):
logger.error(f'User list file not found: {waitlist}')
raise FileNotFoundError(f'User list file not found: {waitlist}')
try:
with open(waitlist, 'r') as f:
self.file_users = [line.strip().lower() for line in f if line.strip()]
logger.info(
f'Successfully loaded {len(self.file_users)} users from {waitlist}'
)
except Exception:
logger.error(f'Error reading user list file {waitlist}', exc_info=True)
def _init_sheets_client(self) -> None:
"""Initialize Google Sheets client if configured"""
sheet_id = os.getenv('GITHUB_USERS_SHEET_ID')
if not sheet_id:
logger.debug('GITHUB_USERS_SHEET_ID not configured')
return
logger.debug('Initializing Google Sheets integration')
self.sheets_client = GoogleSheetsClient()
self.spreadsheet_id = sheet_id
def is_active(self) -> bool:
if os.getenv('DISABLE_WAITLIST', '').lower() == 'true':
logger.info('Waitlist disabled via DISABLE_WAITLIST env var')
return False
return bool(self.file_users or (self.sheets_client and self.spreadsheet_id))
def is_user_allowed(self, username: str) -> bool:
"""Check if user is allowed based on file and/or sheet configuration"""
logger.debug(f'Checking if GitHub user {username} is allowed')
if self.file_users:
if username.lower() in self.file_users:
logger.debug(f'User {username} found in text file allowlist')
return True
logger.debug(f'User {username} not found in text file allowlist')
if self.sheets_client and self.spreadsheet_id:
sheet_users = [
u.lower() for u in self.sheets_client.get_usernames(self.spreadsheet_id)
]
if username.lower() in sheet_users:
logger.debug(f'User {username} found in Google Sheets allowlist')
return True
logger.debug(f'User {username} not found in Google Sheets allowlist')
logger.debug(f'User {username} not found in any allowlist')
return False
user_verifier = UserVerifier()
def is_user_allowed(user_login: str):
if user_verifier.is_active() and not user_verifier.is_user_allowed(user_login):
logger.warning(f'GitHub user {user_login} not in allow list')
+35
View File
@@ -1,11 +1,36 @@
import asyncio
from pydantic import SecretStr
from sqlalchemy import select
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import ProviderType
from openhands.server.types import AppMode
async def _user_has_gitlab_provider(user_id: str) -> bool:
"""Check if the user has authenticated with GitLab.
Args:
user_id: The Keycloak user ID
Returns:
True if the user has a GitLab provider token, False otherwise
"""
# Lazy import to avoid circular dependency issues at module load time
from storage.auth_tokens import AuthTokens
from storage.database import a_session_maker
async with a_session_maker() as session:
result = await session.execute(
select(AuthTokens).where(
AuthTokens.keycloak_user_id == user_id,
AuthTokens.identity_provider == ProviderType.GITLAB.value,
)
)
return result.scalars().first() is not None
def schedule_gitlab_repo_sync(
user_id: str, keycloak_access_token: SecretStr | None = None
) -> None:
@@ -14,10 +39,20 @@ def schedule_gitlab_repo_sync(
Because the outer call is already a background task, we instruct the service
to store repository data synchronously (store_in_background=False) to avoid
nested background tasks while still keeping the overall operation async.
The sync is only performed if the user has authenticated with GitLab.
"""
async def _run():
try:
# Check if the user has a GitLab provider token before syncing
if not await _user_has_gitlab_provider(user_id):
logger.debug(
'gitlab_repo_sync_skipped: user has no GitLab provider',
extra={'user_id': user_id},
)
return
# Lazy import to avoid circular dependency:
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
@@ -18,6 +18,7 @@ from openhands.core.logger import openhands_logger as logger
class AssessmentResult:
"""Result of a reCAPTCHA Enterprise assessment."""
name: str
score: float
valid: bool
action_valid: bool
@@ -63,6 +64,7 @@ class RecaptchaService:
user_ip: str,
user_agent: str,
email: str | None = None,
user_id: str | None = None,
) -> AssessmentResult:
"""Create a reCAPTCHA Enterprise assessment.
@@ -72,6 +74,7 @@ class RecaptchaService:
user_ip: The user's IP address.
user_agent: The user's browser user agent.
email: Optional email for Account Defender hashing.
user_id: Optional Keycloak user ID for logging correlation.
Returns:
AssessmentResult with score, validity, and allowed status.
@@ -100,6 +103,10 @@ class RecaptchaService:
response = self.client.create_assessment(request)
# Capture assessment name for potential annotation later
# Format: projects/{project_id}/assessments/{assessment_id}
assessment_name = response.name
token_properties = response.token_properties
risk_analysis = response.risk_analysis
@@ -129,6 +136,7 @@ class RecaptchaService:
logger.info(
'recaptcha_assessment',
extra={
'assessment_name': assessment_name,
'score': score,
'valid': valid,
'action_valid': action_valid,
@@ -137,10 +145,13 @@ class RecaptchaService:
'has_suspicious_labels': has_suspicious_labels,
'allowed': allowed,
'user_ip': user_ip,
'user_id': user_id,
'email': email,
},
)
return AssessmentResult(
name=assessment_name,
score=score,
valid=valid,
action_valid=action_valid,
+19 -17
View File
@@ -18,9 +18,10 @@ from server.auth.token_manager import TokenManager
from server.config import get_config
from server.logger import logger
from server.rate_limit import RateLimiter, create_redis_rate_limiter
from sqlalchemy import delete, select
from storage.api_key_store import ApiKeyStore
from storage.auth_tokens import AuthTokens
from storage.database import session_maker
from storage.database import a_session_maker
from storage.saas_secrets_store import SaasSecretsStore
from storage.saas_settings_store import SaasSettingsStore
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
@@ -124,7 +125,7 @@ class SaasUserAuth(UserAuth):
if secrets_store:
return secrets_store
user_id = await self.get_user_id()
secrets_store = SaasSecretsStore(user_id, session_maker, get_config())
secrets_store = SaasSecretsStore(user_id, get_config())
self.secrets_store = secrets_store
return secrets_store
@@ -161,12 +162,13 @@ class SaasUserAuth(UserAuth):
try:
# TODO: I think we can do this in a single request if we refactor
with session_maker() as session:
tokens = (
session.query(AuthTokens)
.where(AuthTokens.keycloak_user_id == self.user_id)
.all()
async with a_session_maker() as session:
result = await session.execute(
select(AuthTokens).where(
AuthTokens.keycloak_user_id == self.user_id
)
)
tokens = result.scalars().all()
for token in tokens:
idp_type = ProviderType(token.identity_provider)
@@ -192,11 +194,11 @@ class SaasUserAuth(UserAuth):
'idp_type': token.identity_provider,
},
)
with session_maker() as session:
session.query(AuthTokens).filter(
AuthTokens.id == token.id
).delete()
session.commit()
async with a_session_maker() as session:
await session.execute(
delete(AuthTokens).where(AuthTokens.id == token.id)
)
await session.commit()
raise
self.provider_tokens = MappingProxyType(provider_tokens)
@@ -210,15 +212,15 @@ class SaasUserAuth(UserAuth):
if settings_store:
return settings_store
user_id = await self.get_user_id()
settings_store = SaasSettingsStore(user_id, session_maker, get_config())
settings_store = SaasSettingsStore(user_id, get_config())
self.settings_store = settings_store
return settings_store
async def get_mcp_api_key(self) -> str:
api_key_store = ApiKeyStore.get_instance()
mcp_api_key = api_key_store.retrieve_mcp_api_key(self.user_id)
mcp_api_key = await api_key_store.retrieve_mcp_api_key(self.user_id)
if not mcp_api_key:
mcp_api_key = api_key_store.create_api_key(
mcp_api_key = await api_key_store.create_api_key(
self.user_id, 'MCP_API_KEY', None
)
return mcp_api_key
@@ -278,7 +280,7 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
user_id = api_key_store.validate_api_key(api_key)
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
return None
offline_token = await token_manager.load_offline_token(user_id)
@@ -327,7 +329,7 @@ async def saas_user_auth_from_signed_token(signed_token: str) -> SaasUserAuth:
email_verified = access_token_payload['email_verified']
# Check if email domain is blocked
if email and domain_blocker.is_domain_blocked(email):
if email and await domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for existing user with email: {email}'
)
+29 -19
View File
@@ -38,9 +38,9 @@ from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
from server.config import get_config
from server.logger import logger
from sqlalchemy import String as SQLString
from sqlalchemy import type_coerce
from sqlalchemy import select, type_coerce
from storage.auth_token_store import AuthTokenStore
from storage.database import session_maker
from storage.database import a_session_maker
from storage.github_app_installation import GithubAppInstallation
from storage.offline_token_store import OfflineTokenStore
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
@@ -49,6 +49,10 @@ from openhands.integrations.service_types import ProviderType
from openhands.server.types import SessionExpiredError
from openhands.utils.http_session import httpx_verify_option
# HTTP timeout for external IDP calls (in seconds)
# This prevents indefinite blocking if an IDP is slow or unresponsive
IDP_HTTP_TIMEOUT = 15.0
def _before_sleep_callback(retry_state: RetryCallState) -> None:
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
@@ -202,7 +206,9 @@ class TokenManager:
access_token: str,
idp: ProviderType,
) -> dict[str, str | int]:
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL
url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token'
headers = {
@@ -361,7 +367,9 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitHub token')
@@ -387,7 +395,9 @@ class TokenManager:
'refresh_token': refresh_token,
'grant_type': 'refresh_token',
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=payload)
response.raise_for_status()
logger.info('Successfully refreshed GitLab token')
@@ -415,7 +425,9 @@ class TokenManager:
'refresh_token': refresh_token,
}
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
async with httpx.AsyncClient(
verify=httpx_verify_option(), timeout=IDP_HTTP_TIMEOUT
) as client:
response = await client.post(url, data=data, headers=headers)
response.raise_for_status()
logger.info('Successfully refreshed Bitbucket token')
@@ -771,25 +783,24 @@ class TokenManager:
exc_info=True,
)
def store_org_token(self, installation_id: int, installation_token: str):
async def store_org_token(self, installation_id: int, installation_token: str):
"""Store a GitHub App installation token.
Args:
installation_id: GitHub installation ID (integer or string)
installation_token: The token to store
"""
with session_maker() as session:
async with a_session_maker() as session:
# Ensure installation_id is a string
str_installation_id = str(installation_id)
# Use type_coerce to ensure SQLAlchemy treats the parameter as a string
installation = (
session.query(GithubAppInstallation)
.filter(
result = await session.execute(
select(GithubAppInstallation).filter(
GithubAppInstallation.installation_id
== type_coerce(str_installation_id, SQLString)
)
.first()
)
installation = result.scalars().first()
if installation:
installation.encrypted_token = self.encrypt_text(installation_token)
else:
@@ -799,9 +810,9 @@ class TokenManager:
encrypted_token=self.encrypt_text(installation_token),
)
)
session.commit()
await session.commit()
def load_org_token(self, installation_id: int) -> str | None:
async def load_org_token(self, installation_id: int) -> str | None:
"""Load a GitHub App installation token.
Args:
@@ -810,17 +821,16 @@ class TokenManager:
Returns:
The decrypted token if found, None otherwise
"""
with session_maker() as session:
async with a_session_maker() as session:
# Ensure installation_id is a string and use type_coerce
str_installation_id = str(installation_id)
installation = (
session.query(GithubAppInstallation)
.filter(
result = await session.execute(
select(GithubAppInstallation).filter(
GithubAppInstallation.installation_id
== type_coerce(str_installation_id, SQLString)
)
.first()
)
installation = result.scalars().first()
if not installation:
return None
token = self.decrypt_text(installation.encrypted_token)
+8 -2
View File
@@ -15,6 +15,11 @@ IS_FEATURE_ENV = (
) # Does not include the staging deployment
IS_LOCAL_ENV = bool(HOST == 'localhost')
# Role name constants
ROLE_OWNER = 'owner'
ROLE_ADMIN = 'admin'
ROLE_MEMBER = 'member'
# Deprecated - billing margins are now handled internally in litellm
DEFAULT_BILLING_MARGIN = float(os.environ.get('DEFAULT_BILLING_MARGIN', '1.0'))
@@ -25,7 +30,9 @@ PERSONAL_WORKSPACE_VERSION_TO_MODEL = {
2: 'claude-3-7-sonnet-20250219',
3: 'claude-sonnet-4-20250514',
4: 'claude-sonnet-4-20250514',
5: 'claude-opus-4-5-20251101',
# Minimax is now the default as it gives results close to claude in terms of quality
# but at a much lower price
5: 'minimax-m2.5',
}
LITELLM_DEFAULT_MODEL = os.getenv('LITELLM_DEFAULT_MODEL')
@@ -54,7 +61,6 @@ SUBSCRIPTION_PRICE_DATA = {
},
}
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
@@ -3,7 +3,6 @@ from datetime import datetime
from integrations.github.github_manager import GithubManager
from integrations.github.github_view import GithubViewType
from integrations.models import Message, SourceType
from integrations.utils import (
extract_summary_from_conversation_manager,
get_summary_instruction,
@@ -14,7 +13,6 @@ from storage.conversation_callback import (
ConversationCallback,
ConversationCallbackProcessor,
)
from storage.database import session_maker
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
@@ -36,16 +34,12 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
send_summary_instruction: bool = True
async def _send_message_to_github(self, message: str) -> None:
"""
Send a message to GitHub.
"""Send a message to GitHub.
Args:
message: The message content to send to GitHub
"""
try:
# Create a message object for GitHub
message_obj = Message(source=SourceType.OPENHANDS, message=message)
# Get the token manager
token_manager = TokenManager()
@@ -54,8 +48,8 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
github_manager = GithubManager(token_manager, GitHubDataCollector())
# Send the message
await github_manager.send_message(message_obj, self.github_view)
# Send the message directly as a string
await github_manager.send_message(message, self.github_view)
logger.info(
f'[GitHub] Sent summary message to {self.github_view.full_repo_name}#{self.github_view.issue_number}'
@@ -108,13 +102,10 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
f'[GitHub] Sent summary instruction to conversation {conversation_id} {summary_event}'
)
# Update the processor state
# Update the processor state - the outer session will commit this
self.send_summary_instruction = False
callback.set_processor(self)
callback.updated_at = datetime.now()
with session_maker() as session:
session.merge(callback)
session.commit()
return
# Extract the summary from the event store
@@ -130,14 +121,15 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
logger.info(f'[GitHub] Summary sent for conversation {conversation_id}')
# Mark callback as completed status
# Mark callback as completed status - the outer session will commit this
callback.status = CallbackStatus.COMPLETED
callback.updated_at = datetime.now()
with session_maker() as session:
session.merge(callback)
session.commit()
except Exception as e:
logger.exception(
f'[GitHub] Error processing conversation callback: {str(e)}'
)
# Mark callback as error to prevent infinite re-invocation
# The outer session will commit this
callback.status = CallbackStatus.ERROR
callback.updated_at = datetime.now()
@@ -3,7 +3,6 @@ from datetime import datetime
from integrations.gitlab.gitlab_manager import GitlabManager
from integrations.gitlab.gitlab_view import GitlabViewType
from integrations.models import Message, SourceType
from integrations.utils import (
extract_summary_from_conversation_manager,
get_summary_instruction,
@@ -14,7 +13,7 @@ from storage.conversation_callback import (
ConversationCallback,
ConversationCallbackProcessor,
)
from storage.database import session_maker
from storage.database import a_session_maker
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
@@ -28,8 +27,7 @@ gitlab_manager = GitlabManager(token_manager)
class GitlabCallbackProcessor(ConversationCallbackProcessor):
"""
Processor for sending conversation summaries to GitLab.
"""Processor for sending conversation summaries to GitLab.
This processor is used to send summaries of conversations to GitLab
when agent state changes occur.
@@ -39,22 +37,18 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
send_summary_instruction: bool = True
async def _send_message_to_gitlab(self, message: str) -> None:
"""
Send a message to GitLab.
"""Send a message to GitLab.
Args:
message: The message content to send to GitLab
"""
try:
# Create a message object for GitHub
message_obj = Message(source=SourceType.OPENHANDS, message=message)
# Get the token manager
token_manager = TokenManager()
gitlab_manager = GitlabManager(token_manager)
# Send the message
await gitlab_manager.send_message(message_obj, self.gitlab_view)
# Send the message directly as a string
await gitlab_manager.send_message(message, self.gitlab_view)
logger.info(
f'[GitLab] Sent summary message to {self.gitlab_view.full_repo_name}#{self.gitlab_view.issue_number}'
@@ -111,9 +105,9 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
self.send_summary_instruction = False
callback.set_processor(self)
callback.updated_at = datetime.now()
with session_maker() as session:
async with a_session_maker() as session:
session.merge(callback)
session.commit()
await session.commit()
return
# Extract the summary from the event store
@@ -132,9 +126,9 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
# Mark callback as completed status
callback.status = CallbackStatus.COMPLETED
callback.updated_at = datetime.now()
with session_maker() as session:
async with a_session_maker() as session:
session.merge(callback)
session.commit()
await session.commit()
except Exception as e:
logger.exception(
@@ -37,8 +37,7 @@ class JiraCallbackProcessor(ConversationCallbackProcessor):
workspace_name: str
async def _send_comment_to_jira(self, message: str) -> None:
"""
Send a comment to Jira issue.
"""Send a comment to Jira issue.
Args:
message: The message content to send to Jira
@@ -59,8 +58,9 @@ class JiraCallbackProcessor(ConversationCallbackProcessor):
# Decrypt API key
api_key = jira_manager.token_manager.decrypt_text(workspace.svc_acc_api_key)
# Send comment directly as a string
await jira_manager.send_message(
jira_manager.create_outgoing_message(msg=message),
message,
issue_key=self.issue_key,
jira_cloud_id=workspace.jira_cloud_id,
svc_acc_email=workspace.svc_acc_email,
@@ -37,8 +37,7 @@ class JiraDcCallbackProcessor(ConversationCallbackProcessor):
base_api_url: str
async def _send_comment_to_jira_dc(self, message: str) -> None:
"""
Send a comment to Jira DC issue.
"""Send a comment to Jira DC issue.
Args:
message: The message content to send to Jira DC
@@ -61,8 +60,9 @@ class JiraDcCallbackProcessor(ConversationCallbackProcessor):
workspace.svc_acc_api_key
)
# Send comment directly as a string
await jira_dc_manager.send_message(
jira_dc_manager.create_outgoing_message(msg=message),
message,
issue_key=self.issue_key,
base_api_url=self.base_api_url,
svc_acc_api_key=api_key,
@@ -36,8 +36,7 @@ class LinearCallbackProcessor(ConversationCallbackProcessor):
workspace_name: str
async def _send_comment_to_linear(self, message: str) -> None:
"""
Send a comment to Linear issue.
"""Send a comment to Linear issue.
Args:
message: The message content to send to Linear
@@ -60,9 +59,9 @@ class LinearCallbackProcessor(ConversationCallbackProcessor):
workspace.svc_acc_api_key
)
# Send comment
# Send comment directly as a string
await linear_manager.send_message(
linear_manager.create_outgoing_message(msg=message),
message,
self.issue_id,
api_key,
)
@@ -26,8 +26,7 @@ slack_manager = SlackManager(token_manager)
class SlackCallbackProcessor(ConversationCallbackProcessor):
"""
Processor for sending conversation summaries to Slack.
"""Processor for sending conversation summaries to Slack.
This processor is used to send summaries of conversations to Slack channels
when agent state changes occur.
@@ -41,14 +40,13 @@ class SlackCallbackProcessor(ConversationCallbackProcessor):
last_user_msg_id: int | None = None
async def _send_message_to_slack(self, message: str) -> None:
"""
Send a message to Slack using the conversation_manager's send_to_event_stream method.
"""Send a message to Slack.
Args:
message: The message content to send to Slack
"""
try:
# Create a message object for Slack
# Create a message object for Slack view creation (incoming message format)
message_obj = Message(
source=SourceType.SLACK,
message={
@@ -67,9 +65,8 @@ class SlackCallbackProcessor(ConversationCallbackProcessor):
slack_view = SlackFactory.create_slack_view_from_payload(
message_obj, slack_user, saas_user_auth
)
await slack_manager.send_message(
slack_manager.create_outgoing_message(message), slack_view
)
# Send the message directly as a string
await slack_manager.send_message(message, slack_view)
logger.info(
f'[Slack] Sent summary message to channel {self.channel_id} '
+8
View File
@@ -51,6 +51,14 @@ def custom_json_serializer(obj, **kwargs):
obj['stack_info'] = format_stack(stack_info)
result = json.dumps(obj, **kwargs)
# Swap out newlines to make things easier to read. This will produce
# invalid json but means we can have similar logs in local development
# to production, making things easier to correlate. Obviously,
# LOG_JSON_FOR_CONSOLE should not be used in production environments.
if LOG_JSON_FOR_CONSOLE:
result = result.replace('\\n', '\n')
return result
+5 -3
View File
@@ -22,7 +22,7 @@ from openhands.core.logger import openhands_logger as logger
# NOTE: these details are specific to the MCP protocol
class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
@staticmethod
def create_default_mcp_server_config(
async def create_default_mcp_server_config(
host: str, config: 'OpenHandsConfig', user_id: str | None = None
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
"""
@@ -38,10 +38,12 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
api_key_store = ApiKeyStore.get_instance()
if user_id:
api_key = api_key_store.retrieve_mcp_api_key(user_id)
api_key = await api_key_store.retrieve_mcp_api_key(user_id)
if not api_key:
api_key = api_key_store.create_api_key(user_id, 'MCP_API_KEY', None)
api_key = await api_key_store.create_api_key(
user_id, 'MCP_API_KEY', None
)
if not api_key:
logger.error(f'Could not provision MCP API Key for user: {user_id}')
+8 -2
View File
@@ -103,11 +103,13 @@ class SetAuthCookieMiddleware:
keycloak_auth_cookie = request.cookies.get('keycloak_auth')
auth_header = request.headers.get('Authorization')
mcp_auth_header = request.headers.get('X-Session-API-Key')
accepted_tos = False
api_auth_header = request.headers.get('X-Access-Token')
accepted_tos: bool | None = False
if (
keycloak_auth_cookie is None
and (auth_header is None or not auth_header.startswith('Bearer '))
and mcp_auth_header is None
and api_auth_header is None
):
raise NoCredentialsError
@@ -160,10 +162,10 @@ class SetAuthCookieMiddleware:
'/api/billing/customer-setup-success',
'/api/billing/stripe-webhook',
'/api/email/resend',
'/api/organizations/members/invite/accept',
'/oauth/device/authorize',
'/oauth/device/token',
'/api/v1/web-client/config',
'/api/v1/webhooks/secrets',
)
if path in ignore_paths:
return False
@@ -174,6 +176,10 @@ class SetAuthCookieMiddleware:
):
return False
# Webhooks access is controlled using separate API keys
if path.startswith('/api/v1/webhooks/'):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp
+130 -93
View File
@@ -2,66 +2,58 @@ from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.utils.async_utils import call_sync_from_async
# Helper functions for BYOR API key management
async def get_byor_key_from_db(user_id: str) -> str | None:
"""Get the BYOR key from the database for a user."""
def _get_byor_key():
user = UserStore.get_user_by_id(user_id)
if not user:
return None
current_org_id = user.current_org_id
current_org_member: OrgMember = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
if current_org_member.llm_api_key_for_byor:
return current_org_member.llm_api_key_for_byor.get_secret_value()
user = await UserStore.get_user_by_id_async(user_id)
if not user:
return None
return await call_sync_from_async(_get_byor_key)
current_org_id = user.current_org_id
current_org_member: OrgMember = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
if current_org_member.llm_api_key_for_byor:
return current_org_member.llm_api_key_for_byor.get_secret_value()
return None
async def store_byor_key_in_db(user_id: str, key: str) -> None:
"""Store the BYOR key in the database for a user."""
user = await UserStore.get_user_by_id_async(user_id)
if not user:
return None
def _update_user_settings():
user = UserStore.get_user_by_id(user_id)
if not user:
return None
current_org_id = user.current_org_id
current_org_member: OrgMember = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
current_org_member.llm_api_key_for_byor = key
OrgMemberStore.update_org_member(current_org_member)
await call_sync_from_async(_update_user_settings)
current_org_id = user.current_org_id
current_org_member: OrgMember = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
current_org_member.llm_api_key_for_byor = key
OrgMemberStore.update_org_member(current_org_member)
async def generate_byor_key(user_id: str) -> str | None:
"""Generate a new BYOR key for a user."""
try:
user = await UserStore.get_user_by_id_async(user_id)
if not user:
@@ -144,9 +136,9 @@ class ApiKeyCreate(BaseModel):
class ApiKeyResponse(BaseModel):
id: int
name: str | None = None
created_at: str
last_used_at: str | None = None
expires_at: str | None = None
created_at: datetime
last_used_at: datetime | None = None
expires_at: datetime | None = None
class ApiKeyCreateResponse(ApiKeyResponse):
@@ -157,58 +149,78 @@ class LlmApiKeyResponse(BaseModel):
key: str | None
@api_router.post('', response_model=ApiKeyCreateResponse)
async def create_api_key(key_data: ApiKeyCreate, user_id: str = Depends(get_user_id)):
class ByorPermittedResponse(BaseModel):
permitted: bool
class MessageResponse(BaseModel):
message: str
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
"""Convert an ApiKey model to an ApiKeyResponse."""
return ApiKeyResponse(
id=key.id,
name=key.name,
created_at=key.created_at,
last_used_at=key.last_used_at,
expires_at=key.expires_at,
)
@api_router.get('/llm/byor/permitted', tags=['Keys'])
async def check_byor_permitted(
user_id: str = Depends(get_user_id),
) -> ByorPermittedResponse:
"""Check if BYOR key export is permitted for the user's current org."""
try:
permitted = await OrgService.check_byor_export_enabled(user_id)
return ByorPermittedResponse(permitted=permitted)
except Exception as e:
logger.exception(
'Error checking BYOR export permission', extra={'error': str(e)}
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to check BYOR export permission',
)
@api_router.post('', tags=['Keys'])
async def create_api_key(
key_data: ApiKeyCreate, user_id: str = Depends(get_user_id)
) -> ApiKeyCreateResponse:
"""Create a new API key for the authenticated user."""
try:
api_key = api_key_store.create_api_key(
api_key = await api_key_store.create_api_key(
user_id, key_data.name, key_data.expires_at
)
# Get the created key details
keys = api_key_store.list_api_keys(user_id)
keys = await api_key_store.list_api_keys(user_id)
for key in keys:
if key['name'] == key_data.name:
return {
**key,
'key': api_key,
'created_at': (
key['created_at'].isoformat() if key['created_at'] else None
),
'last_used_at': (
key['last_used_at'].isoformat() if key['last_used_at'] else None
),
'expires_at': (
key['expires_at'].isoformat() if key['expires_at'] else None
),
}
if key.name == key_data.name:
return ApiKeyCreateResponse(
id=key.id,
name=key.name,
key=api_key,
created_at=key.created_at,
last_used_at=key.last_used_at,
expires_at=key.expires_at,
)
except Exception:
logger.exception('Error creating API key')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create API key',
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create API key',
)
@api_router.get('', response_model=list[ApiKeyResponse])
async def list_api_keys(user_id: str = Depends(get_user_id)):
@api_router.get('', tags=['Keys'])
async def list_api_keys(user_id: str = Depends(get_user_id)) -> list[ApiKeyResponse]:
"""List all API keys for the authenticated user."""
try:
keys = api_key_store.list_api_keys(user_id)
return [
{
**key,
'created_at': (
key['created_at'].isoformat() if key['created_at'] else None
),
'last_used_at': (
key['last_used_at'].isoformat() if key['last_used_at'] else None
),
'expires_at': (
key['expires_at'].isoformat() if key['expires_at'] else None
),
}
for key in keys
]
keys = await api_key_store.list_api_keys(user_id)
return [api_key_to_response(key) for key in keys]
except Exception:
logger.exception('Error listing API keys')
raise HTTPException(
@@ -217,16 +229,18 @@ async def list_api_keys(user_id: str = Depends(get_user_id)):
)
@api_router.delete('/{key_id}')
async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)):
@api_router.delete('/{key_id}', tags=['Keys'])
async def delete_api_key(
key_id: int, user_id: str = Depends(get_user_id)
) -> MessageResponse:
"""Delete an API key."""
try:
# First, verify the key belongs to the user
keys = api_key_store.list_api_keys(user_id)
keys = await api_key_store.list_api_keys(user_id)
key_to_delete = None
for key in keys:
if key['id'] == key_id:
if key.id == key_id:
key_to_delete = key
break
@@ -237,14 +251,14 @@ async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)):
)
# Delete the key
success = api_key_store.delete_api_key_by_id(key_id)
success = await api_key_store.delete_api_key_by_id(key_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to delete API key',
)
return {'message': 'API key deleted successfully'}
return MessageResponse(message='API key deleted successfully')
except HTTPException:
raise
except Exception:
@@ -255,22 +269,33 @@ async def delete_api_key(key_id: int, user_id: str = Depends(get_user_id)):
)
@api_router.get('/llm/byor', response_model=LlmApiKeyResponse)
async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
@api_router.get('/llm/byor', tags=['Keys'])
async def get_llm_api_key_for_byor(
user_id: str = Depends(get_user_id),
) -> LlmApiKeyResponse:
"""Get the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.
This endpoint validates that the key exists in LiteLLM before returning it.
If validation fails, it automatically generates a new key to ensure users
always receive a working key.
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
"""
try:
# Check if BYOR export is enabled for the user's org
if not await OrgService.check_byor_export_enabled(user_id):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
)
# Check if the BYOR key exists in the database
byor_key = await get_byor_key_from_db(user_id)
if byor_key:
# Validate that the key is actually registered in LiteLLM
is_valid = await LiteLlmManager.verify_key(byor_key, user_id)
if is_valid:
return {'key': byor_key}
return LlmApiKeyResponse(key=byor_key)
else:
# Key exists in DB but is invalid in LiteLLM - regenerate it
logger.warning(
@@ -295,7 +320,7 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
'Successfully generated and stored new BYOR key',
extra={'user_id': user_id},
)
return {'key': key}
return LlmApiKeyResponse(key=key)
else:
logger.error(
'Failed to generate new BYOR LLM API key',
@@ -317,12 +342,24 @@ async def get_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
)
@api_router.post('/llm/byor/refresh', response_model=LlmApiKeyResponse)
async def refresh_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user."""
@api_router.post('/llm/byor/refresh', tags=['Keys'])
async def refresh_llm_api_key_for_byor(
user_id: str = Depends(get_user_id),
) -> LlmApiKeyResponse:
"""Refresh the LLM API key for BYOR (Bring Your Own Runtime) for the authenticated user.
Returns 402 Payment Required if BYOR export is not enabled for the user's org.
"""
logger.info('Starting BYOR LLM API key refresh', extra={'user_id': user_id})
try:
# Check if BYOR export is enabled for the user's org
if not await OrgService.check_byor_export_enabled(user_id):
raise HTTPException(
status_code=status.HTTP_402_PAYMENT_REQUIRED,
detail='BYOR key export is not enabled. Purchase credits to enable this feature.',
)
# Get the existing BYOR key from the database
existing_byor_key = await get_byor_key_from_db(user_id)
@@ -361,7 +398,7 @@ async def refresh_llm_api_key_for_byor(user_id: str = Depends(get_user_id)):
'BYOR LLM API key refresh completed successfully',
extra={'user_id': user_id},
)
return {'key': key}
return LlmApiKeyResponse(key=key)
except HTTPException as he:
logger.error(
'HTTP exception during BYOR LLM API key refresh',
+140 -21
View File
@@ -5,6 +5,7 @@ import warnings
from datetime import datetime, timezone
from typing import Annotated, Literal, Optional
from urllib.parse import quote
from uuid import UUID as parse_uuid
import posthog
from fastapi import APIRouter, Header, HTTPException, Request, Response, status
@@ -26,7 +27,15 @@ from server.auth.token_manager import TokenManager
from server.config import sign_token
from server.constants import IS_FEATURE_ENV
from server.routes.event_webhook import _get_session_api_key, _get_user_id
from storage.database import session_maker
from server.services.org_invitation_service import (
EmailMismatchError,
InvitationExpiredError,
InvitationInvalidError,
OrgInvitationService,
UserAlreadyMemberError,
)
from sqlalchemy import select
from storage.database import a_session_maker
from storage.user import User
from storage.user_store import UserStore
@@ -104,22 +113,40 @@ def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
)
def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
"""Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.
Returns:
Tuple of (redirect_url, recaptcha_token, invitation_token).
Tokens may be None.
"""
if not state:
return '', None, None
try:
# Try to decode as JSON (new format with reCAPTCHA and/or invitation)
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
return (
state_data.get('redirect_url', ''),
state_data.get('recaptcha_token'),
state_data.get('invitation_token'),
)
except Exception:
# Old format - state is just the redirect URL
return state, None, None
# Keep alias for backward compatibility
def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
"""Extract redirect URL and reCAPTCHA token from OAuth state.
Deprecated: Use _extract_oauth_state instead.
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
if not state:
return '', None
try:
# Try to decode as JSON (new format with reCAPTCHA)
state_data = json.loads(base64.urlsafe_b64decode(state.encode()).decode())
return state_data.get('redirect_url', ''), state_data.get('recaptcha_token')
except Exception:
# Old format - state is just the redirect URL
return state, None
redirect_url, recaptcha_token, _ = _extract_oauth_state(state)
return redirect_url, recaptcha_token
@oauth_router.get('/keycloak/callback')
@@ -130,8 +157,8 @@ async def keycloak_callback(
error: Optional[str] = None,
error_description: Optional[str] = None,
):
# Extract redirect URL and reCAPTCHA token from state
redirect_url, recaptcha_token = _extract_recaptcha_state(state)
# Extract redirect URL, reCAPTCHA token, and invitation token from state
redirect_url, recaptcha_token, invitation_token = _extract_oauth_state(state)
if not redirect_url:
redirect_url = str(request.base_url)
@@ -179,6 +206,10 @@ async def keycloak_callback(
user = await UserStore.get_user_by_id_async(user_id)
if not user:
user = await UserStore.create_user(user_id, user_info)
else:
# Existing user — gradually backfill contact_name if it still has a username-style value
await UserStore.backfill_contact_name(user_id, user_info)
await UserStore.backfill_user_email(user_id, user_info)
if not user:
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
@@ -219,6 +250,7 @@ async def keycloak_callback(
user_ip=user_ip,
user_agent=user_agent,
email=email,
user_id=user_id,
)
if not result.allowed:
@@ -239,7 +271,7 @@ async def keycloak_callback(
# Fail open - continue with login if reCAPTCHA service unavailable
# Check if email domain is blocked
if email and domain_blocker.is_domain_blocked(email):
if email and await domain_blocker.is_domain_blocked(email):
logger.warning(
f'Blocked authentication attempt for email: {email}, user_id: {user_id}'
)
@@ -298,8 +330,13 @@ async def keycloak_callback(
from server.routes.email import verify_email
await verify_email(request=request, user_id=user_id, is_auth_flow=True)
redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
response = RedirectResponse(redirect_url, status_code=302)
verification_redirect_url = f'{request.base_url}login?email_verification_required=true&user_id={user_id}'
# Preserve invitation token so it can be included in OAuth state after verification
if invitation_token:
verification_redirect_url = (
f'{verification_redirect_url}&invitation_token={invitation_token}'
)
response = RedirectResponse(verification_redirect_url, status_code=302)
return response
# default to github IDP for now.
@@ -377,14 +414,90 @@ async def keycloak_callback(
)
has_accepted_tos = user.accepted_tos is not None
# Process invitation token if present (after email verification but before TOS)
if invitation_token:
try:
logger.info(
'Processing invitation token during auth callback',
extra={
'user_id': user_id,
'invitation_token_prefix': invitation_token[:10] + '...',
},
)
await OrgInvitationService.accept_invitation(
invitation_token, parse_uuid(user_id)
)
logger.info(
'Invitation accepted during auth callback',
extra={'user_id': user_id},
)
except InvitationExpiredError:
logger.warning(
'Invitation expired during auth callback',
extra={'user_id': user_id},
)
# Add query param to redirect URL
if '?' in redirect_url:
redirect_url = f'{redirect_url}&invitation_expired=true'
else:
redirect_url = f'{redirect_url}?invitation_expired=true'
except InvitationInvalidError as e:
logger.warning(
'Invalid invitation during auth callback',
extra={'user_id': user_id, 'error': str(e)},
)
if '?' in redirect_url:
redirect_url = f'{redirect_url}&invitation_invalid=true'
else:
redirect_url = f'{redirect_url}?invitation_invalid=true'
except UserAlreadyMemberError:
logger.info(
'User already member during invitation acceptance',
extra={'user_id': user_id},
)
if '?' in redirect_url:
redirect_url = f'{redirect_url}&already_member=true'
else:
redirect_url = f'{redirect_url}?already_member=true'
except EmailMismatchError as e:
logger.warning(
'Email mismatch during auth callback invitation acceptance',
extra={'user_id': user_id, 'error': str(e)},
)
if '?' in redirect_url:
redirect_url = f'{redirect_url}&email_mismatch=true'
else:
redirect_url = f'{redirect_url}?email_mismatch=true'
except Exception as e:
logger.exception(
'Unexpected error processing invitation during auth callback',
extra={'user_id': user_id, 'error': str(e)},
)
# Don't fail the login if invitation processing fails
if '?' in redirect_url:
redirect_url = f'{redirect_url}&invitation_error=true'
else:
redirect_url = f'{redirect_url}?invitation_error=true'
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
encoded_redirect_url = quote(redirect_url, safe='')
tos_redirect_url = (
f'{request.base_url}accept-tos?redirect_url={encoded_redirect_url}'
)
if invitation_token:
tos_redirect_url = f'{tos_redirect_url}&invitation_success=true'
response = RedirectResponse(tos_redirect_url, status_code=302)
else:
if invitation_token:
redirect_url = f'{redirect_url}&invitation_success=true'
response = RedirectResponse(redirect_url, status_code=302)
set_response_cookie(
@@ -438,7 +551,10 @@ async def keycloak_offline_callback(code: str, state: str, request: Request):
user_id=user_info['sub'], offline_token=keycloak_refresh_token
)
return RedirectResponse(state if state else request.base_url, status_code=302)
redirect_url, _, _ = _extract_oauth_state(state)
return RedirectResponse(
redirect_url if redirect_url else request.base_url, status_code=302
)
@oauth_router.get('/github/callback')
@@ -495,17 +611,20 @@ async def accept_tos(request: Request):
# Update user settings with TOS acceptance
accepted_tos: datetime = datetime.now(timezone.utc)
with session_maker() as session:
user = session.query(User).filter(User.id == uuid.UUID(user_id)).first()
async with a_session_maker() as session:
result = await session.execute(
select(User).where(User.id == uuid.UUID(user_id))
)
user = result.scalar_one_or_none()
if not user:
session.rollback()
await session.rollback()
logger.error('User for {user_id} not found.')
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={'error': 'User does not exist'},
)
user.accepted_tos = accepted_tos
session.commit()
await session.commit()
logger.info(f'User {user_id} accepted TOS')
+45 -35
View File
@@ -9,14 +9,14 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from integrations import stripe_service
from pydantic import BaseModel
from server.constants import (
STRIPE_API_KEY,
)
from server.constants import STRIPE_API_KEY
from server.logger import logger
from sqlalchemy import select
from starlette.datastructures import URL
from storage.billing_session import BillingSession
from storage.database import session_maker
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
from storage.org import Org
from storage.subscription_access import SubscriptionAccess
from storage.user_store import UserStore
@@ -24,7 +24,7 @@ from openhands.app_server.config import get_global_config
from openhands.server.user_auth import get_user_id
stripe.api_key = STRIPE_API_KEY
billing_router = APIRouter(prefix='/api/billing')
billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
async def validate_billing_enabled() -> None:
@@ -94,9 +94,9 @@ async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse
user_team_info = await LiteLlmManager.get_user_team_info(
user_id, str(user.current_org_id)
)
# Update to use calculate_credits
spend = user_team_info.get('spend', 0)
max_budget = (user_team_info.get('litellm_budget_table') or {}).get('max_budget', 0)
max_budget, spend = LiteLlmManager.get_budget_from_team_info(
user_team_info, user_id, str(user.current_org_id)
)
credits = max(max_budget - spend, 0)
return GetCreditsResponse(credits=Decimal('{:.2f}'.format(credits)))
@@ -107,16 +107,17 @@ async def get_subscription_access(
user_id: str = Depends(get_user_id),
) -> SubscriptionAccessResponse | None:
"""Get details of the currently valid subscription for the user."""
with session_maker() as session:
async with a_session_maker() as session:
now = datetime.now(UTC)
subscription_access = (
session.query(SubscriptionAccess)
.filter(SubscriptionAccess.status == 'ACTIVE')
.filter(SubscriptionAccess.user_id == user_id)
.filter(SubscriptionAccess.start_at <= now)
.filter(SubscriptionAccess.end_at >= now)
.first()
result = await session.execute(
select(SubscriptionAccess).where(
SubscriptionAccess.status == 'ACTIVE',
SubscriptionAccess.user_id == user_id,
SubscriptionAccess.start_at <= now,
SubscriptionAccess.end_at >= now,
)
)
subscription_access = result.scalar_one_or_none()
if not subscription_access:
return None
return SubscriptionAccessResponse(
@@ -148,7 +149,7 @@ async def create_customer_setup_session(
customer=customer_info['customer_id'],
mode='setup',
payment_method_types=['card'],
success_url=f'{base_url}?free_credits=success',
success_url=f'{base_url}?setup=success',
cancel_url=f'{base_url}',
)
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
@@ -198,7 +199,7 @@ async def create_checkout_session(
'checkout_session_id': checkout_session.id,
},
)
with session_maker() as session:
async with a_session_maker() as session:
billing_session = BillingSession(
id=checkout_session.id,
user_id=user_id,
@@ -207,7 +208,7 @@ async def create_checkout_session(
price_code='NA',
)
session.add(billing_session)
session.commit()
await session.commit()
return CreateBillingSessionResponse(redirect_url=checkout_session.url)
@@ -216,13 +217,14 @@ async def create_checkout_session(
@billing_router.get('/success')
async def success_callback(session_id: str, request: Request):
# We can't use the auth cookie because of SameSite=strict
with session_maker() as session:
billing_session = (
session.query(BillingSession)
.filter(BillingSession.id == session_id)
.filter(BillingSession.status == 'in_progress')
.first()
async with a_session_maker() as session:
result = await session.execute(
select(BillingSession).where(
BillingSession.id == session_id,
BillingSession.status == 'in_progress',
)
)
billing_session = result.scalar_one_or_none()
if billing_session is None:
# Hopefully this never happens - we get a redirect from stripe where the session does not exist
@@ -250,15 +252,22 @@ async def success_callback(session_id: str, request: Request):
)
amount_subtotal = stripe_session.amount_subtotal or 0
add_credits = amount_subtotal / 100
max_budget = (user_team_info.get('litellm_budget_table') or {}).get(
'max_budget', 0
max_budget, _ = LiteLlmManager.get_budget_from_team_info(
user_team_info, billing_session.user_id, str(user.current_org_id)
)
result = await session.execute(select(Org).where(Org.id == user.current_org_id))
org = result.scalar_one_or_none()
new_max_budget = max_budget + add_credits
await LiteLlmManager.update_team_and_users_budget(
str(user.current_org_id), new_max_budget
)
# Enable BYOR export for the org now that they've purchased credits
if org:
org.byor_export_enabled = True
# Store transaction status
billing_session.status = 'completed'
billing_session.price = add_credits
@@ -274,7 +283,7 @@ async def success_callback(session_id: str, request: Request):
'stripe_customer_id': stripe_session.customer,
},
)
session.commit()
await session.commit()
return RedirectResponse(
f'{_get_base_url(request)}settings/billing?checkout=success', status_code=302
@@ -284,13 +293,14 @@ async def success_callback(session_id: str, request: Request):
# Callback endpoint for cancelled Stripe payments - updates billing session status
@billing_router.get('/cancel')
async def cancel_callback(session_id: str, request: Request):
with session_maker() as session:
billing_session = (
session.query(BillingSession)
.filter(BillingSession.id == session_id)
.filter(BillingSession.status == 'in_progress')
.first()
async with a_session_maker() as session:
result = await session.execute(
select(BillingSession).where(
BillingSession.id == session_id,
BillingSession.status == 'in_progress',
)
)
billing_session = result.scalar_one_or_none()
if billing_session:
logger.info(
'stripe_checkout_cancel',
@@ -302,7 +312,7 @@ async def cancel_callback(session_id: str, request: Request):
billing_session.status = 'cancelled'
billing_session.updated_at = datetime.now(UTC)
session.merge(billing_session)
session.commit()
await session.commit()
return RedirectResponse(
f'{_get_base_url(request)}settings/billing?checkout=cancel', status_code=302
+6
View File
@@ -8,6 +8,7 @@ from server.auth.keycloak_manager import get_keycloak_admin
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.auth import set_response_cookie
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
@@ -62,6 +63,10 @@ async def update_email(
},
)
await UserStore.update_user_email(
user_id=user_id, email=email, email_verified=False
)
user_auth: SaasUserAuth = await get_user_auth(request)
await user_auth.refresh() # refresh so access token has updated email
user_auth.email = email
@@ -144,6 +149,7 @@ async def verified_email(request: Request):
user_auth: SaasUserAuth = await get_user_auth(request)
await user_auth.refresh() # refresh so access token has updated email
user_auth.email_verified = True
await UserStore.update_user_email(user_id=user_auth.user_id, email_verified=True)
scheme = 'http' if request.url.hostname == 'localhost' else 'https'
redirect_uri = f'{scheme}://{request.url.netloc}/settings/user'
response = RedirectResponse(redirect_uri, status_code=302)
+43 -47
View File
@@ -3,16 +3,22 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.future import select
from storage.database import session_maker
from storage.database import a_session_maker
from storage.feedback import ConversationFeedback
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
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
from openhands.utils.async_utils import call_sync_from_async
router = APIRouter(prefix='/feedback', tags=['feedback'])
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
# is protected. The actual protection is provided by SetAuthCookieMiddleware
# TODO: It may be an error by you can actually post feedback to a conversation you don't
# own right now - maybe this is useful in the context of public shared conversations?
router = APIRouter(
prefix='/feedback', tags=['feedback'], dependencies=get_dependencies()
)
async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
@@ -30,23 +36,19 @@ async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
"""
# Verify the conversation belongs to the user
def _verify_conversation():
with session_maker() as session:
metadata = (
session.query(StoredConversationMetadataSaas)
.filter(
StoredConversationMetadataSaas.conversation_id == conversation_id,
StoredConversationMetadataSaas.user_id == user_id,
)
.first()
async with a_session_maker() as session:
result = await session.execute(
select(StoredConversationMetadataSaas).where(
StoredConversationMetadataSaas.conversation_id == conversation_id,
StoredConversationMetadataSaas.user_id == user_id,
)
)
metadata = result.scalars().first()
if not metadata:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Conversation {conversation_id} not found',
)
if not metadata:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Conversation {conversation_id} not found',
)
await call_sync_from_async(_verify_conversation)
# Create an event store to access the events directly
# This works even when the conversation is not running
@@ -96,12 +98,9 @@ async def submit_conversation_feedback(feedback: FeedbackRequest):
)
# Add to database
def _save_feedback():
with session_maker() as session:
session.add(new_feedback)
session.commit()
await call_sync_from_async(_save_feedback)
async with a_session_maker() as session:
session.add(new_feedback)
await session.commit()
return {'status': 'success', 'message': 'Feedback submitted successfully'}
@@ -120,30 +119,27 @@ async def get_batch_feedback(conversation_id: str, user_id: str = Depends(get_us
return {}
# Query for existing feedback for all events
def _check_feedback():
with session_maker() as session:
result = session.execute(
select(ConversationFeedback).where(
ConversationFeedback.conversation_id == conversation_id,
ConversationFeedback.event_id.in_(event_ids),
)
async with a_session_maker() as session:
result = await session.execute(
select(ConversationFeedback).where(
ConversationFeedback.conversation_id == conversation_id,
ConversationFeedback.event_id.in_(event_ids),
)
)
# Create a mapping of event_id to feedback
feedback_map = {
feedback.event_id: {
'exists': True,
'rating': feedback.rating,
'reason': feedback.reason,
}
for feedback in result.scalars()
# Create a mapping of event_id to feedback
feedback_map = {
feedback.event_id: {
'exists': True,
'rating': feedback.rating,
'reason': feedback.reason,
}
for feedback in result.scalars()
}
# Build response including all events
response = {}
for event_id in event_ids:
response[str(event_id)] = feedback_map.get(event_id, {'exists': False})
# Build response including all events
response = {}
for event_id in event_ids:
response[str(event_id)] = feedback_map.get(event_id, {'exists': False})
return response
return await call_sync_from_async(_check_feedback)
return response
+6 -9
View File
@@ -4,7 +4,7 @@ import json
import os
import re
import uuid
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import requests
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, status
@@ -308,10 +308,11 @@ async def jira_events(
logger.info(f'Processing new Jira webhook event: {signature}')
redis_client.setex(key, 300, '1')
# Process the webhook
# Process the webhook in background after returning response.
# Note: For async functions, BackgroundTasks runs them in the same event loop
# (not a thread pool), so asyncpg connections work correctly.
message_payload = {'payload': payload}
message = Message(source=SourceType.JIRA, message=message_payload)
background_tasks.add_task(jira_manager.receive_message, message)
return JSONResponse({'success': True})
@@ -371,9 +372,7 @@ async def create_jira_workspace(request: Request, workspace_data: JiraWorkspaceC
'prompt': 'consent',
}
auth_url = (
f"{JIRA_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
)
auth_url = f'{JIRA_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={
@@ -432,9 +431,7 @@ async def create_workspace_link(request: Request, link_data: JiraLinkCreate):
'response_type': 'code',
'prompt': 'consent',
}
auth_url = (
f"{JIRA_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
)
auth_url = f'{JIRA_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={
@@ -2,7 +2,7 @@ import json
import os
import re
import uuid
from urllib.parse import urlparse
from urllib.parse import urlencode, urlparse
import requests
from fastapi import (
@@ -316,7 +316,7 @@ async def create_jira_dc_workspace(
'response_type': 'code',
}
auth_url = f"{JIRA_DC_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
auth_url = f'{JIRA_DC_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={
@@ -436,7 +436,7 @@ async def create_workspace_link(request: Request, link_data: JiraDcLinkCreate):
'state': state,
'response_type': 'code',
}
auth_url = f"{JIRA_DC_AUTH_URL}?{'&'.join([f'{k}={v}' for k, v in auth_params.items()])}"
auth_url = f'{JIRA_DC_AUTH_URL}?{urlencode(auth_params)}'
return JSONResponse(
content={
+12 -11
View File
@@ -7,7 +7,6 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from storage.api_key_store import ApiKeyStore
from storage.database import session_maker
from storage.device_code_store import DeviceCodeStore
from openhands.core.logger import openhands_logger as logger
@@ -54,7 +53,7 @@ class DeviceTokenErrorResponse(BaseModel):
# ---------------------------------------------------------------------------
oauth_device_router = APIRouter(prefix='/oauth/device')
device_code_store = DeviceCodeStore(session_maker)
device_code_store = DeviceCodeStore()
# ---------------------------------------------------------------------------
@@ -90,7 +89,7 @@ async def device_authorization(
) -> DeviceAuthorizationResponse:
"""Start device flow by generating device and user codes."""
try:
device_code_entry = device_code_store.create_device_code(
device_code_entry = await device_code_store.create_device_code(
expires_in=DEVICE_CODE_EXPIRES_IN,
)
@@ -125,7 +124,7 @@ async def device_authorization(
async def device_token(device_code: str = Form(...)):
"""Poll for a token until the user authorizes or the code expires."""
try:
device_code_entry = device_code_store.get_by_device_code(device_code)
device_code_entry = await device_code_store.get_by_device_code(device_code)
if not device_code_entry:
return _oauth_error(
@@ -138,7 +137,9 @@ async def device_token(device_code: str = Form(...)):
is_too_fast, current_interval = device_code_entry.check_rate_limit()
if is_too_fast:
# Update poll time and increase interval
device_code_store.update_poll_time(device_code, increase_interval=True)
await device_code_store.update_poll_time(
device_code, increase_interval=True
)
logger.warning(
'Client polling too fast, returning slow_down error',
extra={
@@ -154,7 +155,7 @@ async def device_token(device_code: str = Form(...)):
)
# Update poll time for successful rate limit check
device_code_store.update_poll_time(device_code, increase_interval=False)
await device_code_store.update_poll_time(device_code, increase_interval=False)
if device_code_entry.is_expired():
return _oauth_error(
@@ -181,7 +182,7 @@ async def device_token(device_code: str = Form(...)):
# Retrieve the specific API key for this device using the user_code
api_key_store = ApiKeyStore.get_instance()
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'
device_api_key = api_key_store.retrieve_api_key_by_name(
device_api_key = await api_key_store.retrieve_api_key_by_name(
device_code_entry.keycloak_user_id, device_key_name
)
@@ -238,7 +239,7 @@ async def device_verification_authenticated(
)
# Validate device code
device_code_entry = device_code_store.get_by_user_code(user_code)
device_code_entry = await device_code_store.get_by_user_code(user_code)
if not device_code_entry:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -252,7 +253,7 @@ async def device_verification_authenticated(
)
# First, authorize the device code
success = device_code_store.authorize_device_code(
success = await device_code_store.authorize_device_code(
user_code=user_code,
user_id=user_id,
)
@@ -272,7 +273,7 @@ async def device_verification_authenticated(
try:
# Create a unique API key for this device using user_code in the name
device_key_name = f'{API_KEY_NAME} ({user_code})'
api_key_store.create_api_key(
await api_key_store.create_api_key(
user_id,
name=device_key_name,
expires_at=datetime.now(UTC) + KEY_EXPIRATION_TIME,
@@ -289,7 +290,7 @@ async def device_verification_authenticated(
# Clean up: revert the device authorization since API key creation failed
# This prevents the device from being in an authorized state without an API key
try:
device_code_store.deny_device_code(user_code)
await device_code_store.deny_device_code(user_code)
logger.info(
'Reverted device authorization due to API key creation failure',
extra={'user_code': user_code, 'user_id': user_id},
@@ -0,0 +1,122 @@
"""
Pydantic models and custom exceptions for organization invitations.
"""
from pydantic import BaseModel, EmailStr
from storage.org_invitation import OrgInvitation
from storage.role_store import RoleStore
class InvitationError(Exception):
"""Base exception for invitation errors."""
pass
class InvitationAlreadyExistsError(InvitationError):
"""Raised when a pending invitation already exists for the email."""
def __init__(
self, message: str = 'A pending invitation already exists for this email'
):
super().__init__(message)
class UserAlreadyMemberError(InvitationError):
"""Raised when the user is already a member of the organization."""
def __init__(self, message: str = 'User is already a member of this organization'):
super().__init__(message)
class InvitationExpiredError(InvitationError):
"""Raised when the invitation has expired."""
def __init__(self, message: str = 'Invitation has expired'):
super().__init__(message)
class InvitationInvalidError(InvitationError):
"""Raised when the invitation is invalid or revoked."""
def __init__(self, message: str = 'Invitation is no longer valid'):
super().__init__(message)
class InsufficientPermissionError(InvitationError):
"""Raised when the user lacks permission to perform the action."""
def __init__(self, message: str = 'Insufficient permission'):
super().__init__(message)
class EmailMismatchError(InvitationError):
"""Raised when the accepting user's email doesn't match the invitation email."""
def __init__(self, message: str = 'Your email does not match the invitation'):
super().__init__(message)
class InvitationCreate(BaseModel):
"""Request model for creating invitation(s)."""
emails: list[EmailStr]
role: str = 'member' # Default to member role
class InvitationResponse(BaseModel):
"""Response model for invitation details."""
id: int
email: str
role: str
status: str
created_at: str
expires_at: str
inviter_email: str | None = None
@classmethod
def from_invitation(
cls,
invitation: OrgInvitation,
inviter_email: str | None = None,
) -> 'InvitationResponse':
"""Create an InvitationResponse from an OrgInvitation entity.
Args:
invitation: The invitation entity to convert
inviter_email: Optional email of the inviter
Returns:
InvitationResponse: The response model instance
"""
role_name = ''
if invitation.role:
role_name = invitation.role.name
elif invitation.role_id:
role = RoleStore.get_role_by_id(invitation.role_id)
role_name = role.name if role else ''
return cls(
id=invitation.id,
email=invitation.email,
role=role_name,
status=invitation.status,
created_at=invitation.created_at.isoformat(),
expires_at=invitation.expires_at.isoformat(),
inviter_email=inviter_email,
)
class InvitationFailure(BaseModel):
"""Response model for a failed invitation."""
email: str
error: str
class BatchInvitationResponse(BaseModel):
"""Response model for batch invitation creation."""
successful: list[InvitationResponse]
failed: list[InvitationFailure]
+226
View File
@@ -0,0 +1,226 @@
"""API routes for organization invitations."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from server.routes.org_invitation_models import (
BatchInvitationResponse,
EmailMismatchError,
InsufficientPermissionError,
InvitationCreate,
InvitationExpiredError,
InvitationFailure,
InvitationInvalidError,
InvitationResponse,
UserAlreadyMemberError,
)
from server.services.org_invitation_service import OrgInvitationService
from server.utils.rate_limit_utils import check_rate_limit_by_user_id
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth.user_auth import get_user_auth
# Router for invitation operations on an organization (requires org_id)
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
# Router for accepting invitations (no org_id required)
accept_router = APIRouter(prefix='/api/organizations/members/invite')
@invitation_router.post(
'/invite',
response_model=BatchInvitationResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_invitation(
org_id: UUID,
invitation_data: InvitationCreate,
request: Request,
user_id: str = Depends(get_user_id),
):
"""Create organization invitations for multiple email addresses.
Sends emails to invitees with secure links to join the organization.
Supports batch invitations - some may succeed while others fail.
Permission rules:
- Only owners and admins can create invitations
- Admins can only invite with 'member' or 'admin' role (not 'owner')
- Owners can invite with any role
Args:
org_id: Organization UUID
invitation_data: Invitation details (emails array, role)
request: FastAPI request
user_id: Authenticated user ID (from dependency)
Returns:
BatchInvitationResponse: Lists of successful and failed invitations
Raises:
HTTPException 400: Invalid role or organization not found
HTTPException 403: User lacks permission to invite
HTTPException 429: Rate limit exceeded
"""
# Rate limit: 10 invitations per minute per user (6 seconds between requests)
await check_rate_limit_by_user_id(
request=request,
key_prefix='org_invitation_create',
user_id=user_id,
user_rate_limit_seconds=6,
)
try:
successful, failed = await OrgInvitationService.create_invitations_batch(
org_id=org_id,
emails=[str(email) for email in invitation_data.emails],
role_name=invitation_data.role,
inviter_id=UUID(user_id),
)
logger.info(
'Batch organization invitations created',
extra={
'org_id': str(org_id),
'total_emails': len(invitation_data.emails),
'successful': len(successful),
'failed': len(failed),
'inviter_id': user_id,
},
)
return BatchInvitationResponse(
successful=[InvitationResponse.from_invitation(inv) for inv in successful],
failed=[
InvitationFailure(email=email, error=error) for email, error in failed
],
)
except InsufficientPermissionError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except Exception as e:
logger.exception(
'Unexpected error creating batch invitations',
extra={'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@accept_router.get('/accept')
async def accept_invitation(
token: str,
request: Request,
):
"""Accept an organization invitation via token.
This endpoint is accessed via the link in the invitation email.
Flow:
1. If user is authenticated: Accept invitation directly and redirect to home
2. If user is not authenticated: Redirect to login page with invitation token
- Frontend stores token and includes it in OAuth state during login
- After authentication, keycloak_callback processes the invitation
Args:
token: The invitation token from the email link
request: FastAPI request
Returns:
RedirectResponse: Redirect to home page on success, or login page if not authenticated,
or home page with error query params on failure
"""
base_url = str(request.base_url).rstrip('/')
# Try to get user_id from auth (may not be authenticated)
user_id = None
try:
user_auth = await get_user_auth(request)
if user_auth:
user_id = await user_auth.get_user_id()
except Exception:
pass
if not user_id:
# User not authenticated - redirect to login page with invitation token
# Frontend will store the token and include it in OAuth state during login
logger.info(
'Invitation accept: redirecting unauthenticated user to login',
extra={'token_prefix': token[:10] + '...'},
)
login_url = f'{base_url}/login?invitation_token={token}'
return RedirectResponse(login_url, status_code=302)
# User is authenticated - process the invitation directly
try:
await OrgInvitationService.accept_invitation(token, UUID(user_id))
logger.info(
'Invitation accepted successfully',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
},
)
# Redirect to home page on success
return RedirectResponse(f'{base_url}/', status_code=302)
except InvitationExpiredError:
logger.warning(
'Invitation accept failed: expired',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?invitation_expired=true', status_code=302)
except InvitationInvalidError as e:
logger.warning(
'Invitation accept failed: invalid',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_invalid=true', status_code=302)
except UserAlreadyMemberError:
logger.info(
'Invitation accept: user already member',
extra={'token_prefix': token[:10] + '...', 'user_id': user_id},
)
return RedirectResponse(f'{base_url}/?already_member=true', status_code=302)
except EmailMismatchError as e:
logger.warning(
'Invitation accept failed: email mismatch',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?email_mismatch=true', status_code=302)
except Exception as e:
logger.exception(
'Unexpected error accepting invitation',
extra={
'token_prefix': token[:10] + '...',
'user_id': user_id,
'error': str(e),
},
)
return RedirectResponse(f'{base_url}/?invitation_error=true', status_code=302)
+320 -5
View File
@@ -1,5 +1,16 @@
from pydantic import BaseModel, EmailStr, Field
from typing import Annotated
from pydantic import (
BaseModel,
EmailStr,
Field,
SecretStr,
StringConstraints,
field_validator,
)
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
class OrgCreationError(Exception):
@@ -41,6 +52,16 @@ class OrgAuthorizationError(OrgDeletionError):
super().__init__(message)
class OrphanedUserError(OrgDeletionError):
"""Raised when deleting an org would leave users without any organization."""
def __init__(self, user_ids: list[str]):
self.user_ids = user_ids
super().__init__(
f'Cannot delete organization: {len(user_ids)} user(s) would have no remaining organization'
)
class OrgNotFoundError(Exception):
"""Raised when organization is not found or user doesn't have access."""
@@ -49,13 +70,70 @@ class OrgNotFoundError(Exception):
super().__init__(f'Organization with id "{org_id}" not found')
class OrgMemberNotFoundError(Exception):
"""Raised when a member is not found in an organization."""
def __init__(self, org_id: str, user_id: str):
self.org_id = org_id
self.user_id = user_id
super().__init__(f'Member "{user_id}" not found in organization "{org_id}"')
class RoleNotFoundError(Exception):
"""Raised when a role is not found."""
def __init__(self, role_id: int):
self.role_id = role_id
super().__init__(f'Role with id "{role_id}" not found')
class InvalidRoleError(Exception):
"""Raised when an invalid role name is specified."""
def __init__(self, role_name: str):
self.role_name = role_name
super().__init__(f'Invalid role: "{role_name}"')
class InsufficientPermissionError(Exception):
"""Raised when user lacks permission to perform an operation."""
def __init__(self, message: str = 'Insufficient permission'):
super().__init__(message)
class CannotModifySelfError(Exception):
"""Raised when user attempts to modify their own membership."""
def __init__(self, action: str = 'modify'):
self.action = action
super().__init__(f'Cannot {action} your own membership')
class LastOwnerError(Exception):
"""Raised when attempting to remove or demote the last owner."""
def __init__(self, action: str = 'remove'):
self.action = action
super().__init__(f'Cannot {action} the last owner of an organization')
class MemberUpdateError(Exception):
"""Raised when member update operation fails."""
def __init__(self, message: str = 'Failed to update member'):
super().__init__(message)
class OrgCreate(BaseModel):
"""Request model for creating a new organization."""
# Required fields
name: str = Field(min_length=1, max_length=255, strip_whitespace=True)
name: Annotated[
str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)
]
contact_name: str
contact_email: EmailStr = Field(strip_whitespace=True)
contact_email: EmailStr
class OrgResponse(BaseModel):
@@ -87,14 +165,18 @@ class OrgResponse(BaseModel):
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
credits: float | None = None
is_personal: bool = False
@classmethod
def from_org(cls, org: Org, credits: float | None = None) -> 'OrgResponse':
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
@@ -130,6 +212,7 @@ class OrgResponse(BaseModel):
enable_solvability_analysis=org.enable_solvability_analysis,
v1_enabled=org.v1_enabled,
credits=credits,
is_personal=str(org.id) == user_id if user_id else False,
)
@@ -138,14 +221,19 @@ class OrgPage(BaseModel):
items: list[OrgResponse]
next_page_id: str | None = None
current_org_id: str | None = None
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),
] = None
contact_name: str | None = None
contact_email: EmailStr | None = Field(default=None, strip_whitespace=True)
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)
@@ -169,3 +257,230 @@ class OrgUpdate(BaseModel):
confirmation_mode: bool | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
class OrgLLMSettingsResponse(BaseModel):
"""Response model for organization LLM settings."""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
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:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return None
raw = secret.get_secret_value()
if not raw:
return None
if len(raw) <= 4:
return '****'
return '****' + raw[-4:]
@classmethod
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,
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.
Field names match OrgMember DB columns.
"""
llm_model: str | None = None
llm_base_url: str | None = None
max_iterations: int | 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)
class OrgLLMSettingsUpdate(BaseModel):
"""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
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)
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)
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,
)
return member_settings if member_settings.has_updates() else None
class OrgMemberResponse(BaseModel):
"""Response model for a single organization member."""
user_id: str
email: str | None
role_id: int
role: str
role_rank: int
status: str | None
class OrgMemberPage(BaseModel):
"""Paginated response for organization members."""
items: list[OrgMemberResponse]
current_page: int = 1
per_page: int = 10
class OrgMemberUpdate(BaseModel):
"""Request model for updating an organization member."""
role: str | None = None # Role name: 'owner', 'admin', or 'member'
class MeResponse(BaseModel):
"""Response model for the current user's membership in an organization."""
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
status: str | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return ''
raw = secret.get_secret_value()
if not raw:
return ''
if len(raw) <= 4:
return '****'
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
"""
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,
status=member.status,
)
class OrgAppSettingsResponse(BaseModel):
"""Response model for organization app settings."""
enable_proactive_conversation_starters: bool = True
enable_solvability_analysis: bool | None = None
max_budget_per_task: float | None = None
@classmethod
def from_org(cls, org: Org) -> 'OrgAppSettingsResponse':
"""Create an OrgAppSettingsResponse from an Org entity.
Args:
org: The organization entity
Returns:
OrgAppSettingsResponse with app settings
"""
return cls(
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
if org.enable_proactive_conversation_starters is not None
else True,
enable_solvability_analysis=org.enable_solvability_analysis,
max_budget_per_task=org.max_budget_per_task,
)
class OrgAppSettingsUpdate(BaseModel):
"""Request model for updating organization app settings."""
enable_proactive_conversation_starters: bool | None = None
enable_solvability_analysis: bool | None = None
max_budget_per_task: float | None = None
@field_validator('max_budget_per_task')
@classmethod
def validate_max_budget_per_task(cls, v: float | None) -> float | None:
if v is not None and v <= 0:
raise ValueError('max_budget_per_task must be greater than 0')
return v
+726 -25
View File
@@ -2,25 +2,62 @@ from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from server.auth.authorization import (
Permission,
require_permission,
)
from server.email_validation import get_admin_user_id
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
LiteLLMIntegrationError,
MemberUpdateError,
MeResponse,
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgAuthorizationError,
OrgCreate,
OrgDatabaseError,
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
OrgMemberUpdate,
OrgNameExistsError,
OrgNotFoundError,
OrgPage,
OrgResponse,
OrgUpdate,
OrphanedUserError,
RoleNotFoundError,
)
from server.services.org_app_settings_service import (
OrgAppSettingsService,
OrgAppSettingsServiceInjector,
)
from server.services.org_llm_settings_service import (
OrgLLMSettingsService,
OrgLLMSettingsServiceInjector,
)
from server.services.org_member_service import OrgMemberService
from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
# Initialize API router
org_router = APIRouter(prefix='/api/organizations')
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
# Create injector instance and dependency for LLM settings
_org_llm_settings_injector = OrgLLMSettingsServiceInjector()
org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends)
# Create injector instance and dependency at module level
_org_app_settings_injector = OrgAppSettingsServiceInjector()
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
@org_router.get('', response_model=OrgPage)
@@ -61,6 +98,12 @@ async def list_user_orgs(
)
try:
# Fetch user to get current_org_id
user = await UserStore.get_user_by_id_async(user_id)
current_org_id = (
str(user.current_org_id) if user and user.current_org_id else None
)
# Fetch organizations from service layer
orgs, next_page_id = OrgService.get_user_orgs_paginated(
user_id=user_id,
@@ -69,7 +112,9 @@ async def list_user_orgs(
)
# Convert Org entities to OrgResponse objects
org_responses = [OrgResponse.from_org(org, credits=None) for org in orgs]
org_responses = [
OrgResponse.from_org(org, credits=None, user_id=user_id) for org in orgs
]
logger.info(
'Successfully retrieved organizations',
@@ -80,7 +125,11 @@ async def list_user_orgs(
},
)
return OrgPage(items=org_responses, next_page_id=next_page_id)
return OrgPage(
items=org_responses,
next_page_id=next_page_id,
current_org_id=current_org_id,
)
except Exception as e:
logger.exception(
@@ -136,7 +185,7 @@ async def create_org(
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNameExistsError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -171,26 +220,218 @@ async def create_org(
)
@org_router.get(
'/llm',
response_model=OrgLLMSettingsResponse,
dependencies=[Depends(require_permission(Permission.VIEW_LLM_SETTINGS))],
)
async def get_org_llm_settings(
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
) -> OrgLLMSettingsResponse:
"""Get LLM settings for the user's current organization.
This endpoint retrieves the LLM configuration settings for the
authenticated user's current organization. All organization members
can view these settings.
Args:
service: OrgLLMSettingsService (injected by dependency)
Returns:
OrgLLMSettingsResponse: The organization's LLM settings
Raises:
HTTPException: 401 if not authenticated
HTTPException: 403 if not a member of any organization
HTTPException: 404 if current organization not found
HTTPException: 500 if retrieval fails
"""
try:
return await service.get_org_llm_settings()
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.exception(
'Error getting organization LLM settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve LLM settings',
)
@org_router.post(
'/llm',
response_model=OrgLLMSettingsResponse,
dependencies=[Depends(require_permission(Permission.EDIT_LLM_SETTINGS))],
)
async def update_org_llm_settings(
settings: OrgLLMSettingsUpdate,
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
) -> OrgLLMSettingsResponse:
"""Update LLM settings for the user's current organization.
This endpoint updates the LLM configuration settings for the
authenticated user's current organization. Only admins and owners
can update these settings.
Args:
settings: The LLM settings to update (only non-None fields are updated)
service: OrgLLMSettingsService (injected by dependency)
Returns:
OrgLLMSettingsResponse: The updated organization's LLM settings
Raises:
HTTPException: 401 if not authenticated
HTTPException: 403 if user lacks EDIT_LLM_SETTINGS permission
HTTPException: 404 if current organization not found
HTTPException: 500 if update fails
"""
try:
return await service.update_org_llm_settings(settings)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database error updating LLM settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update LLM settings',
)
except Exception as e:
logger.exception(
'Error updating organization LLM settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update LLM settings',
)
@org_router.get(
'/app',
response_model=OrgAppSettingsResponse,
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
)
async def get_org_app_settings(
service: OrgAppSettingsService = org_app_settings_service_dependency,
) -> OrgAppSettingsResponse:
"""Get organization app settings for the user's current organization.
This endpoint retrieves application settings for the authenticated user's
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
service: OrgAppSettingsService (injected by dependency)
Returns:
OrgAppSettingsResponse: The organization app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
HTTPException: 404 if current organization not found
"""
try:
return await service.get_org_app_settings()
except OrgNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Current organization not found',
)
except Exception as e:
logger.exception(
'Unexpected error retrieving organization app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.post(
'/app',
response_model=OrgAppSettingsResponse,
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
)
async def update_org_app_settings(
update_data: OrgAppSettingsUpdate,
service: OrgAppSettingsService = org_app_settings_service_dependency,
) -> OrgAppSettingsResponse:
"""Update organization app settings for the user's current organization.
This endpoint updates application settings for the authenticated user's
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
update_data: App settings update data
service: OrgAppSettingsService (injected by dependency)
Returns:
OrgAppSettingsResponse: The updated organization app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
HTTPException: 404 if current organization not found
HTTPException: 422 if validation errors occur (handled by FastAPI)
HTTPException: 500 if update fails
"""
try:
return await service.update_org_app_settings(update_data)
except OrgNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Current organization not found',
)
except Exception as e:
logger.exception(
'Unexpected error updating organization app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
async def get_org(
org_id: UUID,
user_id: str = Depends(get_user_id),
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
) -> OrgResponse:
"""Get organization details by ID.
This endpoint allows authenticated users who are members of an organization
to retrieve its details. Only members of the organization can access this endpoint.
This endpoint retrieves details for a specific organization. Access requires
the VIEW_ORG_SETTINGS permission, which is granted to all organization members
(member, admin, and owner roles).
Args:
org_id: Organization ID (UUID)
user_id: Authenticated user ID (injected by dependency)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
OrgResponse: The organization details
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission
HTTPException: 404 if organization not found
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
HTTPException: 404 if organization not found or user is not a member
HTTPException: 500 if retrieval fails
"""
logger.info(
@@ -211,7 +452,7 @@ async def get_org(
# Retrieve credits from LiteLLM
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -228,26 +469,86 @@ async def get_org(
)
@org_router.get('/{org_id}/me', response_model=MeResponse)
async def get_me(
org_id: UUID,
user_id: str = Depends(get_user_id),
) -> MeResponse:
"""Get the current user's membership record for an organization.
Returns the authenticated user's role, status, email, and LLM override
fields (with masked API keys) within the specified organization.
Args:
org_id: Organization ID (UUID)
user_id: Authenticated user ID (injected by dependency)
Returns:
MeResponse: The user's membership data
Raises:
HTTPException: 404 if user is not a member or org doesn't exist
HTTPException: 500 if retrieval fails
"""
logger.info(
'Retrieving current member details',
extra={'user_id': user_id, 'org_id': str(org_id)},
)
try:
user_uuid = UUID(user_id)
return OrgMemberService.get_me(org_id, user_uuid)
except OrgMemberNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Organization with id "{org_id}" not found',
)
except RoleNotFoundError as e:
logger.exception(
'Role not found for org member',
extra={
'user_id': user_id,
'org_id': str(org_id),
'role_id': e.role_id,
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
except Exception as e:
logger.exception(
'Unexpected error retrieving member details',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.delete('/{org_id}', status_code=status.HTTP_200_OK)
async def delete_org(
org_id: UUID,
user_id: str = Depends(get_admin_user_id),
user_id: str = Depends(require_permission(Permission.DELETE_ORGANIZATION)),
) -> dict:
"""Delete an organization.
This endpoint allows authenticated organization owners to delete their organization.
All associated data including organization members, conversations, billing data,
and external LiteLLM team resources will be permanently removed.
This endpoint permanently deletes an organization and all associated data including
organization members, conversations, billing data, and external LiteLLM team resources.
Access requires the DELETE_ORGANIZATION permission, which is granted only to owners.
Args:
org_id: Organization ID to delete
user_id: Authenticated user ID (injected by dependency)
org_id: Organization ID to delete (UUID)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
dict: Confirmation message with deleted organization details
Raises:
HTTPException: 403 if user is not the organization owner
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks DELETE_ORGANIZATION permission
HTTPException: 404 if organization not found
HTTPException: 500 if deletion fails
"""
@@ -303,6 +604,19 @@ async def delete_org(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except OrphanedUserError as e:
logger.warning(
'Cannot delete organization: users would be orphaned',
extra={
'user_id': user_id,
'org_id': str(org_id),
'orphaned_users': e.user_ids,
},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database error during organization deletion',
@@ -327,25 +641,26 @@ async def delete_org(
async def update_org(
org_id: UUID,
update_data: OrgUpdate,
user_id: str = Depends(get_user_id),
user_id: str = Depends(require_permission(Permission.EDIT_ORG_SETTINGS)),
) -> OrgResponse:
"""Update an existing organization.
This endpoint allows authenticated users to update organization settings.
LLM-related settings require admin or owner role in the organization.
This endpoint updates organization settings. Access requires the EDIT_ORG_SETTINGS
permission, which is granted to admin and owner roles.
Args:
org_id: Organization ID to update (UUID validated by FastAPI)
org_id: Organization ID to update (UUID)
update_data: Organization update data
user_id: Authenticated user ID (injected by dependency)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
OrgResponse: The updated organization details
Raises:
HTTPException: 400 if org_id is invalid UUID format (handled by FastAPI)
HTTPException: 403 if user lacks permission for LLM settings
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks EDIT_ORG_SETTINGS permission
HTTPException: 404 if organization not found
HTTPException: 409 if organization name already exists
HTTPException: 422 if validation errors occur (handled by FastAPI)
HTTPException: 500 if update fails
"""
@@ -368,7 +683,7 @@ async def update_org(
# Retrieve credits from LiteLLM (following same pattern as create endpoint)
credits = await OrgService.get_org_credits(user_id, updated_org.id)
return OrgResponse.from_org(updated_org, credits=credits)
return OrgResponse.from_org(updated_org, credits=credits, user_id=user_id)
except ValueError as e:
# Organization not found
@@ -376,6 +691,11 @@ async def update_org(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgNameExistsError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
)
except PermissionError as e:
# User lacks permission for LLM settings
raise HTTPException(
@@ -400,3 +720,384 @@ async def update_org(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.get('/{org_id}/members')
async def get_org_members(
org_id: UUID,
page_id: Annotated[
str | None,
Query(title='Optional page offset for pagination'),
] = None,
limit: Annotated[
int,
Query(
title='The max number of results in the page',
gt=0,
lte=100,
),
] = 10,
email: Annotated[
str | None,
Query(
title='Filter members by email (case-insensitive partial match)',
min_length=1,
max_length=255,
),
] = None,
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
) -> OrgMemberPage:
"""Get all members of an organization with pagination and optional email filter.
This endpoint retrieves a paginated list of organization members. Access requires
the VIEW_ORG_SETTINGS permission, which is granted to all organization members
(member, admin, and owner roles).
Args:
org_id: Organization ID (UUID)
page_id: Optional page offset for pagination
limit: Maximum number of members to return (1-100, default 10)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
OrgMemberPage: Paginated list of organization members with
current_page and per_page metadata. Use the /count endpoint
to get the total count separately.
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission
HTTPException: 400 if org_id or page_id format is invalid
HTTPException: 500 if retrieval fails
"""
try:
success, error_code, data = await OrgMemberService.get_org_members(
org_id=org_id,
current_user_id=UUID(user_id),
page_id=page_id,
limit=limit,
email_filter=email,
)
if not success:
error_map = {
'not_a_member': (
status.HTTP_403_FORBIDDEN,
'You are not a member of this organization',
),
'invalid_page_id': (
status.HTTP_400_BAD_REQUEST,
'Invalid page_id format',
),
}
status_code, detail = error_map.get(
error_code, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
)
raise HTTPException(status_code=status_code, detail=detail)
if data is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve members',
)
return data
except HTTPException:
raise
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization ID format',
)
except Exception:
logger.exception('Error retrieving organization members')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve members',
)
@org_router.get('/{org_id}/members/count')
async def get_org_members_count(
org_id: UUID,
email: Annotated[
str | None,
Query(
title='Filter members by email (case-insensitive partial match)',
min_length=1,
max_length=255,
),
] = None,
user_id: str = Depends(require_permission(Permission.VIEW_ORG_SETTINGS)),
) -> int:
"""Get count of organization members with optional email filter.
This endpoint returns the total count of organization members matching
the filter criteria. Access requires the VIEW_ORG_SETTINGS permission,
which is granted to all organization members (member, admin, and owner roles).
Args:
org_id: Organization ID (UUID)
email: Optional email filter (case-insensitive partial match)
user_id: Authenticated user ID (injected by require_permission dependency)
Returns:
int: Total count of organization members matching the filter
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 403 if user lacks VIEW_ORG_SETTINGS permission or is not a member
HTTPException: 400 if org_id format is invalid
HTTPException: 500 if retrieval fails
"""
try:
return await OrgMemberService.get_org_members_count(
org_id=org_id,
current_user_id=UUID(user_id),
email_filter=email,
)
except OrgMemberNotFoundError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You are not a member of this organization',
)
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization ID format',
)
except Exception:
logger.exception('Error retrieving organization member count')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve member count',
)
@org_router.delete('/{org_id}/members/{user_id}')
async def remove_org_member(
org_id: UUID,
user_id: str,
current_user_id: str = Depends(get_user_id),
):
"""Remove a member from an organization.
Only owners and admins can remove members:
- Owners can remove admins and regular users
- Admins can only remove regular users
Users cannot remove themselves. The last owner cannot be removed.
"""
try:
success, error = await OrgMemberService.remove_org_member(
org_id=org_id,
target_user_id=UUID(user_id),
current_user_id=UUID(current_user_id),
)
if not success:
error_map = {
'not_a_member': (
status.HTTP_403_FORBIDDEN,
'You are not a member of this organization',
),
'cannot_remove_self': (
status.HTTP_403_FORBIDDEN,
'Cannot remove yourself from an organization',
),
'member_not_found': (
status.HTTP_404_NOT_FOUND,
'Member not found in this organization',
),
'insufficient_permission': (
status.HTTP_403_FORBIDDEN,
'You do not have permission to remove this member',
),
'cannot_remove_last_owner': (
status.HTTP_400_BAD_REQUEST,
'Cannot remove the last owner of an organization',
),
'removal_failed': (
status.HTTP_500_INTERNAL_SERVER_ERROR,
'Failed to remove member',
),
}
status_code, detail = error_map.get(
error, (status.HTTP_500_INTERNAL_SERVER_ERROR, 'An error occurred')
)
raise HTTPException(status_code=status_code, detail=detail)
return {'message': 'Member removed successfully'}
except HTTPException:
raise
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization or user ID format',
)
except Exception:
logger.exception('Error removing organization member')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to remove member',
)
@org_router.post(
'/{org_id}/switch', response_model=OrgResponse, status_code=status.HTTP_200_OK
)
async def switch_org(
org_id: UUID,
user_id: str = Depends(get_user_id),
) -> OrgResponse:
"""Switch to a different organization.
This endpoint allows authenticated users to switch their current active
organization. The user must be a member of the target organization.
Args:
org_id: Organization ID to switch to (UUID)
user_id: Authenticated user ID (injected by dependency)
Returns:
OrgResponse: The organization details that was switched to
Raises:
HTTPException: 422 if org_id is not a valid UUID (handled by FastAPI)
HTTPException: 403 if user is not a member of the organization
HTTPException: 404 if organization not found
HTTPException: 500 if switch fails
"""
logger.info(
'Switching organization',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
try:
# Use service layer to switch organization with membership validation
org = await OrgService.switch_org(
user_id=user_id,
org_id=org_id,
)
# Retrieve credits from LiteLLM for the new current org
credits = await OrgService.get_org_credits(user_id, org.id)
return OrgResponse.from_org(org, credits=credits, user_id=user_id)
except OrgNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except OrgAuthorizationError as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(e),
)
except OrgDatabaseError as e:
logger.error(
'Database operation failed during organization switch',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to switch organization',
)
except Exception as e:
logger.exception(
'Unexpected error switching organization',
extra={'user_id': user_id, 'org_id': str(org_id), 'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='An unexpected error occurred',
)
@org_router.patch('/{org_id}/members/{user_id}', response_model=OrgMemberResponse)
async def update_org_member(
org_id: UUID,
user_id: str,
update_data: OrgMemberUpdate,
current_user_id: str = Depends(get_user_id),
) -> OrgMemberResponse:
"""Update a member's role in an organization.
Permission rules:
- Admins can change roles of regular members to Admin or Member
- Admins cannot modify other Admins or Owners
- Owners can change roles of Admins and Members to any role (Owner, Admin, Member)
- Owners cannot modify other Owners
Members cannot modify their own role. The last owner cannot be demoted.
"""
try:
return await OrgMemberService.update_org_member(
org_id=org_id,
target_user_id=UUID(user_id),
current_user_id=UUID(current_user_id),
update_data=update_data,
)
except OrgMemberNotFoundError as e:
# Distinguish between requester not being a member vs target not found
if str(current_user_id) in str(e):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You are not a member of this organization',
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Member not found in this organization',
)
except CannotModifySelfError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='Cannot modify your own role',
)
except RoleNotFoundError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Role configuration error',
)
except InvalidRoleError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid role specified',
)
except InsufficientPermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='You do not have permission to modify this member',
)
except LastOwnerError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Cannot demote the last owner of an organization',
)
except MemberUpdateError:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)
except ValueError:
logger.exception('Invalid UUID format')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid organization or user ID format',
)
except Exception:
logger.exception('Error updating organization member')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update member',
)
+4 -4
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, HTTPException, status
from sqlalchemy.sql import text
from storage.database import session_maker
from storage.database import a_session_maker
from storage.redis import create_redis_client
from openhands.core.logger import openhands_logger as logger
@@ -9,11 +9,11 @@ readiness_router = APIRouter()
@readiness_router.get('/ready')
def is_ready():
async def is_ready():
# Check database connection
try:
with session_maker() as session:
session.execute(text('SELECT 1'))
async with a_session_maker() as session:
await session.execute(text('SELECT 1'))
except Exception as e:
logger.error(f'Database check failed: {str(e)}')
raise HTTPException(
+14 -3
View File
@@ -4,6 +4,8 @@ from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from storage.user_store import UserStore
from utils.identity import resolve_display_name
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
@@ -114,13 +116,23 @@ async def saas_get_user(
content='Failed to retrieve user_info.',
status_code=status.HTTP_401_UNAUTHORIZED,
)
# Prefer email from DB; fall back to Keycloak if not yet persisted
email = user_info.get('email') if user_info else None
sub = user_info.get('sub') if user_info else ''
if sub:
db_user = await UserStore.get_user_by_id_async(sub)
if db_user and db_user.email is not None:
email = db_user.email
retval = await _check_idp(
access_token=access_token,
default_value=User(
id=(user_info.get('sub') if user_info else '') or '',
id=sub,
login=(user_info.get('preferred_username') if user_info else '') or '',
avatar_url='',
email=user_info.get('email') if user_info else None,
email=email,
name=resolve_display_name(user_info) if user_info else None,
company=user_info.get('company') if user_info else None,
),
user_info=user_info,
)
@@ -376,5 +388,4 @@ async def _check_idp(
access_token.get_secret_value(), ProviderType(idp)
):
return default_value
return None
@@ -0,0 +1,115 @@
"""Routes for user app settings API.
Provides endpoints for managing user-level app preferences:
- GET /api/users/app - Retrieve current user's app settings
- POST /api/users/app - Update current user's app settings
"""
from fastapi import APIRouter, Depends, HTTPException, status
from server.routes.user_app_settings_models import (
UserAppSettingsResponse,
UserAppSettingsUpdate,
UserNotFoundError,
)
from server.services.user_app_settings_service import (
UserAppSettingsService,
UserAppSettingsServiceInjector,
)
from openhands.core.logger import openhands_logger as logger
user_app_settings_router = APIRouter(prefix='/api/users')
# Create injector instance and dependency at module level
_injector = UserAppSettingsServiceInjector()
user_app_settings_service_dependency = Depends(_injector.depends)
@user_app_settings_router.get('/app', response_model=UserAppSettingsResponse)
async def get_user_app_settings(
service: UserAppSettingsService = user_app_settings_service_dependency,
) -> UserAppSettingsResponse:
"""Get the current user's app settings.
Returns language, analytics consent, sound notifications, and git config.
Args:
service: UserAppSettingsService (injected by dependency)
Returns:
UserAppSettingsResponse: The user's app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 404 if user not found
HTTPException: 500 if retrieval fails
"""
try:
return await service.get_user_app_settings()
except ValueError as e:
# User not authenticated
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
except UserNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.exception(
'Unexpected error retrieving user app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to retrieve user app settings',
)
@user_app_settings_router.post('/app', response_model=UserAppSettingsResponse)
async def update_user_app_settings(
update_data: UserAppSettingsUpdate,
service: UserAppSettingsService = user_app_settings_service_dependency,
) -> UserAppSettingsResponse:
"""Update the current user's app settings (partial update).
Only provided fields will be updated. Pass null to clear a field.
Args:
update_data: Fields to update
service: UserAppSettingsService (injected by dependency)
Returns:
UserAppSettingsResponse: The updated user's app settings
Raises:
HTTPException: 401 if user is not authenticated
HTTPException: 404 if user not found
HTTPException: 500 if update fails
"""
try:
return await service.update_user_app_settings(update_data)
except ValueError as e:
# User not authenticated
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
except UserNotFoundError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
except Exception as e:
logger.exception(
'Failed to update user app settings',
extra={'error': str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to update user app settings',
)
@@ -0,0 +1,57 @@
"""
Pydantic models for user app settings API.
"""
from pydantic import BaseModel, EmailStr
from storage.user import User
class UserAppSettingsError(Exception):
"""Base exception for user app settings errors."""
pass
class UserNotFoundError(UserAppSettingsError):
"""Raised when user is not found."""
def __init__(self, user_id: str):
self.user_id = user_id
super().__init__(f'User with id "{user_id}" not found')
class UserAppSettingsUpdateError(UserAppSettingsError):
"""Raised when user app settings update fails."""
pass
class UserAppSettingsResponse(BaseModel):
"""Response model for user app settings."""
language: str | None = None
user_consents_to_analytics: bool | None = None
enable_sound_notifications: bool | None = None
git_user_name: str | None = None
git_user_email: EmailStr | None = None
@classmethod
def from_user(cls, user: User) -> 'UserAppSettingsResponse':
"""Create response from User entity."""
return cls(
language=user.language,
user_consents_to_analytics=user.user_consents_to_analytics,
enable_sound_notifications=user.enable_sound_notifications,
git_user_name=user.git_user_name,
git_user_email=user.git_user_email,
)
class UserAppSettingsUpdate(BaseModel):
"""Request model for updating user app settings (partial update)."""
language: str | None = None
user_consents_to_analytics: bool | None = None
enable_sound_notifications: bool | None = None
git_user_name: str | None = None
git_user_email: EmailStr | None = None
@@ -516,11 +516,13 @@ class SaasNestedConversationManager(ConversationManager):
)
raise
def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
async def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
api_key_store = ApiKeyStore.get_instance()
mcp_api_key = api_key_store.retrieve_mcp_api_key(user_id)
mcp_api_key = await api_key_store.retrieve_mcp_api_key(user_id)
if not mcp_api_key:
mcp_api_key = api_key_store.create_api_key(user_id, 'MCP_API_KEY', None)
mcp_api_key = await api_key_store.create_api_key(
user_id, 'MCP_API_KEY', None
)
if not mcp_api_key:
return None
web_host = os.environ.get('WEB_HOST', 'app.all-hands.dev')
@@ -547,7 +549,7 @@ class SaasNestedConversationManager(ConversationManager):
'conversation_id': sid,
}
mcp_config = self._get_mcp_config(user_id)
mcp_config = await self._get_mcp_config(user_id)
if mcp_config:
# Merge with any MCP config from settings
if settings.mcp_config:
@@ -1137,6 +1139,71 @@ class SaasNestedConversationManager(ConversationManager):
}
update_conversation_metadata(conversation_id, metadata_content)
async def list_files(self, sid: str, path: str | None = None) -> list[str]:
"""List files in the workspace for a conversation.
Delegates to the nested container's list-files endpoint.
Args:
sid: The session/conversation ID.
path: Optional path to list files from. If None, lists from workspace root.
Returns:
A list of file paths.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_list_files_from_nested(
sid, nested_url, session_api_key, path
)
async def select_file(self, sid: str, file: str) -> tuple[str | None, str | None]:
"""Read a file from the workspace via nested container.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_select_file_from_nested(
sid, nested_url, session_api_key, file
)
async def upload_files(
self, sid: str, files: list[tuple[str, bytes]]
) -> tuple[list[str], list[dict[str, str]]]:
"""Upload files to the workspace via nested container.
Raises:
ValueError: If the conversation is not running.
httpx.HTTPError: If there's an error communicating with the nested runtime.
"""
runtime = await self._get_runtime(sid)
if runtime is None or runtime.get('status') != 'running':
raise ValueError(f'Conversation {sid} is not running')
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
session_api_key = runtime.get('session_api_key')
return await self._fetch_upload_files_to_nested(
sid, nested_url, session_api_key, files
)
def _last_updated_at_key(conversation: ConversationMetadata) -> float:
last_updated_at = conversation.last_updated_at
+131
View File
@@ -0,0 +1,131 @@
"""Email service for sending transactional emails via Resend."""
import os
try:
import resend
RESEND_AVAILABLE = True
except ImportError:
RESEND_AVAILABLE = False
from openhands.core.logger import openhands_logger as logger
DEFAULT_FROM_EMAIL = 'OpenHands <no-reply@openhands.dev>'
DEFAULT_WEB_HOST = 'https://app.all-hands.dev'
class EmailService:
"""Service for sending transactional emails."""
@staticmethod
def _get_resend_client() -> bool:
"""Initialize and return the Resend client.
Returns:
bool: True if client is ready, False otherwise
"""
if not RESEND_AVAILABLE:
logger.warning('Resend library not installed, skipping email')
return False
resend_api_key = os.environ.get('RESEND_API_KEY')
if not resend_api_key:
logger.warning('RESEND_API_KEY not configured, skipping email')
return False
resend.api_key = resend_api_key
return True
@staticmethod
def send_invitation_email(
to_email: str,
org_name: str,
inviter_name: str,
role_name: str,
invitation_token: str,
invitation_id: int,
) -> None:
"""Send an organization invitation email.
Args:
to_email: Recipient's email address
org_name: Name of the organization
inviter_name: Display name of the person who sent the invite
role_name: Role being offered (e.g., 'member', 'admin')
invitation_token: The secure invitation token
invitation_id: The invitation ID for logging
"""
if not EmailService._get_resend_client():
return
# Build invitation URL
web_host = os.environ.get('WEB_HOST', DEFAULT_WEB_HOST)
invitation_url = f'{web_host}/api/organizations/members/invite/accept?token={invitation_token}'
from_email = os.environ.get('RESEND_FROM_EMAIL', DEFAULT_FROM_EMAIL)
params = {
'from': from_email,
'to': [to_email],
'subject': f"You're invited to join {org_name} on OpenHands",
'html': f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<p>Hi,</p>
<p><strong>{inviter_name}</strong> has invited you to join <strong>{org_name}</strong> on OpenHands as a <strong>{role_name}</strong>.</p>
<p>Click the button below to accept the invitation:</p>
<p style="margin: 30px 0;">
<a href="{invitation_url}"
style="background-color: #c9b974; color: #0D0F11; padding: 8px 16px;
text-decoration: none; border-radius: 8px; display: inline-block;
font-size: 14px; font-weight: 600;">
Accept Invitation
</a>
</p>
<p style="color: #666; font-size: 14px;">
Or copy and paste this link into your browser:<br>
<a href="{invitation_url}" style="color: #c9b974; font-weight: 600;">{invitation_url}</a>
</p>
<p style="color: #666; font-size: 14px;">
This invitation will expire in 7 days.
</p>
<p style="color: #666; font-size: 14px;">
If you weren't expecting this invitation, you can safely ignore this email.
</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="color: #999; font-size: 12px;">
Best,<br>
The OpenHands Team
</p>
</div>
""",
}
try:
response = resend.Emails.send(params)
logger.info(
'Invitation email sent',
extra={
'invitation_id': invitation_id,
'email': to_email,
'response_id': response.get('id') if response else None,
},
)
except Exception as e:
logger.error(
'Failed to send invitation email',
extra={
'invitation_id': invitation_id,
'email': to_email,
'error': str(e),
},
)
raise
@@ -0,0 +1,130 @@
"""Service class for managing organization app settings.
Separates business logic from route handlers.
Uses dependency injection for db_session and user_context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
from server.routes.org_models import (
OrgAppSettingsResponse,
OrgAppSettingsUpdate,
OrgNotFoundError,
)
from storage.org_app_settings_store import OrgAppSettingsStore
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.core.logger import openhands_logger as logger
@dataclass
class OrgAppSettingsService:
"""Service for organization app settings with injected dependencies."""
store: OrgAppSettingsStore
user_context: UserContext
async def get_org_app_settings(self) -> OrgAppSettingsResponse:
"""Get organization app settings.
User ID is obtained from the injected user_context.
Returns:
OrgAppSettingsResponse: The organization's app settings
Raises:
OrgNotFoundError: If current organization is not found
"""
user_id = await self.user_context.get_user_id()
logger.info(
'Getting organization app settings',
extra={'user_id': user_id},
)
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
return OrgAppSettingsResponse.from_org(org)
async def update_org_app_settings(
self,
update_data: OrgAppSettingsUpdate,
) -> OrgAppSettingsResponse:
"""Update organization app settings.
Only updates fields that are explicitly provided in update_data.
User ID is obtained from the injected user_context.
Session auto-commits at request end via DbSessionInjector.
Args:
update_data: The update data from the request
Returns:
OrgAppSettingsResponse: The updated organization's app settings
Raises:
OrgNotFoundError: If current organization is not found
"""
user_id = await self.user_context.get_user_id()
logger.info(
'Updating organization app settings',
extra={'user_id': user_id},
)
# Get current org first
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('current')
# Check if any fields are provided
update_dict = update_data.model_dump(exclude_unset=True)
if not update_dict:
# No fields to update, just return current settings
logger.info(
'No fields to update in app settings',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return OrgAppSettingsResponse.from_org(org)
updated_org = await self.store.update_org_app_settings(
org_id=org.id,
update_data=update_data,
)
if not updated_org:
raise OrgNotFoundError('current')
logger.info(
'Organization app settings updated successfully',
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
)
return OrgAppSettingsResponse.from_org(updated_org)
class OrgAppSettingsServiceInjector(Injector[OrgAppSettingsService]):
"""Injector that composes store and user_context for OrgAppSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[OrgAppSettingsService, None]:
# Local imports to avoid circular dependencies
from openhands.app_server.config import get_db_session, get_user_context
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
store = OrgAppSettingsStore(db_session=db_session)
yield OrgAppSettingsService(store=store, user_context=user_context)
@@ -0,0 +1,397 @@
"""Service for managing organization invitations."""
import asyncio
from uuid import UUID
from server.auth.token_manager import TokenManager
from server.constants import ROLE_ADMIN, ROLE_OWNER
from server.routes.org_invitation_models import (
EmailMismatchError,
InsufficientPermissionError,
InvitationExpiredError,
InvitationInvalidError,
UserAlreadyMemberError,
)
from server.services.email_service import EmailService
from storage.org_invitation import OrgInvitation
from storage.org_invitation_store import OrgInvitationStore
from storage.org_member_store import OrgMemberStore
from storage.org_service import OrgService
from storage.org_store import OrgStore
from storage.role_store import RoleStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
class OrgInvitationService:
"""Service for organization invitation operations."""
@staticmethod
async def create_invitation(
org_id: UUID,
email: str,
role_name: str,
inviter_id: UUID,
) -> OrgInvitation:
"""Create a new organization invitation.
This method:
1. Validates the organization exists
2. Validates this is not a personal workspace
3. Checks inviter has owner/admin role
4. Validates role assignment permissions
5. Checks if user is already a member
6. Creates the invitation
7. Sends the invitation email
Args:
org_id: Organization UUID
email: Invitee's email address
role_name: Role to assign on acceptance (owner, admin, member)
inviter_id: User ID of the person creating the invitation
Returns:
OrgInvitation: The created invitation
Raises:
ValueError: If organization or role not found
InsufficientPermissionError: If inviter lacks permission
UserAlreadyMemberError: If email is already a member
InvitationAlreadyExistsError: If pending invitation exists
"""
email = email.lower().strip()
logger.info(
'Creating organization invitation',
extra={
'org_id': str(org_id),
'email': email,
'role_name': role_name,
'inviter_id': str(inviter_id),
},
)
# Step 1: Validate organization exists
org = OrgStore.get_org_by_id(org_id)
if not org:
raise ValueError(f'Organization {org_id} not found')
# Step 2: Check this is not a personal workspace
# A personal workspace has org_id matching the user's id
if str(org_id) == str(inviter_id):
raise InsufficientPermissionError(
'Cannot invite users to a personal workspace'
)
# Step 3: Check inviter is a member and has permission
inviter_member = OrgMemberStore.get_org_member(org_id, inviter_id)
if not inviter_member:
raise InsufficientPermissionError(
'You are not a member of this organization'
)
inviter_role = RoleStore.get_role_by_id(inviter_member.role_id)
if not inviter_role or inviter_role.name not in [ROLE_OWNER, ROLE_ADMIN]:
raise InsufficientPermissionError('Only owners and admins can invite users')
# Step 4: Validate role assignment permissions
role_name_lower = role_name.lower()
if role_name_lower == ROLE_OWNER and inviter_role.name != ROLE_OWNER:
raise InsufficientPermissionError('Only owners can invite with owner role')
# Get the target role
target_role = RoleStore.get_role_by_name(role_name_lower)
if not target_role:
raise ValueError(f'Invalid role: {role_name}')
# Step 5: Check if user is already a member (by email)
existing_user = await UserStore.get_user_by_email_async(email)
if existing_user:
existing_member = OrgMemberStore.get_org_member(org_id, existing_user.id)
if existing_member:
raise UserAlreadyMemberError(
'User is already a member of this organization'
)
# Step 6: Create the invitation
invitation = await OrgInvitationStore.create_invitation(
org_id=org_id,
email=email,
role_id=target_role.id,
inviter_id=inviter_id,
)
# Step 7: Send invitation email
try:
# Get inviter info for the email
inviter_user = UserStore.get_user_by_id(str(inviter_member.user_id))
inviter_name = 'A team member'
if inviter_user and inviter_user.email:
inviter_name = inviter_user.email.split('@')[0]
EmailService.send_invitation_email(
to_email=email,
org_name=org.name,
inviter_name=inviter_name,
role_name=target_role.name,
invitation_token=invitation.token,
invitation_id=invitation.id,
)
except Exception as e:
logger.error(
'Failed to send invitation email',
extra={
'invitation_id': invitation.id,
'email': email,
'error': str(e),
},
)
# Don't fail the invitation creation if email fails
# The user can still access via direct link
return invitation
@staticmethod
async def create_invitations_batch(
org_id: UUID,
emails: list[str],
role_name: str,
inviter_id: UUID,
) -> tuple[list[OrgInvitation], list[tuple[str, str]]]:
"""Create multiple organization invitations concurrently.
Validates permissions once upfront, then creates invitations in parallel.
Args:
org_id: Organization UUID
emails: List of invitee email addresses
role_name: Role to assign on acceptance (owner, admin, member)
inviter_id: User ID of the person creating the invitations
Returns:
Tuple of (successful_invitations, failed_emails_with_errors)
Raises:
ValueError: If organization or role not found
InsufficientPermissionError: If inviter lacks permission
"""
logger.info(
'Creating batch organization invitations',
extra={
'org_id': str(org_id),
'email_count': len(emails),
'role_name': role_name,
'inviter_id': str(inviter_id),
},
)
# Step 1: Validate permissions upfront (shared for all emails)
org = OrgStore.get_org_by_id(org_id)
if not org:
raise ValueError(f'Organization {org_id} not found')
if str(org_id) == str(inviter_id):
raise InsufficientPermissionError(
'Cannot invite users to a personal workspace'
)
inviter_member = OrgMemberStore.get_org_member(org_id, inviter_id)
if not inviter_member:
raise InsufficientPermissionError(
'You are not a member of this organization'
)
inviter_role = RoleStore.get_role_by_id(inviter_member.role_id)
if not inviter_role or inviter_role.name not in [ROLE_OWNER, ROLE_ADMIN]:
raise InsufficientPermissionError('Only owners and admins can invite users')
role_name_lower = role_name.lower()
if role_name_lower == ROLE_OWNER and inviter_role.name != ROLE_OWNER:
raise InsufficientPermissionError('Only owners can invite with owner role')
target_role = RoleStore.get_role_by_name(role_name_lower)
if not target_role:
raise ValueError(f'Invalid role: {role_name}')
# Step 2: Create invitations concurrently
async def create_single(
email: str,
) -> tuple[str, OrgInvitation | None, str | None]:
"""Create single invitation, return (email, invitation, error)."""
try:
invitation = await OrgInvitationService.create_invitation(
org_id=org_id,
email=email,
role_name=role_name,
inviter_id=inviter_id,
)
return (email, invitation, None)
except (UserAlreadyMemberError, ValueError) as e:
return (email, None, str(e))
results = await asyncio.gather(*[create_single(email) for email in emails])
# Step 3: Separate successes and failures
successful: list[OrgInvitation] = []
failed: list[tuple[str, str]] = []
for email, invitation, error in results:
if invitation:
successful.append(invitation)
elif error:
failed.append((email, error))
logger.info(
'Batch invitation creation completed',
extra={
'org_id': str(org_id),
'successful': len(successful),
'failed': len(failed),
},
)
return successful, failed
@staticmethod
async def accept_invitation(token: str, user_id: UUID) -> OrgInvitation:
"""Accept an organization invitation.
This method:
1. Validates the token and invitation status
2. Checks expiration
3. Verifies user is not already a member
4. Creates LiteLLM integration
5. Adds user to the organization
6. Marks invitation as accepted
Args:
token: The invitation token
user_id: The user accepting the invitation
Returns:
OrgInvitation: The accepted invitation
Raises:
InvitationInvalidError: If token is invalid or invitation not pending
InvitationExpiredError: If invitation has expired
UserAlreadyMemberError: If user is already a member
"""
logger.info(
'Accepting organization invitation',
extra={
'token_prefix': token[:10] + '...' if len(token) > 10 else token,
'user_id': str(user_id),
},
)
# Step 1: Get and validate invitation
invitation = await OrgInvitationStore.get_invitation_by_token(token)
if not invitation:
raise InvitationInvalidError('Invalid invitation token')
if invitation.status != OrgInvitation.STATUS_PENDING:
if invitation.status == OrgInvitation.STATUS_ACCEPTED:
raise InvitationInvalidError('Invitation has already been accepted')
elif invitation.status == OrgInvitation.STATUS_REVOKED:
raise InvitationInvalidError('Invitation has been revoked')
else:
raise InvitationInvalidError('Invitation is no longer valid')
# Step 2: Check expiration
if OrgInvitationStore.is_token_expired(invitation):
await OrgInvitationStore.update_invitation_status(
invitation.id, OrgInvitation.STATUS_EXPIRED
)
raise InvitationExpiredError('Invitation has expired')
# Step 2.5: Verify user email matches invitation email
user = await UserStore.get_user_by_id_async(str(user_id))
if not user:
raise InvitationInvalidError('User not found')
user_email = user.email
# Fallback: fetch email from Keycloak if not in database (for existing users)
if not user_email:
token_manager = TokenManager()
user_info = await token_manager.get_user_info_from_user_id(str(user_id))
user_email = user_info.get('email') if user_info else None
if not user_email:
raise EmailMismatchError('Your account does not have an email address')
user_email = user_email.lower().strip()
invitation_email = invitation.email.lower().strip()
if user_email != invitation_email:
logger.warning(
'Email mismatch during invitation acceptance',
extra={
'user_id': str(user_id),
'user_email': user_email,
'invitation_email': invitation_email,
'invitation_id': invitation.id,
},
)
raise EmailMismatchError()
# Step 3: Check if user is already a member
existing_member = OrgMemberStore.get_org_member(invitation.org_id, user_id)
if existing_member:
raise UserAlreadyMemberError(
'You are already a member of this organization'
)
# Step 4: Create LiteLLM integration for the user in the new org
try:
settings = await OrgService.create_litellm_integration(
invitation.org_id, str(user_id)
)
except Exception as e:
logger.error(
'Failed to create LiteLLM integration for invitation acceptance',
extra={
'invitation_id': invitation.id,
'user_id': str(user_id),
'org_id': str(invitation.org_id),
'error': str(e),
},
)
raise InvitationInvalidError(
'Failed to set up organization access. Please try again.'
)
# Step 5: Add user to organization
from storage.org_member_store import OrgMemberStore as OMS
org_member_kwargs = OMS.get_kwargs_from_settings(settings)
# Don't override with org defaults - use invitation-specified role
org_member_kwargs.pop('llm_model', None)
org_member_kwargs.pop('llm_base_url', None)
OrgMemberStore.add_user_to_org(
org_id=invitation.org_id,
user_id=user_id,
role_id=invitation.role_id,
llm_api_key=settings.llm_api_key,
status='active',
)
# Step 6: Mark invitation as accepted
updated_invitation = await OrgInvitationStore.update_invitation_status(
invitation.id,
OrgInvitation.STATUS_ACCEPTED,
accepted_by_user_id=user_id,
)
logger.info(
'Organization invitation accepted',
extra={
'invitation_id': invitation.id,
'user_id': str(user_id),
'org_id': str(invitation.org_id),
'role_id': invitation.role_id,
},
)
return updated_invitation
@@ -0,0 +1,130 @@
"""Service class for managing organization LLM settings.
Separates business logic from route handlers.
Uses dependency injection for db_session and user_context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
from server.routes.org_models import (
OrgLLMSettingsResponse,
OrgLLMSettingsUpdate,
OrgNotFoundError,
)
from storage.org_llm_settings_store import OrgLLMSettingsStore
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.core.logger import openhands_logger as logger
@dataclass
class OrgLLMSettingsService:
"""Service for org LLM settings with injected dependencies."""
store: OrgLLMSettingsStore
user_context: UserContext
async def get_org_llm_settings(self) -> OrgLLMSettingsResponse:
"""Get LLM settings for user's current organization.
User ID is obtained from the injected user_context.
Returns:
OrgLLMSettingsResponse: The organization's LLM settings
Raises:
ValueError: If user is not authenticated
OrgNotFoundError: If current organization not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Getting organization LLM settings',
extra={'user_id': user_id},
)
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('No current organization')
return OrgLLMSettingsResponse.from_org(org)
async def update_org_llm_settings(
self,
update_data: OrgLLMSettingsUpdate,
) -> OrgLLMSettingsResponse:
"""Update LLM settings for user's current organization.
Only updates fields that are explicitly provided in update_data.
User ID is obtained from the injected user_context.
Session auto-commits at request end via DbSessionInjector.
Args:
update_data: The update data from the request
Returns:
OrgLLMSettingsResponse: The updated organization's LLM settings
Raises:
ValueError: If user is not authenticated
OrgNotFoundError: If current organization not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Updating organization LLM settings',
extra={'user_id': user_id},
)
# Check if any fields are provided
if not update_data.has_updates():
# No fields to update, just return current settings
return await self.get_org_llm_settings()
# Get user's current org first
org = await self.store.get_current_org_by_user_id(user_id)
if not org:
raise OrgNotFoundError('No current organization')
# Update the org LLM settings
updated_org = await self.store.update_org_llm_settings(
org_id=org.id,
update_data=update_data,
)
if not updated_org:
raise OrgNotFoundError(str(org.id))
logger.info(
'Organization LLM settings updated successfully',
extra={'user_id': user_id, 'org_id': str(org.id)},
)
return OrgLLMSettingsResponse.from_org(updated_org)
class OrgLLMSettingsServiceInjector(Injector[OrgLLMSettingsService]):
"""Injector that composes store and user_context for OrgLLMSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[OrgLLMSettingsService, None]:
# Local imports to avoid circular dependencies
from openhands.app_server.config import get_db_session, get_user_context
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
store = OrgLLMSettingsStore(db_session=db_session)
yield OrgLLMSettingsService(store=store, user_context=user_context)
@@ -0,0 +1,417 @@
"""Service for managing organization members."""
from uuid import UUID
from server.constants import ROLE_ADMIN, ROLE_OWNER
from server.routes.org_models import (
CannotModifySelfError,
InsufficientPermissionError,
InvalidRoleError,
LastOwnerError,
MemberUpdateError,
MeResponse,
OrgMemberNotFoundError,
OrgMemberPage,
OrgMemberResponse,
OrgMemberUpdate,
RoleNotFoundError,
)
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member_store import OrgMemberStore
from storage.role_store import RoleStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.utils.async_utils import call_sync_from_async
class OrgMemberService:
"""Service for organization member operations."""
@staticmethod
def get_me(org_id: UUID, user_id: UUID) -> MeResponse:
"""Get the current user's membership record for an organization.
Retrieves the authenticated user's role, status, email, and LLM override
fields (with masked API keys) within the specified organization.
Args:
org_id: Organization ID (UUID)
user_id: User ID (UUID)
Returns:
MeResponse: The user's membership data with masked API keys
Raises:
OrgMemberNotFoundError: If user is not a member of the organization
RoleNotFoundError: If the role associated with the member is not found
"""
# Look up the user's membership in this org
org_member = OrgMemberStore.get_org_member(org_id, user_id)
if org_member is None:
raise OrgMemberNotFoundError(str(org_id), str(user_id))
# Resolve role name from role_id
role = RoleStore.get_role_by_id(org_member.role_id)
if role is None:
raise RoleNotFoundError(org_member.role_id)
# Get user email
user = UserStore.get_user_by_id(str(user_id))
email = user.email if user and user.email else ''
return MeResponse.from_org_member(org_member, role, email)
@staticmethod
async def get_org_members(
org_id: UUID,
current_user_id: UUID,
page_id: str | None = None,
limit: int = 10,
email_filter: str | None = None,
) -> tuple[bool, str | None, OrgMemberPage | None]:
"""Get organization members with authorization check.
Args:
org_id: Organization UUID.
current_user_id: Requesting user's UUID.
page_id: Offset encoded as string (e.g., "0", "10", "20").
limit: Items per page (default 10).
email_filter: Optional case-insensitive partial email match.
Returns:
Tuple of (success, error_code, data). If success is True, error_code is None.
"""
# Verify current user is a member of the organization
requester_membership = OrgMemberStore.get_org_member(org_id, current_user_id)
if not requester_membership:
return False, 'not_a_member', None
# Parse page_id to get offset (page_id is offset encoded as string)
offset = 0
if page_id is not None:
try:
offset = int(page_id)
if offset < 0:
return False, 'invalid_page_id', None
except ValueError:
return False, 'invalid_page_id', None
# Call store to get paginated members
members, _ = await OrgMemberStore.get_org_members_paginated(
org_id=org_id,
offset=offset,
limit=limit,
email_filter=email_filter,
)
# Transform data to response format
items = []
for member in members:
# Access user and role relationships (eagerly loaded)
user = member.user
role = member.role
items.append(
OrgMemberResponse(
user_id=str(member.user_id),
email=user.email if user else None,
role_id=member.role_id,
role=role.name if role else '',
role_rank=role.rank if role else 0,
status=member.status,
)
)
# Calculate current page (1-indexed)
current_page = (offset // limit) + 1
return (
True,
None,
OrgMemberPage(
items=items,
current_page=current_page,
per_page=limit,
),
)
@staticmethod
async def get_org_members_count(
org_id: UUID,
current_user_id: UUID,
email_filter: str | None = None,
) -> int:
"""Get count of organization members with authorization check.
Args:
org_id: Organization UUID.
current_user_id: Requesting user's UUID.
email_filter: Optional case-insensitive partial email match.
Returns:
int: Count of organization members matching the filter.
Raises:
OrgMemberNotFoundError: If requesting user is not a member of the organization.
"""
# Verify current user is a member of the organization
requester_membership = OrgMemberStore.get_org_member(org_id, current_user_id)
if not requester_membership:
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
return await OrgMemberStore.get_org_members_count(
org_id=org_id,
email_filter=email_filter,
)
@staticmethod
async def remove_org_member(
org_id: UUID,
target_user_id: UUID,
current_user_id: UUID,
) -> tuple[bool, str | None]:
"""Remove a member from an organization.
Returns:
Tuple of (success, error_message). If success is True, error_message is None.
"""
def _remove_member():
# Get current user's membership in the org
requester_membership = OrgMemberStore.get_org_member(
org_id, current_user_id
)
if not requester_membership:
return False, 'not_a_member'
# Check if trying to remove self
if str(current_user_id) == str(target_user_id):
return False, 'cannot_remove_self'
# Get target user's membership
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
if not target_membership:
return False, 'member_not_found'
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
target_role = RoleStore.get_role_by_id(target_membership.role_id)
if not requester_role or not target_role:
return False, 'role_not_found'
# Check permission based on roles
if not OrgMemberService._can_remove_member(
requester_role.name, target_role.name
):
return False, 'insufficient_permission'
# Check if removing the last owner
if target_role.name == ROLE_OWNER:
if OrgMemberService._is_last_owner(org_id, target_user_id):
return False, 'cannot_remove_last_owner'
# Perform the removal
success = OrgMemberStore.remove_user_from_org(org_id, target_user_id)
if not success:
return False, 'removal_failed'
# Update user's current_org_id if it points to the org they were removed from
user = UserStore.get_user_by_id(str(target_user_id))
if user and user.current_org_id == org_id:
# Set current_org_id to personal workspace (org.id == user.id)
UserStore.update_current_org(str(target_user_id), target_user_id)
return True, None
success, error = await call_sync_from_async(_remove_member)
# If database removal succeeded, also remove from LiteLLM team
if success:
try:
await LiteLlmManager.remove_user_from_team(
str(target_user_id), str(org_id)
)
logger.info(
'Successfully removed user from LiteLLM team',
extra={
'user_id': str(target_user_id),
'org_id': str(org_id),
},
)
except Exception as e:
# Log but don't fail the operation - database removal already succeeded
# LiteLLM state will be eventually consistent
logger.warning(
'Failed to remove user from LiteLLM team',
extra={
'user_id': str(target_user_id),
'org_id': str(org_id),
'error': str(e),
},
)
return success, error
@staticmethod
async def update_org_member(
org_id: UUID,
target_user_id: UUID,
current_user_id: UUID,
update_data: OrgMemberUpdate,
) -> OrgMemberResponse:
"""Update a member's role in an organization.
Permission rules:
- Owners can modify anyone (including other owners), can set any role
- Admins can modify other admins and users
- Admins can only set admin or user roles (not owner)
Args:
org_id: Organization ID
target_user_id: User ID of the member to update
current_user_id: User ID of the requester
update_data: Update data containing fields to modify
Returns:
OrgMemberResponse: The updated member data
Raises:
OrgMemberNotFoundError: If requester or target is not a member
CannotModifySelfError: If trying to modify self
RoleNotFoundError: If role configuration is invalid
InvalidRoleError: If new_role_name is not a valid role
InsufficientPermissionError: If requester lacks permission
LastOwnerError: If trying to demote the last owner
MemberUpdateError: If update operation fails
"""
new_role_name = update_data.role
def _update_member():
# Get current user's membership in the org
requester_membership = OrgMemberStore.get_org_member(
org_id, current_user_id
)
if not requester_membership:
raise OrgMemberNotFoundError(str(org_id), str(current_user_id))
# Check if trying to modify self
if str(current_user_id) == str(target_user_id):
raise CannotModifySelfError('modify')
# Get target user's membership
target_membership = OrgMemberStore.get_org_member(org_id, target_user_id)
if not target_membership:
raise OrgMemberNotFoundError(str(org_id), str(target_user_id))
# Get roles
requester_role = RoleStore.get_role_by_id(requester_membership.role_id)
target_role = RoleStore.get_role_by_id(target_membership.role_id)
if not requester_role:
raise RoleNotFoundError(requester_membership.role_id)
if not target_role:
raise RoleNotFoundError(target_membership.role_id)
# If no role change requested, return current state
if new_role_name is None:
user = UserStore.get_user_by_id(str(target_user_id))
return OrgMemberResponse(
user_id=str(target_membership.user_id),
email=user.email if user else None,
role_id=target_membership.role_id,
role=target_role.name,
role_rank=target_role.rank,
status=target_membership.status,
)
# Validate new role exists
new_role = RoleStore.get_role_by_name(new_role_name.lower())
if not new_role:
raise InvalidRoleError(new_role_name)
# Check permission to modify target
if not OrgMemberService._can_update_member_role(
requester_role.name, target_role.name, new_role.name
):
raise InsufficientPermissionError(
'You do not have permission to modify this member'
)
# Check if demoting the last owner
if (
target_role.name == ROLE_OWNER
and new_role.name != ROLE_OWNER
and OrgMemberService._is_last_owner(org_id, target_user_id)
):
raise LastOwnerError('demote')
# Perform the update
updated_member = OrgMemberStore.update_user_role_in_org(
org_id, target_user_id, new_role.id
)
if not updated_member:
raise MemberUpdateError('Failed to update member')
# Get user email for response
user = UserStore.get_user_by_id(str(target_user_id))
return OrgMemberResponse(
user_id=str(updated_member.user_id),
email=user.email if user else None,
role_id=updated_member.role_id,
role=new_role.name,
role_rank=new_role.rank,
status=updated_member.status,
)
return await call_sync_from_async(_update_member)
@staticmethod
def _can_update_member_role(
requester_role_name: str, target_role_name: str, new_role_name: str
) -> bool:
"""Check if requester can change target's role to new_role.
Permission rules:
- Owners can modify anyone (including other owners), can set any role
- Admins can modify other admins and users
- Admins can only set admin or user roles (not owner)
"""
is_requester_owner = requester_role_name == ROLE_OWNER
is_requester_admin = requester_role_name == ROLE_ADMIN
is_target_owner = target_role_name == ROLE_OWNER
is_new_role_owner = new_role_name == ROLE_OWNER
if is_requester_owner:
# Owners can modify anyone (including other owners)
return True
elif is_requester_admin:
# Admins cannot modify owners
if is_target_owner:
return False
# Admins can only set admin or user roles (not owner)
return not is_new_role_owner
return False
@staticmethod
def _can_remove_member(requester_role_name: str, target_role_name: str) -> bool:
"""Check if requester can remove target based on roles."""
if requester_role_name == ROLE_OWNER:
return True
elif requester_role_name == ROLE_ADMIN:
# Admins can remove admins and members (not owners)
return target_role_name != ROLE_OWNER
return False
@staticmethod
def _is_last_owner(org_id: UUID, user_id: UUID) -> bool:
"""Check if user is the last owner of the organization."""
members = OrgMemberStore.get_org_members(org_id)
owners = []
for m in members:
# Use role_id (column) instead of role (relationship) to avoid DetachedInstanceError
role = RoleStore.get_role_by_id(m.role_id)
if role and role.name == ROLE_OWNER:
owners.append(m)
return len(owners) == 1 and str(owners[0].user_id) == str(user_id)
@@ -0,0 +1,126 @@
"""Service class for managing user app settings.
Separates business logic from route handlers.
Uses dependency injection for db_session and user_context.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import AsyncGenerator
from fastapi import Request
from server.routes.user_app_settings_models import (
UserAppSettingsResponse,
UserAppSettingsUpdate,
UserNotFoundError,
)
from storage.user_app_settings_store import UserAppSettingsStore
from openhands.app_server.services.injector import Injector, InjectorState
from openhands.app_server.user.user_context import UserContext
from openhands.core.logger import openhands_logger as logger
@dataclass
class UserAppSettingsService:
"""Service for user app settings with injected dependencies."""
store: UserAppSettingsStore
user_context: UserContext
async def get_user_app_settings(self) -> UserAppSettingsResponse:
"""Get user app settings.
User ID is obtained from the injected user_context.
Returns:
UserAppSettingsResponse: The user's app settings
Raises:
ValueError: If user is not authenticated
UserNotFoundError: If user is not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Getting user app settings',
extra={'user_id': user_id},
)
user = await self.store.get_user_by_id(user_id)
if not user:
raise UserNotFoundError(user_id)
return UserAppSettingsResponse.from_user(user)
async def update_user_app_settings(
self,
update_data: UserAppSettingsUpdate,
) -> UserAppSettingsResponse:
"""Update user app settings.
Only updates fields that are explicitly provided in update_data.
User ID is obtained from the injected user_context.
Session auto-commits at request end via DbSessionInjector.
Args:
update_data: The update data from the request
Returns:
UserAppSettingsResponse: The updated user's app settings
Raises:
ValueError: If user is not authenticated
UserNotFoundError: If user is not found
"""
user_id = await self.user_context.get_user_id()
if not user_id:
raise ValueError('User is not authenticated')
logger.info(
'Updating user app settings',
extra={'user_id': user_id},
)
# Check if any fields are provided
update_dict = update_data.model_dump(exclude_unset=True)
if not update_dict:
# No fields to update, just return current settings
return await self.get_user_app_settings()
user = await self.store.update_user_app_settings(
user_id=user_id,
update_data=update_data,
)
if not user:
raise UserNotFoundError(user_id)
logger.info(
'User app settings updated successfully',
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
)
return UserAppSettingsResponse.from_user(user)
class UserAppSettingsServiceInjector(Injector[UserAppSettingsService]):
"""Injector that composes store and user_context for UserAppSettingsService."""
async def inject(
self, state: InjectorState, request: Request | None = None
) -> AsyncGenerator[UserAppSettingsService, None]:
# Local imports to avoid circular dependencies
from openhands.app_server.config import get_db_session, get_user_context
async with (
get_user_context(state, request) as user_context,
get_db_session(state, request) as db_session,
):
store = UserAppSettingsStore(db_session=db_session)
yield UserAppSettingsService(store=store, user_context=user_context)
@@ -4,13 +4,14 @@ import pickle
from datetime import datetime
from server.logger import logger
from sqlalchemy import and_, select
from storage.conversation_callback import (
CallbackStatus,
ConversationCallback,
ConversationCallbackProcessor,
)
from storage.conversation_work import ConversationWork
from storage.database import session_maker
from storage.database import a_session_maker, session_maker
from storage.stored_conversation_metadata import StoredConversationMetadata
from openhands.core.config import load_openhands_config
@@ -79,15 +80,16 @@ async def invoke_conversation_callbacks(
conversation_id: The conversation ID to process callbacks for
observation: The AgentStateChangedObservation that triggered the callback
"""
with session_maker() as session:
callbacks = (
session.query(ConversationCallback)
.filter(
ConversationCallback.conversation_id == conversation_id,
ConversationCallback.status == CallbackStatus.ACTIVE,
async with a_session_maker() as session:
result = await session.execute(
select(ConversationCallback).filter(
and_(
ConversationCallback.conversation_id == conversation_id,
ConversationCallback.status == CallbackStatus.ACTIVE,
)
)
.all()
)
callbacks = result.scalars().all()
for callback in callbacks:
try:
@@ -115,7 +117,7 @@ async def invoke_conversation_callbacks(
callback.status = CallbackStatus.ERROR
callback.updated_at = datetime.now()
session.commit()
await session.commit()
def update_conversation_metadata(conversation_id: str, content: dict):
@@ -22,11 +22,70 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
SQLAppConversationInfoService,
)
from openhands.app_server.errors import AuthError
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import ADMIN
class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
"""Extended SQLAppConversationInfoService with user-based filtering and SAAS metadata handling."""
"""Extended SQLAppConversationInfoService with user and organization-based filtering and SAAS metadata handling."""
async def _get_current_user(self) -> User | None:
"""Get the current user using the existing db_session.
Uses self.db_session to avoid opening a separate database session.
Returns:
User object or None if no user_id is available
"""
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
return None
user_id_uuid = UUID(user_id_str)
result = await self.db_session.execute(
select(User).where(User.id == user_id_uuid)
)
return result.scalars().first()
async def _apply_user_and_org_filter(self, query):
"""Apply user_id and org_id filters to ensure conversation isolation.
Filters conversations by:
- user_id: Only show conversations belonging to the current user
- org_id: Only show conversations belonging to the user's current organization
Args:
query: SQLAlchemy query to apply filters to
Returns:
Query with user and organization filters applied
Raises:
AuthError: If no user_id is available (secure default: deny access)
"""
# For internal operations such as getting a conversation by session_api_key
# we need a mode that does not have filtering. The dependency `as_admin()`
# is used to enable it
if self.user_context == ADMIN:
return query
user_id_str = await self.user_context.get_user_id()
if not user_id_str:
# Secure default: no user means no access, not "show everything"
raise AuthError('User authentication required')
user_id_uuid = UUID(user_id_str)
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
# Filter by organization ID to ensure conversations are isolated per organization
user = await self._get_current_user()
if user and user.current_org_id is not None:
query = query.where(
StoredConversationMetadataSaas.org_id == user.current_org_id
)
return query
async def _secure_select(self):
query = (
@@ -38,13 +97,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
)
.where(StoredConversationMetadata.conversation_version == 'V1')
)
user_id_str = await self.user_context.get_user_id()
if user_id_str:
user_id_uuid = UUID(user_id_str)
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
return query
return await self._apply_user_and_org_filter(query)
async def _secure_select_with_saas_metadata(self):
"""Select query that includes SAAS metadata for retrieving user_id."""
@@ -57,13 +110,7 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
)
.where(StoredConversationMetadata.conversation_version == 'V1')
)
user_id_str = await self.user_context.get_user_id()
if user_id_str:
user_id_uuid = UUID(user_id_str)
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
return query
return await self._apply_user_and_org_filter(query)
async def search_app_conversation_info(
self,
@@ -155,21 +202,16 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
"""Count conversations matching the given filters with SAAS metadata."""
query = (
select(func.count(StoredConversationMetadata.conversation_id))
.select_from(
StoredConversationMetadata.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.where(StoredConversationMetadata.conversation_version == 'V1')
)
# Apply user filtering
user_id_str = await self.user_context.get_user_id()
if user_id_str:
user_id_uuid = UUID(user_id_str)
query = query.where(StoredConversationMetadataSaas.user_id == user_id_uuid)
# Apply user and organization filtering
query = await self._apply_user_and_org_filter(query)
query = self._apply_filters_with_saas_metadata(
query=query,
@@ -233,7 +275,13 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
result = result_set.first()
if result:
stored_metadata, saas_metadata = result
return self._to_info_with_user_id(stored_metadata, saas_metadata)
# Fetch sub-conversation IDs
sub_conversation_ids = await self.get_sub_conversation_ids(conversation_id)
return self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
return None
async def batch_get_app_conversation_info(
@@ -262,8 +310,16 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
for conversation_id in conversation_id_strs:
if conversation_id in info_by_id:
stored_metadata, saas_metadata = info_by_id[conversation_id]
# Fetch sub-conversation IDs for each conversation
sub_conversation_ids = await self.get_sub_conversation_ids(
UUID(conversation_id)
)
results.append(
self._to_info_with_user_id(stored_metadata, saas_metadata)
self._to_info_with_user_id(
stored_metadata,
saas_metadata,
sub_conversation_ids=sub_conversation_ids,
)
)
else:
results.append(None)
@@ -316,10 +372,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
self,
stored: StoredConversationMetadata,
saas_metadata: StoredConversationMetadataSaas,
sub_conversation_ids: list[UUID] | None = None,
) -> AppConversationInfo:
"""Convert stored metadata to AppConversationInfo with user_id from SAAS metadata."""
# Use the base _to_info method to get the basic info
info = self._to_info(stored)
info = self._to_info(stored, sub_conversation_ids=sub_conversation_ids)
# Override the created_by_user_id with the user_id from SAAS metadata
info.created_by_user_id = (
@@ -0,0 +1,33 @@
from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, StringConstraints
class VerifiedModelCreate(BaseModel):
model_name: Annotated[
str,
StringConstraints(max_length=255),
]
provider: Annotated[
str,
StringConstraints(max_length=100),
]
is_enabled: bool = True
class VerifiedModel(VerifiedModelCreate):
id: int
created_at: datetime
updated_at: datetime
class VerifiedModelUpdate(BaseModel):
is_enabled: bool | None = None
class VerifiedModelPage(BaseModel):
"""Paginated response model for verified model list."""
items: list[VerifiedModel]
next_page_id: str | None = None
@@ -0,0 +1,143 @@
"""API routes for managing verified LLM models (admin only)."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from server.email_validation import get_admin_user_id
from server.verified_models.verified_model_models import (
VerifiedModel,
VerifiedModelCreate,
VerifiedModelPage,
VerifiedModelUpdate,
)
from server.verified_models.verified_model_service import (
VerifiedModelService,
verified_model_store_dependency,
)
from openhands.app_server.config import get_db_session
from openhands.server.routes import public
from openhands.utils.llm import get_supported_llm_models
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
@api_router.get('')
async def search_verified_models(
provider: str | None = None,
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,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> VerifiedModelPage:
"""List all verified models, optionally filtered by provider."""
# Use SQL-level filtering and pagination
result = await verified_model_service.search_verified_models(
provider=provider,
enabled_only=False, # Admin sees all models including disabled
page_id=page_id,
limit=limit,
)
return result
@api_router.post('', status_code=201)
async def create_verified_model(
data: VerifiedModelCreate,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> VerifiedModel:
"""Create a new verified model."""
try:
model = await verified_model_service.create_verified_model(
model_name=data.model_name,
provider=data.provider,
is_enabled=data.is_enabled,
)
return model
except ValueError as ex:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(ex),
)
@api_router.put('/{provider}/{model_name:path}')
async def update_verified_model(
provider: str,
model_name: str,
data: VerifiedModelUpdate,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> VerifiedModel:
"""Update a verified model by provider and model name."""
model = await verified_model_service.update_verified_model(
model_name=model_name,
provider=provider,
is_enabled=data.is_enabled,
)
if not model:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'Model {provider}/{model_name} not found',
)
return model
@api_router.delete('/{provider}/{model_name:path}')
async def delete_verified_model(
provider: str,
model_name: str,
user_id: str = Depends(get_admin_user_id),
verified_model_service: VerifiedModelService = Depends(
verified_model_store_dependency
),
) -> bool:
"""Delete a verified model by provider and model name."""
try:
await verified_model_service.delete_verified_model(
model_name=model_name, provider=provider
)
return True
except ValueError as ex:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(ex),
)
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
"""SaaS implementation for the LLM models endpoint."""
async with get_db_session(request.state, request) as db_session:
# Prevent circular import
from openhands.server.shared import config
verified_model_service = VerifiedModelService(db_session)
page = await verified_model_service.search_verified_models(enabled_only=True)
if page.next_page_id:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Too many models defined in database',
)
verified_models = [f'{m.provider}/{m.model_name}' for m in page.items]
return get_supported_llm_models(config, verified_models)
# Override the default implementation with SaaS implementation
# This must be called after the app is created in saas_server.py
def override_llm_models_dependency(app):
"""Override the default LLM models implementation with SaaS version."""
app.dependency_overrides[public.get_llm_models_dependency] = (
get_saas_llm_models_dependency
)
@@ -0,0 +1,242 @@
"""Store for managing verified LLM models in the database."""
from dataclasses import dataclass
from server.verified_models.verified_model_models import (
VerifiedModel,
VerifiedModelPage,
)
from sqlalchemy import (
Boolean,
Column,
DateTime,
Identity,
Integer,
String,
UniqueConstraint,
and_,
func,
select,
text,
)
from sqlalchemy.ext.asyncio import AsyncSession
from storage.base import Base
from openhands.app_server.config import depends_db_session
from openhands.core.logger import openhands_logger as logger
class StoredVerifiedModel(Base): # type: ignore
"""A verified LLM model available in the model selector.
The composite unique constraint on (model_name, provider) allows the same
model name to exist under different providers (e.g. 'claude-sonnet' under
both 'openhands' and 'anthropic').
"""
__tablename__ = 'verified_models'
__table_args__ = (
UniqueConstraint('model_name', 'provider', name='uq_verified_model_provider'),
)
id = Column(Integer, Identity(), primary_key=True)
model_name = Column(String(255), nullable=False)
provider = Column(String(100), nullable=False, index=True)
is_enabled = Column(
Boolean, nullable=False, default=True, server_default=text('true')
)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
)
def verified_model(result: StoredVerifiedModel) -> VerifiedModel:
return VerifiedModel(
id=result.id,
model_name=result.model_name,
provider=result.provider,
is_enabled=result.is_enabled,
created_at=result.created_at,
updated_at=result.updated_at,
)
@dataclass
class VerifiedModelService:
"""Store for CRUD operations on verified models.
Follows the async pattern with db_session as an attribute.
"""
db_session: AsyncSession
async def search_verified_models(
self,
provider: str | None = None,
enabled_only: bool = True,
page_id: str | None = None,
limit: int = 100,
) -> VerifiedModelPage:
"""Search for verified models with optional filtering and pagination.
Args:
provider: Optional provider name to filter by (e.g., 'openhands', 'anthropic')
enabled_only: If True, only return enabled models (default: True)
page_id: Page id for pagination
limit: Maximum number of records to return
Returns:
SearchModelsResult containing items list and has_more flag
"""
query = select(StoredVerifiedModel)
# Build filters
filters = []
if provider:
filters.append(StoredVerifiedModel.provider == provider)
if enabled_only:
filters.append(StoredVerifiedModel.is_enabled.is_(True))
if filters:
query = query.where(and_(*filters))
# Order by provider, then model_name
query = query.order_by(
StoredVerifiedModel.provider, StoredVerifiedModel.model_name
)
# Fetch limit + 1 to check if there are more results
offset = int(page_id or '0')
query = query.offset(offset).limit(limit + 1)
result = await self.db_session.execute(query)
results = list(result.scalars().all())
has_more = len(results) > limit
next_page_id = None
# Return only the requested number of results
if has_more:
next_page_id = str(offset + limit)
results.pop()
items = [verified_model(result) for result in results]
return VerifiedModelPage(items=items, next_page_id=next_page_id)
async def get_model(self, model_name: str, provider: str) -> VerifiedModel | None:
"""Get a model by its composite key (model_name, provider).
Args:
model_name: The model identifier
provider: The provider name
"""
query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(query)
return result.scalars().first()
async def create_verified_model(
self,
model_name: str,
provider: str,
is_enabled: bool = True,
) -> VerifiedModel:
"""Create a new verified model.
Args:
model_name: The model identifier
provider: The provider name
is_enabled: Whether the model is enabled (default True)
Raises:
ValueError: If a model with the same (model_name, provider) already exists
"""
existing_query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(existing_query)
existing = result.scalars().first()
if existing:
raise ValueError(f'Model {provider}/{model_name} already exists')
model = StoredVerifiedModel(
model_name=model_name,
provider=provider,
is_enabled=is_enabled,
)
self.db_session.add(model)
await self.db_session.commit()
await self.db_session.refresh(model)
logger.info(f'Created verified model: {provider}/{model_name}')
return verified_model(model)
async def update_verified_model(
self,
model_name: str,
provider: str,
is_enabled: bool | None = None,
) -> VerifiedModel | None:
"""Update an existing verified model.
Args:
model_name: The model name to update
provider: The provider name
is_enabled: New enabled state (optional)
Returns:
The updated model if found, None otherwise
"""
query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(query)
model = result.scalars().first()
if not model:
return None
if is_enabled is not None:
model.is_enabled = is_enabled
await self.db_session.commit()
await self.db_session.refresh(model)
logger.info(f'Updated verified model: {provider}/{model_name}')
return verified_model(model)
async def delete_verified_model(self, model_name: str, provider: str):
"""Delete a verified model.
Args:
model_name: The model name to delete
provider: The provider name
Returns:
True if deleted, False if not found
"""
query = select(StoredVerifiedModel).where(
and_(
StoredVerifiedModel.model_name == model_name,
StoredVerifiedModel.provider == provider,
)
)
result = await self.db_session.execute(query)
model = result.scalars().first()
if not model:
raise ValueError('Unknown model')
await self.db_session.delete(model)
await self.db_session.commit()
logger.info(f'Deleted verified model: {provider}/{model_name}')
def verified_model_store_dependency(db_session: AsyncSession = depends_db_session()):
return VerifiedModelService(db_session)
+4
View File
@@ -20,8 +20,10 @@ from storage.linear_workspace import LinearWorkspace
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.openhands_pr import OpenhandsPR
from storage.org import Org
from storage.org_invitation import OrgInvitation
from storage.org_member import OrgMember
from storage.proactive_convos import ProactiveConversation
from storage.resend_synced_user import ResendSyncedUser
from storage.role import Role
from storage.slack_conversation import SlackConversation
from storage.slack_team import SlackTeam
@@ -65,8 +67,10 @@ __all__ = [
'MaintenanceTaskStatus',
'OpenhandsPR',
'Org',
'OrgInvitation',
'OrgMember',
'ProactiveConversation',
'ResendSyncedUser',
'Role',
'SlackConversation',
'SlackTeam',

Some files were not shown because too many files have changed in this diff Show More