mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
16 Commits
test-8core
...
feat/bidir
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d895197b36 | ||
|
|
42109ac692 | ||
|
|
8ce3089a68 | ||
|
|
b9b10ebf5e | ||
|
|
ce6d5b77c4 | ||
|
|
a458c9b785 | ||
|
|
a65ddc3db6 | ||
|
|
732a1c1991 | ||
|
|
d058323a87 | ||
|
|
7d04cffe4e | ||
|
|
6ad27b77bb | ||
|
|
2739fc8fbe | ||
|
|
38b7e10252 | ||
|
|
7b7d1c0c55 | ||
|
|
e38eda4ac9 | ||
|
|
99c19b6ef0 |
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
@@ -1,8 +1,7 @@
|
||||
# CODEOWNERS file for OpenHands repository
|
||||
# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||
|
||||
/frontend/ @amanape @hieptl
|
||||
/openhands-ui/ @amanape @hieptl
|
||||
/frontend/ @hieptl
|
||||
/openhands-ui/ @hieptl
|
||||
/openhands/ @tofarr @malhotra5 @hieptl
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
/enterprise/ @chuckbutkus @tofarr @malhotra5 @jlav @aivong-openhands
|
||||
|
||||
152
.github/workflows/pr-artifacts.yml
vendored
152
.github/workflows/pr-artifacts.yml
vendored
@@ -1,30 +1,136 @@
|
||||
---
|
||||
name: PR Artifacts
|
||||
|
||||
run-name: PR Artifacts Smoke Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: [main]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
smoke-test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest-8core
|
||||
# Auto-remove .pr/ directory when a reviewer approves
|
||||
cleanup-on-approval:
|
||||
concurrency:
|
||||
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if fork PR
|
||||
id: check-fork
|
||||
run: |
|
||||
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
|
||||
echo "is_fork=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
|
||||
else
|
||||
echo "is_fork=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
steps:
|
||||
- name: Show runner details
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "runner_name=${RUNNER_NAME}"
|
||||
echo "runner_os=${RUNNER_OS}"
|
||||
echo "runner_arch=${RUNNER_ARCH}"
|
||||
uname -a
|
||||
nproc
|
||||
free -h || true
|
||||
df -h
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
|
||||
- name: Simple shell check
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "smoke test start"
|
||||
sleep 10
|
||||
echo "smoke test done"
|
||||
- name: Remove .pr/ directory
|
||||
id: remove
|
||||
if: steps.check-fork.outputs.is_fork == 'false'
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
git config user.name "allhands-bot"
|
||||
git config user.email "allhands-bot@users.noreply.github.com"
|
||||
git rm -rf .pr/
|
||||
git commit -m "chore: Remove PR-only artifacts [automated]"
|
||||
git push || {
|
||||
echo "::error::Failed to push cleanup commit. Check branch protection rules."
|
||||
exit 1
|
||||
}
|
||||
echo "removed=true" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Removed .pr/ directory"
|
||||
else
|
||||
echo "removed=false" >> $GITHUB_OUTPUT
|
||||
echo "::notice::No .pr/ directory to remove"
|
||||
fi
|
||||
|
||||
- name: Update PR comment after cleanup
|
||||
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
✅ **PR Artifacts Cleaned Up**
|
||||
|
||||
The \`.pr/\` directory has been automatically removed.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
# Warn if .pr/ directory exists (will be auto-removed on approval)
|
||||
check-pr-artifacts:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Check for .pr/ directory
|
||||
id: check
|
||||
run: |
|
||||
if [ -d ".pr" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.check.outputs.exists == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- pr-artifacts-notice -->';
|
||||
const body = `${marker}
|
||||
📁 **PR Artifacts Notice**
|
||||
|
||||
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
|
||||
|
||||
> For fork PRs: Manual removal is required before merging.
|
||||
`;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (!existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body,
|
||||
});
|
||||
}
|
||||
|
||||
15
README.md
15
README.md
@@ -86,8 +86,19 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
|
||||
|
||||
<hr>
|
||||
|
||||
### Thank You to Our Contributors
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/OpenHands/OpenHands/graphs/contributors)
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
### Trusted by Engineers at
|
||||
|
||||
<div align="center">
|
||||
<strong>Trusted by engineers at</strong>
|
||||
<br/><br/>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://assets.openhands.dev/logos/external/white/tiktok.svg">
|
||||
@@ -138,3 +149,5 @@ If you need help with anything, or just want to chat, [come find us on Slack](ht
|
||||
<img src="https://assets.openhands.dev/logos/external/black/google.svg" alt="Google" height="17" hspace="5">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ from integrations.github.github_types import (
|
||||
)
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
@@ -26,6 +27,7 @@ from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.org_store import OrgStore
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
@@ -41,16 +43,14 @@ from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.services.conversation_service import start_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
@@ -154,12 +154,17 @@ class GithubIssue(ResolverViewInterface):
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# FIXME: Handle if initialize_conversation returns None
|
||||
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='github',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
@@ -173,16 +178,28 @@ class GithubIssue(ResolverViewInterface):
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITHUB,
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
@@ -294,7 +311,10 @@ class GithubIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitHub user context for the V1 system
|
||||
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
github_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -322,7 +342,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
'full_repo_name': self.full_repo_name,
|
||||
'installation_id': self.installation_id,
|
||||
},
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
@@ -476,7 +496,7 @@ class GithubInlinePRComment(GithubPRComment):
|
||||
'comment_id': self.comment_id,
|
||||
},
|
||||
inline_pr_comment=True,
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_V1_GITLAB_RESOLVER,
|
||||
@@ -14,6 +15,7 @@ from integrations.utils import (
|
||||
from jinja2 import Environment
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
@@ -29,15 +31,13 @@ from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
initialize_conversation,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.services.conversation_service import start_conversation
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
CONFIDENTIAL_NOTE = 'confidential_note'
|
||||
@@ -118,6 +118,14 @@ class GitlabIssue(ResolverViewInterface):
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# v1_enabled is already set at construction time in the factory method
|
||||
# This is the source of truth for the conversation type
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='gitlab',
|
||||
full_repo_name=self.full_repo_name,
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversation metadata
|
||||
# Don't save to conversation store
|
||||
@@ -128,16 +136,28 @@ class GitlabIssue(ResolverViewInterface):
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
conversation_metadata: ConversationMetadata = await initialize_conversation( # type: ignore[assignment]
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
conversation_id=None,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
conversation_trigger=ConversationTrigger.RESOLVER,
|
||||
git_provider=ProviderType.GITLAB,
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
self.conversation_id = conversation_metadata.conversation_id
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
@@ -228,7 +248,10 @@ class GitlabIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
# Set up the GitLab user context for the V1 system
|
||||
gitlab_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
|
||||
gitlab_user_context = ResolverUserContext(
|
||||
saas_user_auth=saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, gitlab_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
@@ -260,7 +283,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
'is_mr': self.is_mr,
|
||||
'discussion_id': getattr(self, 'discussion_id', None),
|
||||
},
|
||||
send_summary_instruction=self.send_summary_instruction,
|
||||
should_request_summary=self.send_summary_instruction,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
@@ -12,8 +14,10 @@ class ResolverUserContext(UserContext):
|
||||
def __init__(
|
||||
self,
|
||||
saas_user_auth: UserAuth,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.saas_user_auth = saas_user_auth
|
||||
self.resolver_org_id = resolver_org_id
|
||||
self._provider_handler: ProviderHandler | None = None
|
||||
|
||||
async def get_user_id(self) -> str | None:
|
||||
|
||||
68
enterprise/integrations/resolver_org_router.py
Normal file
68
enterprise/integrations/resolver_org_router.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Resolve which OpenHands organization workspace a resolver conversation should be created in.
|
||||
|
||||
This module provides a reusable utility for routing resolver conversations
|
||||
(GitHub, GitLab, Bitbucket, Slack, etc.) to the correct OpenHands organization
|
||||
workspace based on claimed Git organizations.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from storage.org_git_claim_store import OrgGitClaimStore
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
async def resolve_org_for_repo(
|
||||
provider: str,
|
||||
full_repo_name: str,
|
||||
keycloak_user_id: str,
|
||||
) -> UUID | None:
|
||||
"""Determine the OpenHands org_id for a resolver conversation.
|
||||
|
||||
If the repo's git organization is claimed by an OpenHands org AND the user
|
||||
is a member of that org, returns the claiming org's ID. Otherwise returns
|
||||
None (caller should fall back to user.current_org_id / personal workspace).
|
||||
|
||||
Args:
|
||||
provider: Git provider name ("github", "gitlab", "bitbucket")
|
||||
full_repo_name: Full repository name (e.g., "OpenHands/foo")
|
||||
keycloak_user_id: The user's Keycloak UUID string
|
||||
|
||||
Returns:
|
||||
The org_id if the repo's org is claimed and user is a member, else None
|
||||
"""
|
||||
git_org = full_repo_name.split('/')[0].lower()
|
||||
|
||||
try:
|
||||
claim = await OrgGitClaimStore.get_claim_by_provider_and_git_org(
|
||||
provider, git_org
|
||||
)
|
||||
if not claim:
|
||||
logger.debug(
|
||||
f'[OrgResolver] No claim found for {provider}/{git_org}',
|
||||
)
|
||||
return None
|
||||
|
||||
member = await OrgMemberStore.get_org_member(
|
||||
claim.org_id, UUID(keycloak_user_id)
|
||||
)
|
||||
if not member:
|
||||
logger.debug(
|
||||
f'[OrgResolver] User {keycloak_user_id} is not a member of org '
|
||||
f'{claim.org_id} (claimed {provider}/{git_org}). '
|
||||
f'Falling back to personal workspace.',
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f'[OrgResolver] Routing conversation to org {claim.org_id} '
|
||||
f'for {provider}/{git_org} (user {keycloak_user_id})',
|
||||
)
|
||||
return claim.org_id
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[OrgResolver] Error resolving org for {provider}/{git_org}: {e}',
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
@@ -17,7 +18,9 @@ from integrations.utils import (
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from server.config import get_config
|
||||
from slack_sdk import WebClient
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
@@ -36,18 +39,20 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
start_conversation,
|
||||
)
|
||||
from openhands.server.shared import ConversationStoreImpl, config, conversation_manager
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
# =================================================
|
||||
# SECTION: Slack view types
|
||||
@@ -202,6 +207,22 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
user_secrets = await self.saas_user_auth.get_secrets()
|
||||
|
||||
# Determine git provider from repository (needed for both org routing and conversation creation)
|
||||
self._resolved_git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
self._resolved_git_provider = repository.git_provider
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = None
|
||||
if self._resolved_git_provider and self.selected_repo:
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider=self._resolved_git_provider.value,
|
||||
full_repo_name=self.selected_repo,
|
||||
keycloak_user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
)
|
||||
|
||||
# Check if V1 conversations are enabled for this user
|
||||
self.v1_enabled = await is_v1_enabled_for_slack_resolver(
|
||||
self.slack_to_openhands_user.keycloak_user_id
|
||||
@@ -224,30 +245,44 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
jinja
|
||||
)
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = repository.git_provider
|
||||
user_id = self.slack_to_openhands_user.keycloak_user_id
|
||||
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.SLACK,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=user_id,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
git_provider=self._resolved_git_provider,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
await start_conversation(
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
initial_user_msg=user_instructions,
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_id=conversation_id,
|
||||
conversation_metadata=conversation_metadata,
|
||||
conversation_instructions=(
|
||||
conversation_instructions if conversation_instructions else None
|
||||
),
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.SLACK,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
git_provider=git_provider,
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
self.conversation_id = conversation_id
|
||||
logger.info(f'[Slack]: Created V0 conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo(v1_enabled=False)
|
||||
|
||||
@@ -265,13 +300,8 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
# Create the Slack V1 callback processor
|
||||
slack_callback_processor = self._create_slack_v1_callback_processor()
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = ProviderType(repository.git_provider.value)
|
||||
# Use git provider resolved in create_or_update_conversation
|
||||
git_provider = self._resolved_git_provider
|
||||
|
||||
# Get the app conversation service and start the conversation
|
||||
injector_state = InjectorState()
|
||||
@@ -292,7 +322,10 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
# Set up the Slack user context for the V1 system
|
||||
slack_user_context = ResolverUserContext(saas_user_auth=self.saas_user_auth)
|
||||
slack_user_context = ResolverUserContext(
|
||||
saas_user_auth=self.saas_user_auth,
|
||||
resolver_org_id=self.resolved_org_id,
|
||||
)
|
||||
setattr(injector_state, USER_CONTEXT_ATTR, slack_user_context)
|
||||
|
||||
async with get_app_conversation_service(
|
||||
|
||||
4
enterprise/poetry.lock
generated
4
enterprise/poetry.lock
generated
@@ -6486,7 +6486,7 @@ files = []
|
||||
develop = true
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.13.3"
|
||||
aiohttp = ">=3.13.5"
|
||||
anthropic = {version = "*", extras = ["vertex"]}
|
||||
anyio = "4.9"
|
||||
asyncpg = ">=0.30"
|
||||
@@ -6554,7 +6554,7 @@ pyyaml = ">=6.0.2"
|
||||
qtconsole = ">=5.6.1"
|
||||
rapidfuzz = ">=3.9"
|
||||
redis = ">=5.2,<7"
|
||||
requests = ">=2.32.5"
|
||||
requests = ">=2.33.0"
|
||||
setuptools = ">=78.1.1"
|
||||
shellingham = ">=1.5.4"
|
||||
sqlalchemy = {version = ">=2.0.40", extras = ["asyncio"]}
|
||||
|
||||
@@ -287,28 +287,7 @@ def get_api_key_from_header(request: Request):
|
||||
return session_api_key
|
||||
|
||||
# Fallback to X-Access-Token header as an additional option
|
||||
access_token = request.headers.get('X-Access-Token')
|
||||
if access_token:
|
||||
return access_token
|
||||
|
||||
# DEPRECATED: Accept api_key from query parameters for backward compatibility.
|
||||
# Passing API keys as query parameters is a security risk because they are
|
||||
# logged in proxy access logs (e.g. Traefik) and application logs.
|
||||
# Callers should migrate to using the Authorization header instead:
|
||||
# Authorization: Bearer sk-oh-...
|
||||
# See: https://github.com/OpenHands/evaluation/issues/391
|
||||
query_api_key = request.query_params.get('api_key')
|
||||
if query_api_key:
|
||||
logger.warning(
|
||||
'DEPRECATED: api_key passed as URL query parameter. '
|
||||
'This is a security risk as tokens are logged in proxy/access logs. '
|
||||
'Use the Authorization header instead: Authorization: Bearer <token>. '
|
||||
'Query parameter support will be removed in a future release.',
|
||||
extra={'path': request.url.path},
|
||||
)
|
||||
return query_api_key
|
||||
|
||||
return None
|
||||
return request.headers.get('X-Access-Token')
|
||||
|
||||
|
||||
async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
|
||||
|
||||
@@ -106,17 +106,12 @@ class SetAuthCookieMiddleware:
|
||||
auth_header = request.headers.get('Authorization')
|
||||
mcp_auth_header = request.headers.get('X-Session-API-Key')
|
||||
api_auth_header = request.headers.get('X-Access-Token')
|
||||
# DEPRECATED: Also check for api_key in query params for backward
|
||||
# compatibility. The actual deprecation warning is logged in
|
||||
# get_api_key_from_header() when the key is extracted.
|
||||
query_api_key = request.query_params.get('api_key')
|
||||
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
|
||||
and query_api_key is None
|
||||
):
|
||||
raise NoCredentialsError
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ from storage.database import a_session_maker
|
||||
from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from server.auth.token_manager import TokenManager
|
||||
from storage.user_store import UserStore
|
||||
from utils.identity import resolve_display_name
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
@@ -23,7 +24,6 @@ from openhands.microagent.types import (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.routes.git import (
|
||||
get_repository_branches,
|
||||
get_repository_microagent_content,
|
||||
|
||||
@@ -363,6 +363,11 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
if api_key_org_id is not None:
|
||||
org_id = api_key_org_id
|
||||
|
||||
# Override with resolver org_id if set (from git org claim resolution)
|
||||
resolver_org_id = getattr(self.user_context, 'resolver_org_id', None)
|
||||
if resolver_org_id is not None:
|
||||
org_id = resolver_org_id
|
||||
|
||||
# Check if SAAS metadata already exists
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(info.id)
|
||||
|
||||
@@ -34,10 +34,17 @@ class SaasConversationStore(ConversationStore):
|
||||
session_maker: sessionmaker
|
||||
org_id: UUID | None = None # will be fetched automatically
|
||||
|
||||
def __init__(self, user_id: str, org_id: UUID, session_maker: sessionmaker):
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
session_maker: sessionmaker,
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.user_id = user_id
|
||||
self.org_id = org_id
|
||||
self.session_maker = session_maker
|
||||
self.resolver_org_id = resolver_org_id
|
||||
|
||||
def _select_by_id(self, session, conversation_id: str):
|
||||
# Join StoredConversationMetadata with ConversationMetadataSaas to filter by user/org
|
||||
@@ -103,6 +110,13 @@ class SaasConversationStore(ConversationStore):
|
||||
|
||||
stored_metadata = StoredConversationMetadata(**kwargs)
|
||||
|
||||
# Override with resolver org_id if set (from git org claim resolution),
|
||||
# same pattern as V1's save_app_conversation_info in
|
||||
# saas_app_conversation_info_injector.py
|
||||
org_id = self.org_id
|
||||
if self.resolver_org_id is not None:
|
||||
org_id = self.resolver_org_id
|
||||
|
||||
def _save_metadata():
|
||||
with self.session_maker() as session:
|
||||
# Save the main conversation metadata
|
||||
@@ -122,13 +136,13 @@ class SaasConversationStore(ConversationStore):
|
||||
saas_metadata = StoredConversationMetadataSaas(
|
||||
conversation_id=stored_metadata.conversation_id,
|
||||
user_id=UUID(self.user_id),
|
||||
org_id=self.org_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
session.add(saas_metadata)
|
||||
else:
|
||||
# Validate
|
||||
expected_user_id = UUID(self.user_id)
|
||||
expected_org_id = self.org_id
|
||||
expected_org_id = org_id
|
||||
|
||||
if saas_metadata.user_id != expected_user_id:
|
||||
raise ValueError(
|
||||
@@ -240,3 +254,19 @@ class SaasConversationStore(ConversationStore):
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker)
|
||||
|
||||
@classmethod
|
||||
async def get_resolver_instance(
|
||||
cls,
|
||||
config: OpenHandsConfig,
|
||||
user_id: str,
|
||||
resolver_org_id: UUID | None = None,
|
||||
) -> 'SaasConversationStore':
|
||||
"""Get a store for resolver conversations with explicit org routing.
|
||||
|
||||
Unlike get_instance, this accepts a resolver_org_id that overrides
|
||||
the user's default org when saving conversation metadata.
|
||||
"""
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
org_id = user.current_org_id if user else None
|
||||
return SaasConversationStore(user_id, org_id, session_maker, resolver_org_id)
|
||||
|
||||
@@ -88,6 +88,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
view.previous_comments = [MagicMock(author='alice', body='old comment 1')]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
@@ -144,6 +145,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
]
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_app_conversation_service.return_value = (
|
||||
@@ -200,6 +202,7 @@ class TestGithubViewV1InitialUserMessage:
|
||||
view.previous_comments = []
|
||||
|
||||
view._load_resolver_context = AsyncMock(side_effect=_load_context) # type: ignore[method-assign]
|
||||
view.resolved_org_id = None
|
||||
|
||||
fake_service = _FakeAppConversationService()
|
||||
mock_get_service.return_value = _fake_app_conversation_service_ctx(fake_service)
|
||||
|
||||
@@ -32,6 +32,28 @@ def resolver_context(mock_saas_user_auth):
|
||||
return ResolverUserContext(saas_user_auth=mock_saas_user_auth)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests for resolver_org_id - org routing for resolver conversations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolver_org_id_defaults_to_none(mock_saas_user_auth):
|
||||
"""Test that resolver_org_id defaults to None when not provided."""
|
||||
ctx = ResolverUserContext(saas_user_auth=mock_saas_user_auth)
|
||||
assert ctx.resolver_org_id is None
|
||||
|
||||
|
||||
def test_resolver_org_id_can_be_set_via_constructor(mock_saas_user_auth):
|
||||
"""Test that resolver_org_id can be set via constructor for org routing."""
|
||||
from uuid import UUID
|
||||
|
||||
org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
ctx = ResolverUserContext(
|
||||
saas_user_auth=mock_saas_user_auth, resolver_org_id=org_id
|
||||
)
|
||||
assert ctx.resolver_org_id == org_id
|
||||
|
||||
|
||||
def create_custom_secret(value: str, description: str = 'Test secret') -> CustomSecret:
|
||||
"""Helper to create CustomSecret instances."""
|
||||
return CustomSecret(secret=SecretStr(value), description=description)
|
||||
|
||||
111
enterprise/tests/unit/integrations/test_resolver_org_router.py
Normal file
111
enterprise/tests/unit/integrations/test_resolver_org_router.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Tests for resolver org routing logic.
|
||||
|
||||
Tests the resolve_org_for_repo function which determines which OpenHands
|
||||
organization workspace a resolver conversation should be created in.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
# Patch at module level where the names are looked up
|
||||
_CLAIM_STORE = 'enterprise.integrations.resolver_org_router.OrgGitClaimStore'
|
||||
_MEMBER_STORE = 'enterprise.integrations.resolver_org_router.OrgMemberStore'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_stores():
|
||||
"""Mock OrgGitClaimStore and OrgMemberStore for all tests."""
|
||||
with (
|
||||
patch(_CLAIM_STORE) as mock_claim_store,
|
||||
patch(_MEMBER_STORE) as mock_member_store,
|
||||
):
|
||||
mock_claim_store.get_claim_by_provider_and_git_org = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
mock_member_store.get_org_member = AsyncMock(return_value=None)
|
||||
yield mock_claim_store, mock_member_store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_org_id_when_claimed_and_user_is_member(mock_stores):
|
||||
"""When the git org is claimed and the user is a member, return the claiming org's ID."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, mock_member_store = mock_stores
|
||||
|
||||
# Arrange
|
||||
claim = MagicMock()
|
||||
claim.org_id = CLAIMING_ORG_ID
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
|
||||
mock_member_store.get_org_member.return_value = MagicMock() # member exists
|
||||
|
||||
# Act
|
||||
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
|
||||
|
||||
# Assert
|
||||
assert result == CLAIMING_ORG_ID
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
|
||||
'github', 'openhands'
|
||||
)
|
||||
mock_member_store.get_org_member.assert_called_once_with(
|
||||
CLAIMING_ORG_ID, UUID(USER_ID)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_claimed_but_user_not_member(mock_stores):
|
||||
"""When the git org is claimed but user is not a member, return None."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, mock_member_store = mock_stores
|
||||
|
||||
# Arrange
|
||||
claim = MagicMock()
|
||||
claim.org_id = CLAIMING_ORG_ID
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.return_value = claim
|
||||
mock_member_store.get_org_member.return_value = None
|
||||
|
||||
# Act
|
||||
result = await resolve_org_for_repo('github', 'OpenHands/foo', USER_ID)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_no_claim_exists(mock_stores):
|
||||
"""When no org has claimed the git organization, return None."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, _ = mock_stores
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.return_value = None
|
||||
|
||||
# Act
|
||||
result = await resolve_org_for_repo('github', 'UnclaimedOrg/repo', USER_ID)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
|
||||
'github', 'unclaimedorg'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extracts_git_org_lowercase_from_repo_name(mock_stores):
|
||||
"""The git org is extracted from repo name and lowercased for claim lookup."""
|
||||
from enterprise.integrations.resolver_org_router import resolve_org_for_repo
|
||||
|
||||
mock_claim_store, _ = mock_stores
|
||||
|
||||
# Act
|
||||
await resolve_org_for_repo('github', 'MyOrg/some-repo', USER_ID)
|
||||
|
||||
# Assert
|
||||
mock_claim_store.get_claim_by_provider_and_git_org.assert_called_once_with(
|
||||
'github', 'myorg'
|
||||
)
|
||||
@@ -1306,3 +1306,100 @@ class TestApiKeyOrgIdHandling:
|
||||
conv_from_org1 = await user_service_org1.get_app_conversation_info(conv_id)
|
||||
assert conv_from_org1 is not None
|
||||
assert conv_from_org1.id == conv_id
|
||||
|
||||
|
||||
class TestResolverOrgIdRouting:
|
||||
"""Test that resolver_org_id on user_context overrides the default org_id."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_uses_resolver_org_id_when_set_on_context(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""When user_context has resolver_org_id, conversation is saved in that org."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
|
||||
# Arrange: user1 is in ORG1, but resolver routes to ORG2
|
||||
# Use spec to prevent MagicMock from auto-creating undefined attributes
|
||||
mock_context = MagicMock(spec=ResolverUserContext)
|
||||
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
|
||||
mock_context.resolver_org_id = ORG2_ID
|
||||
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=mock_context,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_resolver',
|
||||
title='Resolver Routed Conversation',
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: conversation is stored in ORG2, not user's default ORG1
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None
|
||||
assert saas_metadata.org_id == ORG2_ID
|
||||
assert saas_metadata.user_id == USER1_ID
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_uses_default_org_when_resolver_org_id_is_none(
|
||||
self,
|
||||
async_session_with_users: AsyncSession,
|
||||
):
|
||||
"""When resolver_org_id is None, conversation uses user's default org."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
from enterprise.integrations.resolver_context import ResolverUserContext
|
||||
|
||||
# Arrange: user1 in ORG1 with no resolver override
|
||||
# Use spec to prevent MagicMock from auto-creating undefined attributes
|
||||
mock_context = MagicMock(spec=ResolverUserContext)
|
||||
mock_context.get_user_id = AsyncMock(return_value=str(USER1_ID))
|
||||
mock_context.resolver_org_id = None
|
||||
|
||||
service = SaasSQLAppConversationInfoService(
|
||||
db_session=async_session_with_users,
|
||||
user_context=mock_context,
|
||||
)
|
||||
|
||||
conv_id = uuid4()
|
||||
conv_info = AppConversationInfo(
|
||||
id=conv_id,
|
||||
created_by_user_id=str(USER1_ID),
|
||||
sandbox_id='sandbox_default',
|
||||
title='Default Org Conversation',
|
||||
)
|
||||
|
||||
# Act
|
||||
await service.save_app_conversation_info(conv_info)
|
||||
|
||||
# Assert: conversation stored in user's default ORG1
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(conv_id)
|
||||
)
|
||||
result = await async_session_with_users.execute(saas_query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
assert saas_metadata is not None
|
||||
assert saas_metadata.org_id == ORG1_ID
|
||||
|
||||
@@ -25,7 +25,6 @@ def middleware():
|
||||
def mock_request():
|
||||
request = MagicMock(spec=Request)
|
||||
request.cookies = {}
|
||||
request.query_params = {}
|
||||
return request
|
||||
|
||||
|
||||
@@ -357,7 +356,6 @@ async def test_middleware_does_not_skip_similar_non_webhook_paths(
|
||||
mock_request.url.path = path
|
||||
mock_request.headers = MagicMock()
|
||||
mock_request.headers.get = MagicMock(side_effect=lambda k: None)
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Since these paths start with /api, _should_attach returns True
|
||||
# Since there's no auth, middleware catches NoCredentialsError and returns 401
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from unittest import TestCase, mock
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
|
||||
@@ -215,3 +216,119 @@ class TestGithubV1ConversationRouting(TestCase):
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
mock_create_v0.assert_not_called()
|
||||
|
||||
|
||||
class TestGithubOrgRouting(TestCase):
|
||||
"""Test org routing for GitHub resolver conversations."""
|
||||
|
||||
def setUp(self):
|
||||
self.user_data = UserData(
|
||||
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
|
||||
)
|
||||
self.raw_payload = Message(
|
||||
source=SourceType.GITHUB,
|
||||
message={
|
||||
'payload': {
|
||||
'action': 'opened',
|
||||
'issue': {'number': 42},
|
||||
}
|
||||
},
|
||||
)
|
||||
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
|
||||
def _create_github_issue(self):
|
||||
return GithubIssue(
|
||||
user_info=self.user_data,
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
issue_number=42,
|
||||
installation_id=456,
|
||||
conversation_id='',
|
||||
should_extract=True,
|
||||
send_summary_instruction=False,
|
||||
is_public_repo=True,
|
||||
raw_payload=self.raw_payload,
|
||||
uuid='test-uuid',
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.github.github_view.resolve_org_for_repo')
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
|
||||
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
|
||||
# Arrange
|
||||
mock_v1_setting.return_value = False
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
|
||||
# Act
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='github',
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
keycloak_user_id='test-keycloak-id',
|
||||
)
|
||||
# get_resolver_instance(config, user_id, resolver_org_id)
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[1] == 'test-keycloak-id'
|
||||
assert args[2] == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.github.github_view.get_app_conversation_service')
|
||||
@patch('integrations.github.github_view.resolve_org_for_repo')
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
|
||||
self, mock_v1_setting, mock_resolve_org, mock_get_service
|
||||
):
|
||||
"""V1 path passes resolved org_id to ResolverUserContext."""
|
||||
# Arrange
|
||||
mock_v1_setting.return_value = True
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
|
||||
# Initialize to set resolved_org_id and v1_enabled
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
assert github_issue.resolved_org_id == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.github.github_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.github.github_view.resolve_org_for_repo')
|
||||
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
|
||||
async def test_no_claim_passes_none_resolver_org_id(
|
||||
self, mock_v1_setting, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
|
||||
# Arrange
|
||||
mock_v1_setting.return_value = False
|
||||
mock_resolve_org.return_value = None
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
github_issue = self._create_github_issue()
|
||||
|
||||
# Act
|
||||
await github_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[2] is None
|
||||
|
||||
126
enterprise/tests/unit/test_gitlab_view.py
Normal file
126
enterprise/tests/unit/test_gitlab_view.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Tests for GitLab resolver org routing logic.
|
||||
|
||||
Tests that the GitLab resolver correctly resolves the target organization
|
||||
and passes resolver_org_id through V0 and V1 conversation paths.
|
||||
"""
|
||||
|
||||
from unittest import TestCase
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from integrations.gitlab.gitlab_view import GitlabIssue
|
||||
from integrations.models import Message, SourceType
|
||||
from integrations.types import UserData
|
||||
|
||||
|
||||
class TestGitlabOrgRouting(TestCase):
|
||||
"""Test org routing for GitLab resolver conversations."""
|
||||
|
||||
def setUp(self):
|
||||
self.user_data = UserData(
|
||||
user_id=123, username='testuser', keycloak_user_id='test-keycloak-id'
|
||||
)
|
||||
self.raw_payload = Message(
|
||||
source=SourceType.GITLAB,
|
||||
message={
|
||||
'payload': {
|
||||
'object_kind': 'issue',
|
||||
'object_attributes': {'action': 'open', 'iid': 42},
|
||||
}
|
||||
},
|
||||
)
|
||||
self.resolved_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
|
||||
def _create_gitlab_issue(self):
|
||||
return GitlabIssue(
|
||||
user_info=self.user_data,
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
issue_number=42,
|
||||
project_id=100,
|
||||
installation_id='install-123',
|
||||
conversation_id='',
|
||||
should_extract=True,
|
||||
send_summary_instruction=False,
|
||||
is_public_repo=True,
|
||||
raw_payload=self.raw_payload,
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
|
||||
async def test_v0_passes_resolver_org_id_to_get_resolver_instance(
|
||||
self, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""V0 path creates store via get_resolver_instance with resolver_org_id."""
|
||||
# Arrange
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
gitlab_issue = self._create_gitlab_issue()
|
||||
|
||||
# Act
|
||||
await gitlab_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='gitlab',
|
||||
full_repo_name='ClaimedOrg/repo',
|
||||
keycloak_user_id='test-keycloak-id',
|
||||
)
|
||||
# get_resolver_instance(config, user_id, resolver_org_id)
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[1] == 'test-keycloak-id'
|
||||
assert args[2] == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('integrations.gitlab.gitlab_view.get_app_conversation_service')
|
||||
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
|
||||
async def test_v1_passes_resolver_org_id_to_resolver_user_context(
|
||||
self, mock_resolve_org, mock_get_service
|
||||
):
|
||||
"""V1 path passes resolved org_id to ResolverUserContext."""
|
||||
# Arrange
|
||||
mock_resolve_org.return_value = self.resolved_org_id
|
||||
|
||||
gitlab_issue = self._create_gitlab_issue()
|
||||
gitlab_issue.v1_enabled = True
|
||||
|
||||
# Initialize to set resolved_org_id
|
||||
await gitlab_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
assert gitlab_issue.resolved_org_id == self.resolved_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.gitlab.gitlab_view.SaasConversationStore.get_resolver_instance'
|
||||
)
|
||||
@patch('integrations.gitlab.gitlab_view.resolve_org_for_repo')
|
||||
async def test_no_claim_passes_none_resolver_org_id(
|
||||
self, mock_resolve_org, mock_get_resolver
|
||||
):
|
||||
"""When no claim exists, resolver_org_id is None (falls back to personal workspace)."""
|
||||
# Arrange
|
||||
mock_resolve_org.return_value = None
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver.return_value = mock_store
|
||||
|
||||
gitlab_issue = self._create_gitlab_issue()
|
||||
|
||||
# Act
|
||||
await gitlab_issue.initialize_new_conversation()
|
||||
|
||||
# Assert
|
||||
args, _ = mock_get_resolver.call_args
|
||||
assert args[2] is None
|
||||
@@ -214,3 +214,125 @@ class TestGetInstance:
|
||||
# Assert
|
||||
assert store.user_id == user_id
|
||||
assert store.org_id is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resolver_instance_passes_resolver_org_id(self):
|
||||
"""Verify get_resolver_instance forwards resolver_org_id to the store."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
mock_user = MagicMock(spec=User)
|
||||
mock_user.current_org_id = UUID(user_id)
|
||||
mock_config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
with patch(
|
||||
'storage.saas_conversation_store.UserStore.get_user_by_id',
|
||||
AsyncMock(return_value=mock_user),
|
||||
), patch('storage.saas_conversation_store.session_maker'):
|
||||
# Act
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
mock_config, user_id, resolver_org_id=resolver_org_id
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert store.resolver_org_id == resolver_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instance_does_not_have_resolver_org_id(self):
|
||||
"""Verify get_instance does not set resolver_org_id (it's not a resolver path)."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
mock_user = MagicMock(spec=User)
|
||||
mock_user.current_org_id = UUID(user_id)
|
||||
mock_config = MagicMock(spec=OpenHandsConfig)
|
||||
|
||||
with patch(
|
||||
'storage.saas_conversation_store.UserStore.get_user_by_id',
|
||||
AsyncMock(return_value=mock_user),
|
||||
), patch('storage.saas_conversation_store.session_maker'):
|
||||
# Act
|
||||
store = await SaasConversationStore.get_instance(mock_config, user_id)
|
||||
|
||||
# Assert
|
||||
assert store.resolver_org_id is None
|
||||
|
||||
|
||||
class TestResolverOrgIdRouting:
|
||||
"""Tests for resolver_org_id overriding org_id in save_metadata."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_metadata_uses_resolver_org_id_over_default(self, session_maker):
|
||||
"""When resolver_org_id is set, save_metadata stores it instead of the default org_id."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
resolver_org_id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
store = SaasConversationStore(
|
||||
user_id, default_org_id, session_maker, resolver_org_id=resolver_org_id
|
||||
)
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='resolver-routed-conv',
|
||||
user_id=user_id,
|
||||
selected_repository='ClaimedOrg/repo',
|
||||
selected_branch=None,
|
||||
created_at=datetime.now(UTC),
|
||||
last_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Act
|
||||
await store.save_metadata(metadata)
|
||||
|
||||
# Assert - verify the SaaS metadata record has the resolver org, not the default
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
saas_record = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id
|
||||
== 'resolver-routed-conv'
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert saas_record is not None
|
||||
assert saas_record.org_id == resolver_org_id
|
||||
assert saas_record.org_id != default_org_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_metadata_uses_default_org_when_no_resolver_org(
|
||||
self, session_maker
|
||||
):
|
||||
"""When resolver_org_id is None, save_metadata uses the default org_id."""
|
||||
# Arrange
|
||||
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
|
||||
default_org_id = UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
|
||||
store = SaasConversationStore(user_id, default_org_id, session_maker)
|
||||
metadata = ConversationMetadata(
|
||||
conversation_id='default-org-conv',
|
||||
user_id=user_id,
|
||||
selected_repository='PersonalOrg/repo',
|
||||
selected_branch=None,
|
||||
created_at=datetime.now(UTC),
|
||||
last_updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
# Act
|
||||
await store.save_metadata(metadata)
|
||||
|
||||
# Assert
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
saas_record = (
|
||||
session.query(StoredConversationMetadataSaas)
|
||||
.filter(
|
||||
StoredConversationMetadataSaas.conversation_id == 'default-org-conv'
|
||||
)
|
||||
.first()
|
||||
)
|
||||
assert saas_record is not None
|
||||
assert saas_record.org_id == default_org_id
|
||||
|
||||
@@ -31,7 +31,6 @@ def mock_request():
|
||||
request = MagicMock(spec=Request)
|
||||
request.headers = {}
|
||||
request.cookies = {}
|
||||
request.query_params = {}
|
||||
return request
|
||||
|
||||
|
||||
@@ -512,7 +511,6 @@ async def test_saas_user_auth_from_bearer_no_auth_header():
|
||||
"""Test that saas_user_auth_from_bearer returns None if no auth header."""
|
||||
mock_request = MagicMock()
|
||||
mock_request.headers = {}
|
||||
mock_request.query_params = {}
|
||||
|
||||
result = await saas_user_auth_from_bearer(mock_request)
|
||||
|
||||
@@ -635,7 +633,6 @@ def test_get_api_key_from_header_with_authorization_header():
|
||||
# Create a mock request with Authorization header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -649,7 +646,6 @@ def test_get_api_key_from_header_with_x_session_api_key():
|
||||
# Create a mock request with X-Session-API-Key header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Session-API-Key': 'session_api_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -666,7 +662,6 @@ def test_get_api_key_from_header_with_both_headers():
|
||||
'Authorization': 'Bearer auth_api_key',
|
||||
'X-Session-API-Key': 'session_api_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -676,11 +671,10 @@ def test_get_api_key_from_header_with_both_headers():
|
||||
|
||||
|
||||
def test_get_api_key_from_header_with_no_headers():
|
||||
"""Test that get_api_key_from_header returns None when no relevant headers or query params are present."""
|
||||
"""Test that get_api_key_from_header returns None when no relevant headers are present."""
|
||||
# Create a mock request with no relevant headers
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Other-Header': 'some_value'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -694,7 +688,6 @@ def test_get_api_key_from_header_with_invalid_authorization_format():
|
||||
# Create a mock request with incorrectly formatted Authorization header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Authorization': 'InvalidFormat api_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -708,7 +701,6 @@ def test_get_api_key_from_header_with_x_access_token():
|
||||
# Create a mock request with X-Access-Token header
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Access-Token': 'access_token_key'}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -725,7 +717,6 @@ def test_get_api_key_from_header_priority_authorization_over_x_access_token():
|
||||
'Authorization': 'Bearer auth_api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -742,7 +733,6 @@ def test_get_api_key_from_header_priority_x_session_over_x_access_token():
|
||||
'X-Session-API-Key': 'session_api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -760,7 +750,6 @@ def test_get_api_key_from_header_all_three_headers():
|
||||
'X-Session-API-Key': 'session_api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -777,7 +766,6 @@ def test_get_api_key_from_header_invalid_authorization_fallback_to_x_access_toke
|
||||
'Authorization': 'InvalidFormat api_key',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -795,7 +783,6 @@ def test_get_api_key_from_header_empty_headers():
|
||||
'X-Session-API-Key': '',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -812,7 +799,6 @@ def test_get_api_key_from_header_bearer_with_empty_token():
|
||||
'Authorization': 'Bearer ',
|
||||
'X-Access-Token': 'access_token_key',
|
||||
}
|
||||
mock_request.query_params = {}
|
||||
|
||||
# Call the function
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
@@ -822,68 +808,6 @@ def test_get_api_key_from_header_bearer_with_empty_token():
|
||||
assert api_key == ''
|
||||
|
||||
|
||||
def test_get_api_key_from_query_param_fallback():
|
||||
"""Test that get_api_key_from_header falls back to api_key query parameter (deprecated)."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = '/mcp'
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'sk-oh-query-param-key'
|
||||
|
||||
|
||||
def test_get_api_key_from_header_takes_priority_over_query_param():
|
||||
"""Test that Authorization header takes priority over api_key query parameter."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'Authorization': 'Bearer header_api_key'}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'header_api_key'
|
||||
|
||||
|
||||
def test_get_api_key_x_session_header_takes_priority_over_query_param():
|
||||
"""Test that X-Session-API-Key header takes priority over api_key query parameter."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Session-API-Key': 'session_key'}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'session_key'
|
||||
|
||||
|
||||
def test_get_api_key_x_access_token_takes_priority_over_query_param():
|
||||
"""Test that X-Access-Token header takes priority over api_key query parameter."""
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {'X-Access-Token': 'access_token_key'}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'access_token_key'
|
||||
|
||||
|
||||
def test_get_api_key_from_query_param_logs_deprecation_warning(caplog):
|
||||
"""Test that using api_key query parameter logs a deprecation warning."""
|
||||
import logging
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {}
|
||||
mock_request.query_params = {'api_key': 'sk-oh-query-param-key'}
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = '/api/v1/auth/github'
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
api_key = get_api_key_from_header(mock_request)
|
||||
|
||||
assert api_key == 'sk-oh-query-param-key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_saas_user_auth_from_signed_token_blocked_domain(mock_config):
|
||||
"""Test that saas_user_auth_from_signed_token raises AuthError when email domain is blocked."""
|
||||
|
||||
331
enterprise/tests/unit/test_slack_view.py
Normal file
331
enterprise/tests/unit/test_slack_view.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Tests for Slack view org routing logic.
|
||||
|
||||
Tests that the SlackNewConversationView correctly resolves the target org
|
||||
based on claimed git organizations and passes it through V0/V1 paths.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from integrations.slack.slack_view import SlackNewConversationView
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
CLAIMING_ORG_ID = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
KEYCLOAK_USER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_slack_user():
|
||||
"""Create a mock SlackUser."""
|
||||
user = SlackUser()
|
||||
user.slack_user_id = 'U1234567890'
|
||||
user.keycloak_user_id = KEYCLOAK_USER_ID
|
||||
user.slack_display_name = 'Test User'
|
||||
user.org_id = UUID('cccccccc-cccc-cccc-cccc-cccccccccccc')
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_auth():
|
||||
"""Create a mock UserAuth."""
|
||||
auth = MagicMock(spec=UserAuth)
|
||||
auth.get_provider_tokens = AsyncMock(
|
||||
return_value={ProviderType.GITHUB: MagicMock()}
|
||||
)
|
||||
auth.get_secrets = AsyncMock(return_value=MagicMock(custom_secrets={}))
|
||||
auth.get_access_token = AsyncMock(return_value='access-token')
|
||||
auth.get_user_id = AsyncMock(return_value=KEYCLOAK_USER_ID)
|
||||
return auth
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_view(mock_slack_user, mock_user_auth):
|
||||
"""Create a SlackNewConversationView instance for testing."""
|
||||
return SlackNewConversationView(
|
||||
bot_access_token='xoxb-test-token',
|
||||
user_msg='Hello OpenHands!',
|
||||
slack_user_id='U1234567890',
|
||||
slack_to_openhands_user=mock_slack_user,
|
||||
saas_user_auth=mock_user_auth,
|
||||
channel_id='C1234567890',
|
||||
message_ts='1234567890.123456',
|
||||
thread_ts=None,
|
||||
selected_repo='OpenHands/foo',
|
||||
should_extract=True,
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id='T1234567890',
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_view_no_repo(mock_slack_user, mock_user_auth):
|
||||
"""Create a SlackNewConversationView with no selected repo."""
|
||||
return SlackNewConversationView(
|
||||
bot_access_token='xoxb-test-token',
|
||||
user_msg='Hello OpenHands!',
|
||||
slack_user_id='U1234567890',
|
||||
slack_to_openhands_user=mock_slack_user,
|
||||
saas_user_auth=mock_user_auth,
|
||||
channel_id='C1234567890',
|
||||
message_ts='1234567890.123456',
|
||||
thread_ts=None,
|
||||
selected_repo=None,
|
||||
should_extract=True,
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id='T1234567890',
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
class TestSlackV0ConversationRouting:
|
||||
"""Test V0 conversation routing logic in Slack integration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch('integrations.slack.slack_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
|
||||
async def test_v0_passes_resolver_org_id(
|
||||
self,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view,
|
||||
):
|
||||
"""V0 path should pass resolver_org_id to SaasConversationStore.get_resolver_instance."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = CLAIMING_ORG_ID
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(
|
||||
slack_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
),
|
||||
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
|
||||
):
|
||||
await slack_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='github',
|
||||
full_repo_name='OpenHands/foo',
|
||||
keycloak_user_id=KEYCLOAK_USER_ID,
|
||||
)
|
||||
mock_get_resolver_instance.assert_called_once()
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][1] == KEYCLOAK_USER_ID # user_id
|
||||
assert call_args[0][2] == CLAIMING_ORG_ID # resolver_org_id
|
||||
mock_store.save_metadata.assert_called_once()
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.git_provider == ProviderType.GITHUB
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch('integrations.slack.slack_view.ProviderHandler')
|
||||
@patch(
|
||||
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
|
||||
async def test_v0_passes_none_when_no_claim(
|
||||
self,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view,
|
||||
):
|
||||
"""V0 path should pass resolver_org_id=None when no claim exists."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = None
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(
|
||||
slack_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
),
|
||||
patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock),
|
||||
):
|
||||
await slack_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None # resolver_org_id is None
|
||||
|
||||
|
||||
class TestSlackV1ConversationRouting:
|
||||
"""Test V1 conversation routing logic in Slack integration."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch('integrations.slack.slack_view.ProviderHandler')
|
||||
@patch('integrations.slack.slack_view.get_app_conversation_service')
|
||||
@patch('integrations.slack.slack_view.ResolverUserContext')
|
||||
async def test_v1_passes_resolver_org_id_to_context(
|
||||
self,
|
||||
mock_resolver_ctx_cls,
|
||||
mock_get_service,
|
||||
mock_provider_handler_cls,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view,
|
||||
):
|
||||
"""V1 path should pass resolver_org_id to ResolverUserContext."""
|
||||
# Arrange
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.git_provider = ProviderType.GITHUB
|
||||
mock_handler = MagicMock()
|
||||
mock_handler.verify_repo_provider = AsyncMock(return_value=mock_repo)
|
||||
mock_provider_handler_cls.return_value = mock_handler
|
||||
|
||||
mock_resolve_org.return_value = CLAIMING_ORG_ID
|
||||
mock_resolver_ctx_cls.return_value = MagicMock()
|
||||
|
||||
# Mock the async context manager for app_conversation_service
|
||||
mock_service = MagicMock()
|
||||
mock_service.start_app_conversation = MagicMock(return_value=aiter_empty())
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_service)
|
||||
mock_ctx.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_get_service.return_value = mock_ctx
|
||||
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with patch.object(
|
||||
slack_view,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
):
|
||||
with patch.object(slack_view, 'save_slack_convo', new_callable=AsyncMock):
|
||||
await slack_view.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_called_once_with(
|
||||
provider='github',
|
||||
full_repo_name='OpenHands/foo',
|
||||
keycloak_user_id=KEYCLOAK_USER_ID,
|
||||
)
|
||||
mock_resolver_ctx_cls.assert_called_once_with(
|
||||
saas_user_auth=slack_view.saas_user_auth,
|
||||
resolver_org_id=CLAIMING_ORG_ID,
|
||||
)
|
||||
|
||||
|
||||
class TestSlackNoRepoRouting:
|
||||
"""Test routing when no repository is selected."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
'integrations.slack.slack_view.is_v1_enabled_for_slack_resolver',
|
||||
new_callable=AsyncMock,
|
||||
return_value=False,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.resolve_org_for_repo', new_callable=AsyncMock)
|
||||
@patch(
|
||||
'integrations.slack.slack_view.SaasConversationStore.get_resolver_instance',
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
@patch('integrations.slack.slack_view.start_conversation', new_callable=AsyncMock)
|
||||
async def test_no_repo_skips_org_resolution(
|
||||
self,
|
||||
mock_start_convo,
|
||||
mock_get_resolver_instance,
|
||||
mock_resolve_org,
|
||||
mock_v1_enabled,
|
||||
slack_view_no_repo,
|
||||
):
|
||||
"""When selected_repo is None, org resolution should be skipped."""
|
||||
# Arrange
|
||||
mock_store = MagicMock()
|
||||
mock_store.save_metadata = AsyncMock()
|
||||
mock_get_resolver_instance.return_value = mock_store
|
||||
mock_start_convo.return_value = MagicMock(conversation_id='test-conv-id')
|
||||
mock_jinja = MagicMock()
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(
|
||||
slack_view_no_repo,
|
||||
'_get_instructions',
|
||||
new_callable=AsyncMock,
|
||||
return_value=('msg', 'instructions'),
|
||||
),
|
||||
patch.object(
|
||||
slack_view_no_repo, 'save_slack_convo', new_callable=AsyncMock
|
||||
),
|
||||
patch.object(slack_view_no_repo, '_verify_necessary_values_are_set'),
|
||||
):
|
||||
await slack_view_no_repo.create_or_update_conversation(mock_jinja)
|
||||
|
||||
# Assert
|
||||
mock_resolve_org.assert_not_called()
|
||||
call_args = mock_get_resolver_instance.call_args
|
||||
assert call_args[0][2] is None # resolver_org_id is None
|
||||
saved_metadata = mock_store.save_metadata.call_args[0][0]
|
||||
assert saved_metadata.git_provider is None
|
||||
|
||||
|
||||
async def aiter_empty():
|
||||
"""Helper: empty async iterator."""
|
||||
return
|
||||
yield # noqa: unreachable - makes this an async generator
|
||||
@@ -26,9 +26,10 @@ function makeConversation(version: "V0" | "V1"): Conversation {
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvent(): OpenHandsEvent {
|
||||
function makeEvent(timestamp?: string): OpenHandsEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
timestamp: timestamp ?? "2026-01-15T10:00:00Z",
|
||||
} as OpenHandsEvent;
|
||||
}
|
||||
|
||||
@@ -60,7 +61,7 @@ describe("useConversationHistory", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls V1 REST endpoint for V1 conversations", async () => {
|
||||
it("calls V1 REST endpoint for V1 conversations with TIMESTAMP_DESC", async () => {
|
||||
const v1SearchEventsSpy = vi.spyOn(EventService, "searchEventsV1");
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
@@ -72,7 +73,10 @@ describe("useConversationHistory", () => {
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
v1SearchEventsSpy.mockResolvedValue([makeEvent()]);
|
||||
v1SearchEventsSpy.mockResolvedValue({
|
||||
items: [makeEvent("2026-01-15T12:00:00Z"), makeEvent("2026-01-15T10:00:00Z")],
|
||||
next_page_id: undefined,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useConversationHistory("conv-123"), {
|
||||
wrapper,
|
||||
@@ -82,8 +86,45 @@ describe("useConversationHistory", () => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123");
|
||||
// Should call with TIMESTAMP_DESC for bi-directional loading (newest first)
|
||||
expect(EventService.searchEventsV1).toHaveBeenCalledWith("conv-123", {
|
||||
sort_order: "TIMESTAMP_DESC",
|
||||
limit: 100,
|
||||
});
|
||||
expect(EventService.searchEventsV0).not.toHaveBeenCalled();
|
||||
|
||||
// Should return events and oldest timestamp for WebSocket handoff
|
||||
expect(result.current.data?.events).toHaveLength(2);
|
||||
expect(result.current.data?.oldestTimestamp).toBe("2026-01-15T10:00:00Z");
|
||||
});
|
||||
|
||||
it("returns null oldestTimestamp when no events", async () => {
|
||||
const v1SearchEventsSpy = vi.spyOn(EventService, "searchEventsV1");
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as any);
|
||||
|
||||
v1SearchEventsSpy.mockResolvedValue({
|
||||
items: [],
|
||||
next_page_id: undefined,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useConversationHistory("conv-empty"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
|
||||
expect(result.current.data?.events).toHaveLength(0);
|
||||
expect(result.current.data?.oldestTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
it("calls V0 REST endpoint for V0 conversations", async () => {
|
||||
@@ -110,6 +151,10 @@ describe("useConversationHistory", () => {
|
||||
|
||||
expect(EventService.searchEventsV0).toHaveBeenCalledWith("conv-456");
|
||||
expect(EventService.searchEventsV1).not.toHaveBeenCalled();
|
||||
|
||||
// V0 returns events but null oldestTimestamp (no bi-directional loading)
|
||||
expect(result.current.data?.events).toHaveLength(1);
|
||||
expect(result.current.data?.oldestTimestamp).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,7 +189,7 @@ describe("useConversationHistory cache key stability", () => {
|
||||
|
||||
it("does not refetch when conversation object changes but version stays the same", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
|
||||
|
||||
const conv1 = makeConversation("V1");
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
@@ -200,7 +245,7 @@ describe("useConversationHistory cache key stability", () => {
|
||||
const v0Spy = vi.spyOn(EventService, "searchEventsV0");
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v0Spy.mockResolvedValue([makeEvent()]);
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
|
||||
|
||||
// Start with V0
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
@@ -242,7 +287,7 @@ describe("useConversationHistory cache key stability", () => {
|
||||
|
||||
it("treats cached history as never stale (staleTime is Infinity)", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
@@ -274,7 +319,7 @@ describe("useConversationHistory cache key stability", () => {
|
||||
|
||||
it("has gcTime of at least 30 minutes for navigation resilience", async () => {
|
||||
const v1Spy = vi.spyOn(EventService, "searchEventsV1");
|
||||
v1Spy.mockResolvedValue([makeEvent()]);
|
||||
v1Spy.mockResolvedValue({ items: [makeEvent()], next_page_id: undefined });
|
||||
|
||||
vi.mocked(useUserConversation).mockReturnValue({
|
||||
data: makeConversation("V1"),
|
||||
|
||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -11905,11 +11905,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/lodash.curry": {
|
||||
"version": "4.1.1",
|
||||
@@ -13821,10 +13820,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "4.0.0",
|
||||
|
||||
@@ -65,14 +65,28 @@ class EventService {
|
||||
}
|
||||
|
||||
// V1 conversations — App Server REST endpoint
|
||||
static async searchEventsV1(conversationId: string, limit = 100) {
|
||||
// Note: cursor parameter is exposed for future pagination of older events.
|
||||
// Currently only the initial page is fetched; background pagination is planned.
|
||||
static async searchEventsV1(
|
||||
conversationId: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
sort_order?: "TIMESTAMP_ASC" | "TIMESTAMP_DESC";
|
||||
cursor?: string;
|
||||
},
|
||||
) {
|
||||
const { data } = await openHands.get<{
|
||||
items: OpenHandsEvent[];
|
||||
next_page_id?: string;
|
||||
}>(`/api/v1/conversation/${conversationId}/events/search`, {
|
||||
params: { limit },
|
||||
params: {
|
||||
limit: options?.limit ?? 100,
|
||||
sort_order: options?.sort_order,
|
||||
cursor: options?.cursor,
|
||||
},
|
||||
});
|
||||
|
||||
return data.items;
|
||||
return data;
|
||||
}
|
||||
|
||||
// V0 conversations — Legacy REST endpoint
|
||||
|
||||
@@ -318,7 +318,11 @@ export function ConversationWebSocketProvider({
|
||||
latestPlanningFileEventRef.current = null;
|
||||
}, [conversationId]);
|
||||
|
||||
const { data: preloadedEvents } = useConversationHistory(conversationId);
|
||||
const { data: conversationHistory } = useConversationHistory(conversationId);
|
||||
|
||||
// Extract preloaded events and oldest timestamp for WebSocket handoff
|
||||
const preloadedEvents = conversationHistory?.events;
|
||||
const oldestPreloadedTimestamp = conversationHistory?.oldestTimestamp;
|
||||
|
||||
useEffect(() => {
|
||||
if (!preloadedEvents || preloadedEvents.length === 0) {
|
||||
@@ -698,6 +702,15 @@ export function ConversationWebSocketProvider({
|
||||
queryParams.session_api_key = sessionApiKey;
|
||||
}
|
||||
|
||||
// Bi-directional loading: pass after_timestamp to avoid duplicate events.
|
||||
// WebSocket will only send events with timestamp >= oldestPreloadedTimestamp.
|
||||
// This ensures zero duplication with REST-fetched events while also catching
|
||||
// any events created between REST fetch and WebSocket connect (since server
|
||||
// timestamps are monotonically increasing, >= comparison handles the handoff).
|
||||
if (oldestPreloadedTimestamp) {
|
||||
queryParams.after_timestamp = oldestPreloadedTimestamp;
|
||||
}
|
||||
|
||||
return {
|
||||
queryParams,
|
||||
reconnect: { enabled: true },
|
||||
@@ -747,6 +760,7 @@ export function ConversationWebSocketProvider({
|
||||
sessionApiKey,
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
oldestPreloadedTimestamp,
|
||||
]);
|
||||
|
||||
// Separate WebSocket options for planning agent connection
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
|
||||
export interface ConversationHistoryResult {
|
||||
events: OpenHandsEvent[];
|
||||
/**
|
||||
* The oldest timestamp from preloaded events, used for WebSocket handoff.
|
||||
* WebSocket should use after_timestamp to only receive events newer than this.
|
||||
*/
|
||||
oldestTimestamp: string | null;
|
||||
}
|
||||
|
||||
export const useConversationHistory = (conversationId?: string) => {
|
||||
const { data: conversation } = useUserConversation(conversationId ?? null);
|
||||
@@ -9,14 +19,42 @@ export const useConversationHistory = (conversationId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["conversation-history", conversationId, conversationVersion],
|
||||
enabled: !!conversationId && !!conversation,
|
||||
queryFn: async () => {
|
||||
if (!conversationId || !conversationVersion) return [];
|
||||
|
||||
if (conversationVersion === "V1") {
|
||||
return EventService.searchEventsV1(conversationId);
|
||||
queryFn: async (): Promise<ConversationHistoryResult> => {
|
||||
if (!conversationId || !conversationVersion) {
|
||||
return { events: [], oldestTimestamp: null };
|
||||
}
|
||||
|
||||
return EventService.searchEventsV0(conversationId);
|
||||
if (conversationVersion === "V1") {
|
||||
// Fetch newest events first for instant perceived load.
|
||||
// User sees current conversation state immediately.
|
||||
// NOTE: Currently limited to 100 most recent events for performance.
|
||||
// Older events (if any) will be loaded via WebSocket resend_all.
|
||||
// TODO(#12705): Implement cursor-based background pagination for >100 events.
|
||||
const result = await EventService.searchEventsV1(conversationId, {
|
||||
sort_order: "TIMESTAMP_DESC",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Extract oldest timestamp for WebSocket handoff.
|
||||
// Events are sorted DESC (newest first), so last item has oldest timestamp.
|
||||
// WebSocket will use after_timestamp to only send events >= this timestamp,
|
||||
// ensuring no duplicates while also catching any events created during the
|
||||
// brief window between REST fetch and WebSocket connect (server timestamps
|
||||
// are monotonically increasing, so no race condition).
|
||||
const oldestTimestamp =
|
||||
result.items.length > 0
|
||||
? result.items[result.items.length - 1].timestamp
|
||||
: null;
|
||||
|
||||
return {
|
||||
events: result.items,
|
||||
oldestTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// V0 conversations - legacy behavior (no bi-directional loading)
|
||||
const events = await EventService.searchEventsV0(conversationId);
|
||||
return { events, oldestTimestamp: null };
|
||||
},
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes — survive navigation away and back (AC5)
|
||||
|
||||
@@ -65,12 +65,12 @@ from openhands.app_server.services.httpx_client_injector import (
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.docker_utils import (
|
||||
replace_localhost_hostname_for_docker,
|
||||
)
|
||||
from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger
|
||||
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
# Handle anext compatibility for Python < 3.10
|
||||
if sys.version_info >= (3, 10):
|
||||
|
||||
@@ -10,8 +10,8 @@ from openhands.agent_server.models import EventPage, EventSortOrder
|
||||
from openhands.app_server.config import depends_event_service
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.event_callback.event_callback_models import EventKind
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.sdk import Event
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
# 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
|
||||
|
||||
@@ -22,6 +22,9 @@ from openhands.sdk.utils.models import (
|
||||
get_known_concrete_subclasses,
|
||||
)
|
||||
|
||||
# TODO(OpenHands/evaluation#418): import from openhands.sdk.utils.redact
|
||||
from openhands.utils._redact_compat import redact_text_secrets
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
EventKind = str
|
||||
@@ -56,7 +59,11 @@ class LoggingCallbackProcessor(EventCallbackProcessor):
|
||||
callback: EventCallback,
|
||||
event: Event,
|
||||
) -> EventCallbackResult:
|
||||
_logger.info(f'Callback {callback.id} Invoked for event {event}')
|
||||
_logger.info(
|
||||
'Callback %s Invoked for event %s',
|
||||
callback.id,
|
||||
redact_text_secrets(str(event)),
|
||||
)
|
||||
return EventCallbackResult(
|
||||
status=EventCallbackResultStatus.SUCCESS,
|
||||
event_callback_id=callback.id,
|
||||
|
||||
@@ -23,6 +23,9 @@ from openhands.app_server.utils.docker_utils import (
|
||||
)
|
||||
from openhands.sdk import Event, MessageEvent
|
||||
|
||||
# TODO(OpenHands/evaluation#418): import from openhands.sdk.utils.redact
|
||||
from openhands.utils._redact_compat import redact_text_secrets
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Delay between attempts to poll title
|
||||
@@ -88,7 +91,11 @@ class SetTitleCallbackProcessor(EventCallbackProcessor):
|
||||
get_httpx_client,
|
||||
)
|
||||
|
||||
_logger.info(f'Callback {callback.id} Invoked for event {event}')
|
||||
_logger.info(
|
||||
'Callback %s Invoked for event %s',
|
||||
callback.id,
|
||||
redact_text_secrets(str(event)),
|
||||
)
|
||||
|
||||
state = InjectorState()
|
||||
setattr(state, USER_CONTEXT_ATTR, ADMIN)
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.app_server.pending_messages.pending_message_models import (
|
||||
from openhands.app_server.pending_messages.pending_message_service import (
|
||||
PendingMessageService,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from openhands.app_server.sandbox.sandbox_service import (
|
||||
)
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.server.user_auth.user_auth import (
|
||||
get_for_user as get_user_auth_for_user,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from openhands.app_server.sandbox.sandbox_spec_models import (
|
||||
from openhands.app_server.sandbox.sandbox_spec_service import (
|
||||
SandboxSpecService,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
|
||||
# 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
|
||||
|
||||
329
openhands/app_server/secrets/secrets_router.py
Normal file
329
openhands/app_server/secrets/secrets_router.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Secrets router for OpenHands App Server.
|
||||
|
||||
This module provides the V1 API routes for secrets under /api/v1/secrets.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.models import EditResponse
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
CustomSecret,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.server.settings import (
|
||||
CustomSecretModel,
|
||||
CustomSecretWithoutValueModel,
|
||||
GETCustomSecrets,
|
||||
POSTProviderModel,
|
||||
)
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets,
|
||||
get_secrets_store,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
|
||||
# Create router with /api/v1/secrets prefix
|
||||
router = APIRouter(
|
||||
prefix='/secrets',
|
||||
tags=['Secrets'],
|
||||
dependencies=get_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Helper functions for git providers
|
||||
# =================================================
|
||||
|
||||
|
||||
def _check_token_type(
|
||||
confirmed_token_type: ProviderType | None, token_type: ProviderType
|
||||
) -> None:
|
||||
"""Returns error message if token type doesn't match, None otherwise."""
|
||||
if not confirmed_token_type or confirmed_token_type != token_type:
|
||||
raise AuthError(
|
||||
f'Invalid token. Please make sure it is a valid {token_type.value} token.'
|
||||
)
|
||||
|
||||
|
||||
async def check_provider_tokens(
|
||||
incoming_provider_tokens: POSTProviderModel,
|
||||
existing_provider_tokens: PROVIDER_TOKEN_TYPE | None,
|
||||
) -> None:
|
||||
if incoming_provider_tokens.provider_tokens:
|
||||
# Determine whether tokens are valid
|
||||
for token_type, token_value in incoming_provider_tokens.provider_tokens.items():
|
||||
if token_value.token:
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
token_value.token, token_value.host
|
||||
) # FE always sends latest host
|
||||
_check_token_type(confirmed_token_type, token_type)
|
||||
|
||||
existing_token = (
|
||||
existing_provider_tokens.get(token_type, None)
|
||||
if existing_provider_tokens
|
||||
else None
|
||||
)
|
||||
if (
|
||||
existing_token
|
||||
and (existing_token.host != token_value.host)
|
||||
and existing_token.token
|
||||
):
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
existing_token.token, token_value.host
|
||||
)
|
||||
# Host has changed, check it against existing token
|
||||
_check_token_type(confirmed_token_type, token_type)
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Git Provider Token Endpoints
|
||||
# =================================================
|
||||
|
||||
|
||||
@router.post(
|
||||
'/git-providers',
|
||||
tags=['Git Providers'],
|
||||
)
|
||||
async def store_provider_tokens(
|
||||
provider_info: POSTProviderModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
) -> EditResponse:
|
||||
"""Store git provider tokens.
|
||||
|
||||
Saves the git provider tokens (GitHub, GitLab, Bitbucket, etc.) for the authenticated user.
|
||||
|
||||
Returns:
|
||||
200: Git providers stored successfully
|
||||
401: Invalid token
|
||||
500: Error storing git providers
|
||||
"""
|
||||
await check_provider_tokens(provider_info, provider_tokens)
|
||||
|
||||
user_secrets = await secrets_store.load()
|
||||
if not user_secrets:
|
||||
user_secrets = Secrets()
|
||||
|
||||
if provider_info.provider_tokens:
|
||||
existing_providers = [provider for provider in user_secrets.provider_tokens]
|
||||
|
||||
# Merge incoming settings store with the existing one
|
||||
for provider, token_value in list(provider_info.provider_tokens.items()):
|
||||
if provider in existing_providers and not token_value.token:
|
||||
existing_token = user_secrets.provider_tokens.get(provider)
|
||||
if existing_token and existing_token.token:
|
||||
provider_info.provider_tokens[provider] = existing_token
|
||||
|
||||
provider_info.provider_tokens[provider] = provider_info.provider_tokens[
|
||||
provider
|
||||
].model_copy(update={'host': token_value.host})
|
||||
|
||||
updated_secrets = user_secrets.model_copy(
|
||||
update={'provider_tokens': provider_info.provider_tokens}
|
||||
)
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return EditResponse(
|
||||
message='Git providers stored',
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/git-providers',
|
||||
tags=['Git Providers'],
|
||||
)
|
||||
async def unset_provider_tokens(
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> EditResponse:
|
||||
"""Unset (delete) all git provider tokens.
|
||||
|
||||
Removes all git provider tokens for the authenticated user.
|
||||
|
||||
Returns:
|
||||
200: Git provider tokens unset successfully
|
||||
500: Error unsetting git provider tokens
|
||||
"""
|
||||
user_secrets = await secrets_store.load()
|
||||
if user_secrets:
|
||||
updated_secrets = user_secrets.model_copy(update={'provider_tokens': {}})
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return EditResponse(message='Unset Git provider tokens')
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Custom Secrets Endpoints
|
||||
# =================================================
|
||||
|
||||
|
||||
@router.get('', response_model=GETCustomSecrets)
|
||||
async def load_custom_secrets_names(
|
||||
user_secrets: Secrets | None = Depends(get_secrets),
|
||||
) -> GETCustomSecrets:
|
||||
"""Load custom secret names.
|
||||
|
||||
Retrieves the names and descriptions of all custom secrets for the authenticated user.
|
||||
|
||||
Returns:
|
||||
GETCustomSecrets: List of custom secrets (without values)
|
||||
"""
|
||||
if not user_secrets:
|
||||
return GETCustomSecrets(custom_secrets=[])
|
||||
|
||||
custom_secrets: list[CustomSecretWithoutValueModel] = []
|
||||
if user_secrets.custom_secrets:
|
||||
for secret_name, secret_value in user_secrets.custom_secrets.items():
|
||||
custom_secret = CustomSecretWithoutValueModel(
|
||||
name=secret_name,
|
||||
description=secret_value.description,
|
||||
)
|
||||
custom_secrets.append(custom_secret)
|
||||
|
||||
return GETCustomSecrets(custom_secrets=custom_secrets)
|
||||
|
||||
|
||||
@router.post('', status_code=status.HTTP_201_CREATED)
|
||||
async def create_custom_secret(
|
||||
incoming_secret: CustomSecretModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> EditResponse:
|
||||
"""Create a custom secret.
|
||||
|
||||
Creates a new custom secret for the authenticated user.
|
||||
|
||||
Returns:
|
||||
201: Secret created successfully
|
||||
400: Secret already exists
|
||||
500: Error creating secret
|
||||
"""
|
||||
existing_secrets = await secrets_store.load()
|
||||
custom_secrets = dict(existing_secrets.custom_secrets) if existing_secrets else {}
|
||||
|
||||
secret_name = incoming_secret.name
|
||||
secret_value = incoming_secret.value
|
||||
secret_description = incoming_secret.description
|
||||
|
||||
if secret_name in custom_secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Secret {secret_name} already exists',
|
||||
)
|
||||
|
||||
custom_secrets[secret_name] = CustomSecret(
|
||||
secret=secret_value,
|
||||
description=secret_description or '',
|
||||
)
|
||||
|
||||
# Create a new Secrets that preserves provider tokens
|
||||
updated_user_secrets = Secrets(
|
||||
custom_secrets=custom_secrets, # type: ignore[arg-type]
|
||||
provider_tokens=existing_secrets.provider_tokens if existing_secrets else {}, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
await secrets_store.store(updated_user_secrets)
|
||||
|
||||
return EditResponse(
|
||||
message='Secret created successfully',
|
||||
)
|
||||
|
||||
|
||||
@router.put('/{secret_id}')
|
||||
async def update_custom_secret(
|
||||
secret_id: str,
|
||||
incoming_secret: CustomSecretWithoutValueModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> EditResponse:
|
||||
"""Update a custom secret.
|
||||
|
||||
Updates the name and/or description of an existing custom secret.
|
||||
|
||||
Returns:
|
||||
200: Secret updated successfully
|
||||
400: Secret name already exists
|
||||
404: Secret not found
|
||||
500: Error updating secret
|
||||
"""
|
||||
existing_secrets = await secrets_store.load()
|
||||
if existing_secrets:
|
||||
# Check if the secret to update exists
|
||||
if secret_id not in existing_secrets.custom_secrets:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Secret with ID {secret_id} not found',
|
||||
)
|
||||
|
||||
secret_name = incoming_secret.name
|
||||
secret_description = incoming_secret.description
|
||||
|
||||
custom_secrets = dict(existing_secrets.custom_secrets)
|
||||
existing_secret = custom_secrets.pop(secret_id)
|
||||
|
||||
if secret_name != secret_id and secret_name in custom_secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Secret {secret_name} already exists',
|
||||
)
|
||||
|
||||
custom_secrets[secret_name] = CustomSecret(
|
||||
secret=existing_secret.secret,
|
||||
description=secret_description or '',
|
||||
)
|
||||
|
||||
updated_secrets = Secrets(
|
||||
custom_secrets=custom_secrets, # type: ignore[arg-type]
|
||||
provider_tokens=existing_secrets.provider_tokens,
|
||||
)
|
||||
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return EditResponse(
|
||||
message='Secret updated successfully',
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{secret_id}')
|
||||
async def delete_custom_secret(
|
||||
secret_id: str,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> EditResponse:
|
||||
"""Delete a custom secret.
|
||||
|
||||
Removes a custom secret for the authenticated user.
|
||||
|
||||
Returns:
|
||||
200: Secret deleted successfully
|
||||
404: Secret not found
|
||||
500: Error deleting secret
|
||||
"""
|
||||
existing_secrets = await secrets_store.load()
|
||||
if existing_secrets:
|
||||
# Get existing custom secrets
|
||||
custom_secrets = dict(existing_secrets.custom_secrets)
|
||||
|
||||
# Check if the secret to delete exists
|
||||
if secret_id not in custom_secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Secret with ID {secret_id} not found',
|
||||
)
|
||||
|
||||
# Remove the secret
|
||||
custom_secrets.pop(secret_id)
|
||||
|
||||
# Create a new Secrets that preserves provider tokens and remaining secrets
|
||||
updated_secrets = Secrets(
|
||||
custom_secrets=custom_secrets, # type: ignore[arg-type]
|
||||
provider_tokens=existing_secrets.provider_tokens,
|
||||
)
|
||||
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return EditResponse(
|
||||
message='Secret deleted successfully',
|
||||
)
|
||||
268
openhands/app_server/settings/settings_router.py
Normal file
268
openhands/app_server/settings/settings_router.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Settings router for OpenHands App Server.
|
||||
|
||||
This module provides the V1 API routes for user settings under /api/v1/settings.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.server.routes.secrets import invalidate_legacy_secrets_store
|
||||
from openhands.server.settings import (
|
||||
GETSettingsModel,
|
||||
)
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
get_user_settings,
|
||||
get_user_settings_store,
|
||||
)
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.llm import get_provider_api_base, is_openhands_model
|
||||
|
||||
LITE_LLM_API_URL = os.environ.get(
|
||||
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
|
||||
)
|
||||
|
||||
# Create router with /api/v1/settings prefix
|
||||
router = APIRouter(
|
||||
prefix='/settings',
|
||||
tags=['Settings'],
|
||||
dependencies=get_dependencies(),
|
||||
)
|
||||
|
||||
|
||||
async def store_llm_settings(
|
||||
settings: Settings, existing_settings: Settings
|
||||
) -> Settings:
|
||||
"""Merge LLM settings with existing settings."""
|
||||
if not existing_settings:
|
||||
return settings
|
||||
|
||||
# Preserve unset LLM settings
|
||||
settings.llm_api_key = settings.llm_api_key or existing_settings.llm_api_key
|
||||
settings.llm_model = settings.llm_model or existing_settings.llm_model
|
||||
|
||||
if settings.llm_base_url is None:
|
||||
# Not provided at all (e.g. MCP config save) - preserve existing or auto-detect
|
||||
if existing_settings.llm_base_url:
|
||||
settings.llm_base_url = existing_settings.llm_base_url
|
||||
elif is_openhands_model(settings.llm_model):
|
||||
# OpenHands models use the LiteLLM proxy
|
||||
settings.llm_base_url = LITE_LLM_API_URL
|
||||
elif settings.llm_model:
|
||||
# For non-openhands models, try to get URL from litellm
|
||||
try:
|
||||
api_base = get_provider_api_base(settings.llm_model)
|
||||
if api_base:
|
||||
settings.llm_base_url = api_base
|
||||
else:
|
||||
logger.debug(
|
||||
f'No api_base found in litellm for model: {settings.llm_model}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Failed to get api_base from litellm for model {settings.llm_model}: {e}'
|
||||
)
|
||||
elif settings.llm_base_url == '':
|
||||
# Explicitly cleared by the user (basic view save or advanced view clear)
|
||||
settings.llm_base_url = None
|
||||
|
||||
settings.search_api_key = (
|
||||
settings.search_api_key or existing_settings.search_api_key
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
def convert_to_settings(settings_with_token_data: Settings) -> Settings:
|
||||
"""Convert settings with token data to Settings model."""
|
||||
settings_data = settings_with_token_data.model_dump()
|
||||
|
||||
# Filter out additional fields from `SettingsWithTokenData`
|
||||
filtered_settings_data = {
|
||||
key: value
|
||||
for key, value in settings_data.items()
|
||||
if key in Settings.model_fields # Ensures only `Settings` fields are included
|
||||
}
|
||||
|
||||
# Convert the API keys to `SecretStr` instances
|
||||
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
|
||||
filtered_settings_data['search_api_key'] = settings_with_token_data.search_api_key
|
||||
|
||||
# Create a new Settings instance
|
||||
settings = Settings(**filtered_settings_data)
|
||||
return settings
|
||||
|
||||
|
||||
# NOTE: We use response_model=None for endpoints that return JSONResponse directly.
|
||||
# This is because FastAPI's response_model expects a Pydantic model, but we're returning
|
||||
# a response object directly. We document the possible responses using the 'responses'
|
||||
# parameter and maintain proper type annotations for mypy.
|
||||
@router.get(
|
||||
'',
|
||||
response_model=GETSettingsModel,
|
||||
responses={
|
||||
404: {'description': 'Settings not found', 'model': dict},
|
||||
401: {'description': 'Invalid token', 'model': dict},
|
||||
},
|
||||
)
|
||||
async def load_settings(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
"""Load user settings.
|
||||
|
||||
Retrieves the settings for the authenticated user, including LLM configuration,
|
||||
provider tokens, and other user preferences.
|
||||
|
||||
Returns:
|
||||
GETSettingsModel: The user settings with token data
|
||||
|
||||
Raises:
|
||||
404: Settings not found
|
||||
401: Invalid token
|
||||
"""
|
||||
try:
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Settings not found'},
|
||||
)
|
||||
|
||||
# On initial load, user secrets may not be populated with values migrated from settings store
|
||||
user_secrets = await invalidate_legacy_secrets_store(
|
||||
settings, settings_store, secrets_store
|
||||
)
|
||||
|
||||
# If invalidation is successful, then the returned user secrets holds the most recent values
|
||||
git_providers = (
|
||||
user_secrets.provider_tokens if user_secrets else provider_tokens
|
||||
)
|
||||
|
||||
provider_tokens_set: dict[ProviderType, str | None] = {}
|
||||
if git_providers:
|
||||
for provider_type, provider_token in git_providers.items():
|
||||
if provider_token.token or provider_token.user_id:
|
||||
provider_tokens_set[provider_type] = provider_token.host
|
||||
|
||||
settings_with_token_data = GETSettingsModel(
|
||||
**settings.model_dump(exclude={'secrets_store'}),
|
||||
llm_api_key_set=settings.llm_api_key is not None
|
||||
and bool(settings.llm_api_key),
|
||||
search_api_key_set=settings.search_api_key is not None
|
||||
and bool(settings.search_api_key),
|
||||
provider_tokens_set=provider_tokens_set,
|
||||
)
|
||||
|
||||
# If the base url matches the default for the provider, we don't send it
|
||||
# So that the frontend can display basic mode
|
||||
if is_openhands_model(settings.llm_model):
|
||||
if settings.llm_base_url == LITE_LLM_API_URL:
|
||||
settings_with_token_data.llm_base_url = None
|
||||
elif settings.llm_model and settings.llm_base_url == get_provider_api_base(
|
||||
settings.llm_model
|
||||
):
|
||||
settings_with_token_data.llm_base_url = None
|
||||
|
||||
settings_with_token_data.llm_api_key = None
|
||||
settings_with_token_data.search_api_key = None
|
||||
settings_with_token_data.sandbox_api_key = None
|
||||
return settings_with_token_data
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
# Get user_id from settings if available
|
||||
user_id = getattr(settings, 'user_id', 'unknown') if settings else 'unknown'
|
||||
logger.info(
|
||||
f'Returning 401 Unauthorized - Invalid token for user_id: {user_id}'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Invalid token'},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
'',
|
||||
response_model=None,
|
||||
responses={
|
||||
200: {'description': 'Settings stored successfully', 'model': dict},
|
||||
500: {'description': 'Error storing settings', 'model': dict},
|
||||
},
|
||||
)
|
||||
async def store_settings(
|
||||
settings: Settings,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
"""Store user settings.
|
||||
|
||||
Saves the user settings including LLM configuration, provider tokens,
|
||||
and other user preferences.
|
||||
|
||||
Returns:
|
||||
200: Settings stored successfully
|
||||
500: Error storing settings
|
||||
"""
|
||||
# Check provider tokens are valid
|
||||
try:
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
settings = await store_llm_settings(settings, existing_settings)
|
||||
|
||||
# Keep existing analytics consent if not provided
|
||||
if settings.user_consents_to_analytics is None:
|
||||
settings.user_consents_to_analytics = (
|
||||
existing_settings.user_consents_to_analytics
|
||||
)
|
||||
|
||||
# Keep existing disabled_skills if not provided
|
||||
if settings.disabled_skills is None:
|
||||
settings.disabled_skills = existing_settings.disabled_skills
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
config.sandbox.remote_runtime_resource_factor = (
|
||||
settings.remote_runtime_resource_factor
|
||||
)
|
||||
|
||||
# Update git configuration with new settings
|
||||
git_config_updated = False
|
||||
if settings.git_user_name is not None:
|
||||
config.git_user_name = settings.git_user_name
|
||||
git_config_updated = True
|
||||
if settings.git_user_email is not None:
|
||||
config.git_user_email = settings.git_user_email
|
||||
git_config_updated = True
|
||||
|
||||
# Note: Git configuration will be applied when new sessions are initialized
|
||||
# Existing sessions will continue with their current git configuration
|
||||
if git_config_updated:
|
||||
logger.info(
|
||||
f'Updated global git configuration: name={config.git_user_name}, email={config.git_user_email}'
|
||||
)
|
||||
|
||||
settings = convert_to_settings(settings)
|
||||
await settings_store.store(settings)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Settings stored'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong storing settings: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong storing settings'},
|
||||
)
|
||||
@@ -5,9 +5,9 @@ import yaml
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.memory.memory import GLOBAL_MICROAGENTS_DIR, USER_MICROAGENTS_DIR
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
|
||||
router = APIRouter(prefix='/skills', tags=['Skills'], dependencies=get_dependencies())
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from openhands.app_server.config import depends_user_context
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
import os
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
7
openhands/app_server/utils/models.py
Normal file
7
openhands/app_server/utils/models.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EditResponse(BaseModel):
|
||||
"""General response to an edit operation"""
|
||||
|
||||
message: str
|
||||
@@ -9,6 +9,12 @@ from openhands.app_server.pending_messages.pending_message_router import (
|
||||
router as pending_message_router,
|
||||
)
|
||||
from openhands.app_server.sandbox import sandbox_router, sandbox_spec_router
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
router as secrets_router,
|
||||
)
|
||||
from openhands.app_server.settings.settings_router import (
|
||||
router as settings_router,
|
||||
)
|
||||
from openhands.app_server.user import skills_router, user_router
|
||||
from openhands.app_server.web_client import web_client_router
|
||||
|
||||
@@ -19,6 +25,8 @@ router.include_router(app_conversation_router.router)
|
||||
router.include_router(pending_message_router)
|
||||
router.include_router(sandbox_router.router)
|
||||
router.include_router(sandbox_spec_router.router)
|
||||
router.include_router(settings_router)
|
||||
router.include_router(secrets_router)
|
||||
router.include_router(user_router.router)
|
||||
router.include_router(skills_router.router)
|
||||
router.include_router(webhook_router.router)
|
||||
|
||||
@@ -19,6 +19,7 @@ from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationInfo,
|
||||
)
|
||||
from openhands.app_server.config import depends_app_conversation_info_service
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event_filter import EventFilter
|
||||
@@ -27,7 +28,6 @@ from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.microagent.types import InputMetadata
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.server.shared import conversation_manager, file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
@@ -83,7 +83,7 @@ def _get_v0_conversation_config(
|
||||
}
|
||||
|
||||
|
||||
@app.get('/config')
|
||||
@app.get('/config', deprecated=True)
|
||||
async def get_remote_runtime_config(
|
||||
conversation_id: str,
|
||||
app_conversation_info_service: AppConversationInfoService = app_conversation_info_service_dependency,
|
||||
@@ -91,6 +91,10 @@ async def get_remote_runtime_config(
|
||||
) -> JSONResponse:
|
||||
"""Retrieve the runtime configuration.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. The config is now returned as part of
|
||||
GET /api/v1/app-conversation/{conversation_id}. Use that endpoint instead.
|
||||
|
||||
For V0 conversations: returns runtime_id and session_id from the runtime.
|
||||
For V1 conversations: returns sandbox_id as runtime_id and conversation_id as session_id.
|
||||
"""
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.server.utils import get_conversation
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
@@ -24,7 +24,7 @@ app = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
@app.post('/submit-feedback')
|
||||
@app.post('/submit-feedback', deprecated=True)
|
||||
async def submit_feedback(
|
||||
request: Request, conversation: ServerConversation = Depends(get_conversation)
|
||||
) -> JSONResponse:
|
||||
@@ -32,6 +32,10 @@ async def submit_feedback(
|
||||
|
||||
This function stores the provided feedback data.
|
||||
|
||||
.. deprecated::
|
||||
Submitting feedback in OSS doesn't really make sense. This endpoint is deprecated
|
||||
and will be removed.
|
||||
|
||||
To submit feedback:
|
||||
```sh
|
||||
curl -X POST -d '{"email": "test@example.com"}' -H "Authorization:"
|
||||
|
||||
@@ -14,10 +14,10 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.exceptions import AgentRuntimeUnavailableError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.file_config import FILES_TO_IGNORE
|
||||
from openhands.server.files import POSTUploadFilesModel
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
@@ -32,7 +33,6 @@ from openhands.microagent.types import (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
@@ -308,6 +308,7 @@ def _extract_repo_name(repository_name: str) -> str:
|
||||
@app.get(
|
||||
'/repository/{repository_name:path}/microagents',
|
||||
response_model=list[MicroagentResponse],
|
||||
deprecated=True,
|
||||
)
|
||||
async def get_repository_microagents(
|
||||
repository_name: str,
|
||||
@@ -317,6 +318,10 @@ async def get_repository_microagents(
|
||||
) -> list[MicroagentResponse] | JSONResponse:
|
||||
"""Scan the microagents directory of a repository and return the list of microagents.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. The microagents UI has already been removed
|
||||
and is not supported in V1.
|
||||
|
||||
The microagents directory location depends on the git provider and actual repository name:
|
||||
- If git provider is not GitLab and actual repository name is ".openhands": scans "microagents" folder
|
||||
- If git provider is GitLab and actual repository name is "openhands-config": scans "microagents" folder
|
||||
@@ -371,6 +376,7 @@ async def get_repository_microagents(
|
||||
@app.get(
|
||||
'/repository/{repository_name:path}/microagents/content',
|
||||
response_model=MicroagentContentResponse,
|
||||
deprecated=True,
|
||||
)
|
||||
async def get_repository_microagent_content(
|
||||
repository_name: str,
|
||||
@@ -383,6 +389,10 @@ async def get_repository_microagent_content(
|
||||
) -> MicroagentContentResponse | JSONResponse:
|
||||
"""Fetch the content of a specific microagent file from a repository.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. The microagents UI has already been removed
|
||||
and is not supported in V1.
|
||||
|
||||
Args:
|
||||
repository_name: Repository name in the format 'owner/repo' or 'domain/owner/repo'
|
||||
file_path: Query parameter - Path to the microagent file within the repository
|
||||
|
||||
@@ -47,6 +47,7 @@ from openhands.app_server.services.db_session_injector import set_db_session_kee
|
||||
from openhands.app_server.services.httpx_client_injector import (
|
||||
set_httpx_client_keep_open,
|
||||
)
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.mcp_config import MCPConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -77,7 +78,6 @@ from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
@@ -704,13 +704,18 @@ async def _delete_v0_conversation(conversation_id: str, user_id: str | None) ->
|
||||
return True
|
||||
|
||||
|
||||
@app.get('/conversations/{conversation_id}/remember-prompt')
|
||||
@app.get('/conversations/{conversation_id}/remember-prompt', deprecated=True)
|
||||
async def get_prompt(
|
||||
event_id: int,
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
user_settings: SettingsStore = Depends(get_user_settings_store),
|
||||
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||
):
|
||||
"""Get the remember prompt for the microagent UI.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. Microagent UI is deprecated in V1.
|
||||
"""
|
||||
# get event store for the conversation
|
||||
event_store = EventStore(
|
||||
sid=conversation_id, file_store=file_store, user_id=metadata.user_id
|
||||
@@ -1467,7 +1472,7 @@ def _create_combined_page_id(
|
||||
return base64.b64encode(json.dumps(next_page_data).encode()).decode()
|
||||
|
||||
|
||||
@app.get('/microagent-management/conversations')
|
||||
@app.get('/microagent-management/conversations', deprecated=True)
|
||||
async def get_microagent_management_conversations(
|
||||
selected_repository: str,
|
||||
page_id: str | None = None,
|
||||
@@ -1478,6 +1483,9 @@ async def get_microagent_management_conversations(
|
||||
) -> ConversationInfoResultSet:
|
||||
"""Get conversations for the microagent management page with pagination support.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. Microagent UI is deprecated in V1.
|
||||
|
||||
This endpoint returns conversations with conversation_trigger = 'microagent_management'
|
||||
and only includes conversations with active PRs. Pagination is supported.
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.security.options import SecurityAnalyzers
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import config, server_config
|
||||
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
|
||||
|
||||
@@ -36,10 +36,14 @@ async def get_litellm_models(
|
||||
return models
|
||||
|
||||
|
||||
@app.get('/agents', response_model=list[str])
|
||||
@app.get('/agents', response_model=list[str], deprecated=True)
|
||||
async def get_agents() -> list[str]:
|
||||
"""Get all agents supported by LiteLLM.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. The agent definitions are now part of the
|
||||
OpenAPI schema so this is no longer required.
|
||||
|
||||
To get the agents:
|
||||
```sh
|
||||
curl http://localhost:3000/api/agents
|
||||
@@ -51,10 +55,14 @@ async def get_agents() -> list[str]:
|
||||
return sorted(Agent.list_agents())
|
||||
|
||||
|
||||
@app.get('/security-analyzers', response_model=list[str])
|
||||
@app.get('/security-analyzers', response_model=list[str], deprecated=True)
|
||||
async def get_security_analyzers() -> list[str]:
|
||||
"""Get all supported security analyzers.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. The security analyzers are now part of the
|
||||
OpenAPI schema so this is no longer required.
|
||||
|
||||
To get the security analyzers:
|
||||
```sh
|
||||
curl http://localhost:3000/api/security-analyzers
|
||||
|
||||
@@ -7,13 +7,28 @@
|
||||
# Tag: Legacy-V0
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, CustomSecret
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.integrations.utils import validate_provider_token
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
create_custom_secret as v1_create_custom_secret,
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
delete_custom_secret as v1_delete_custom_secret,
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
load_custom_secrets_names as v1_load_custom_secrets_names,
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
store_provider_tokens as v1_store_provider_tokens,
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
unset_provider_tokens as v1_unset_provider_tokens,
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
update_custom_secret as v1_update_custom_secret,
|
||||
)
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.models import EditResponse
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.server.settings import (
|
||||
CustomSecretModel,
|
||||
CustomSecretWithoutValueModel,
|
||||
@@ -60,126 +75,20 @@ async def invalidate_legacy_secrets_store(
|
||||
return None
|
||||
|
||||
|
||||
def process_token_validation_result(
|
||||
confirmed_token_type: ProviderType | None, token_type: ProviderType
|
||||
) -> str:
|
||||
if not confirmed_token_type or confirmed_token_type != token_type:
|
||||
return (
|
||||
f'Invalid token. Please make sure it is a valid {token_type.value} token.'
|
||||
)
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
async def check_provider_tokens(
|
||||
incoming_provider_tokens: POSTProviderModel,
|
||||
existing_provider_tokens: PROVIDER_TOKEN_TYPE | None,
|
||||
) -> str:
|
||||
msg = ''
|
||||
if incoming_provider_tokens.provider_tokens:
|
||||
# Determine whether tokens are valid
|
||||
for token_type, token_value in incoming_provider_tokens.provider_tokens.items():
|
||||
if token_value.token:
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
token_value.token, token_value.host
|
||||
) # FE always sends latest host
|
||||
msg = process_token_validation_result(confirmed_token_type, token_type)
|
||||
|
||||
existing_token = (
|
||||
existing_provider_tokens.get(token_type, None)
|
||||
if existing_provider_tokens
|
||||
else None
|
||||
)
|
||||
if (
|
||||
existing_token
|
||||
and (existing_token.host != token_value.host)
|
||||
and existing_token.token
|
||||
):
|
||||
confirmed_token_type = await validate_provider_token(
|
||||
existing_token.token, token_value.host
|
||||
) # Host has changed, check it against existing token
|
||||
if not confirmed_token_type or confirmed_token_type != token_type:
|
||||
msg = process_token_validation_result(
|
||||
confirmed_token_type, token_type
|
||||
)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
@app.post('/add-git-providers')
|
||||
@app.post('/add-git-providers', deprecated=True)
|
||||
async def store_provider_tokens(
|
||||
provider_info: POSTProviderModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
) -> JSONResponse:
|
||||
provider_err_msg = await check_provider_tokens(provider_info, provider_tokens)
|
||||
if provider_err_msg:
|
||||
# We don't have direct access to user_id here, but we can log the provider info
|
||||
logger.info(
|
||||
f'Returning 401 Unauthorized - Provider token error: {provider_err_msg}'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': provider_err_msg},
|
||||
)
|
||||
|
||||
try:
|
||||
user_secrets = await secrets_store.load()
|
||||
if not user_secrets:
|
||||
user_secrets = Secrets()
|
||||
|
||||
if provider_info.provider_tokens:
|
||||
existing_providers = [provider for provider in user_secrets.provider_tokens]
|
||||
|
||||
# Merge incoming settings store with the existing one
|
||||
for provider, token_value in list(provider_info.provider_tokens.items()):
|
||||
if provider in existing_providers and not token_value.token:
|
||||
existing_token = user_secrets.provider_tokens.get(provider)
|
||||
if existing_token and existing_token.token:
|
||||
provider_info.provider_tokens[provider] = existing_token
|
||||
|
||||
provider_info.provider_tokens[provider] = provider_info.provider_tokens[
|
||||
provider
|
||||
].model_copy(update={'host': token_value.host})
|
||||
|
||||
updated_secrets = user_secrets.model_copy(
|
||||
update={'provider_tokens': provider_info.provider_tokens}
|
||||
)
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Git providers stored'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong storing git providers: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong storing git providers'},
|
||||
)
|
||||
) -> EditResponse:
|
||||
return await v1_store_provider_tokens(provider_info, secrets_store, provider_tokens)
|
||||
|
||||
|
||||
@app.post('/unset-provider-tokens', response_model=dict[str, str])
|
||||
@app.post('/unset-provider-tokens', deprecated=True)
|
||||
async def unset_provider_tokens(
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
user_secrets = await secrets_store.load()
|
||||
if user_secrets:
|
||||
updated_secrets = user_secrets.model_copy(update={'provider_tokens': {}})
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Unset Git provider tokens'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong unsetting tokens: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong unsetting tokens'},
|
||||
)
|
||||
) -> EditResponse:
|
||||
return await v1_unset_provider_tokens(secrets_store)
|
||||
|
||||
|
||||
# =================================================
|
||||
@@ -187,170 +96,33 @@ async def unset_provider_tokens(
|
||||
# =================================================
|
||||
|
||||
|
||||
@app.get('/secrets', response_model=GETCustomSecrets)
|
||||
@app.get('/secrets', response_model=GETCustomSecrets, deprecated=True)
|
||||
async def load_custom_secrets_names(
|
||||
user_secrets: Secrets | None = Depends(get_secrets),
|
||||
) -> GETCustomSecrets | JSONResponse:
|
||||
try:
|
||||
if not user_secrets:
|
||||
return GETCustomSecrets(custom_secrets=[])
|
||||
|
||||
custom_secrets: list[CustomSecretWithoutValueModel] = []
|
||||
if user_secrets.custom_secrets:
|
||||
for secret_name, secret_value in user_secrets.custom_secrets.items():
|
||||
custom_secret = CustomSecretWithoutValueModel(
|
||||
name=secret_name,
|
||||
description=secret_value.description,
|
||||
)
|
||||
custom_secrets.append(custom_secret)
|
||||
|
||||
return GETCustomSecrets(custom_secrets=custom_secrets)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to load secret names: {e}')
|
||||
logger.info('Returning 401 Unauthorized - Failed to get secret names')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Failed to get secret names'},
|
||||
)
|
||||
) -> GETCustomSecrets:
|
||||
return await v1_load_custom_secrets_names(user_secrets)
|
||||
|
||||
|
||||
@app.post('/secrets', response_model=dict[str, str])
|
||||
@app.post('/secrets', status_code=status.HTTP_201_CREATED, deprecated=True)
|
||||
async def create_custom_secret(
|
||||
incoming_secret: CustomSecretModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
existing_secrets = await secrets_store.load()
|
||||
custom_secrets = (
|
||||
dict(existing_secrets.custom_secrets) if existing_secrets else {}
|
||||
)
|
||||
|
||||
secret_name = incoming_secret.name
|
||||
secret_value = incoming_secret.value
|
||||
secret_description = incoming_secret.description
|
||||
|
||||
if secret_name in custom_secrets:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'message': f'Secret {secret_name} already exists'},
|
||||
)
|
||||
|
||||
custom_secrets[secret_name] = CustomSecret(
|
||||
secret=secret_value,
|
||||
description=secret_description or '',
|
||||
)
|
||||
|
||||
# Create a new Secrets that preserves provider tokens
|
||||
updated_user_secrets = Secrets(
|
||||
custom_secrets=custom_secrets, # type: ignore[arg-type]
|
||||
provider_tokens=existing_secrets.provider_tokens
|
||||
if existing_secrets
|
||||
else {}, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
await secrets_store.store(updated_user_secrets)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
content={'message': 'Secret created successfully'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong creating secret: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong creating secret'},
|
||||
)
|
||||
) -> EditResponse:
|
||||
return await v1_create_custom_secret(incoming_secret, secrets_store)
|
||||
|
||||
|
||||
@app.put('/secrets/{secret_id}', response_model=dict[str, str])
|
||||
@app.put('/secrets/{secret_id}', deprecated=True)
|
||||
async def update_custom_secret(
|
||||
secret_id: str,
|
||||
incoming_secret: CustomSecretWithoutValueModel,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
existing_secrets = await secrets_store.load()
|
||||
if existing_secrets:
|
||||
# Check if the secret to update exists
|
||||
if secret_id not in existing_secrets.custom_secrets:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Secret with ID {secret_id} not found'},
|
||||
)
|
||||
|
||||
secret_name = incoming_secret.name
|
||||
secret_description = incoming_secret.description
|
||||
|
||||
custom_secrets = dict(existing_secrets.custom_secrets)
|
||||
existing_secret = custom_secrets.pop(secret_id)
|
||||
|
||||
if secret_name != secret_id and secret_name in custom_secrets:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={'message': f'Secret {secret_name} already exists'},
|
||||
)
|
||||
|
||||
custom_secrets[secret_name] = CustomSecret(
|
||||
secret=existing_secret.secret,
|
||||
description=secret_description or '',
|
||||
)
|
||||
|
||||
updated_secrets = Secrets(
|
||||
custom_secrets=custom_secrets, # type: ignore[arg-type]
|
||||
provider_tokens=existing_secrets.provider_tokens,
|
||||
)
|
||||
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Secret updated successfully'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong updating secret: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong updating secret'},
|
||||
)
|
||||
) -> EditResponse:
|
||||
return await v1_update_custom_secret(secret_id, incoming_secret, secrets_store)
|
||||
|
||||
|
||||
@app.delete('/secrets/{secret_id}')
|
||||
@app.delete('/secrets/{secret_id}', deprecated=True)
|
||||
async def delete_custom_secret(
|
||||
secret_id: str,
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
existing_secrets = await secrets_store.load()
|
||||
if existing_secrets:
|
||||
# Get existing custom secrets
|
||||
custom_secrets = dict(existing_secrets.custom_secrets)
|
||||
|
||||
# Check if the secret to delete exists
|
||||
if secret_id not in custom_secrets:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': f'Secret with ID {secret_id} not found'},
|
||||
)
|
||||
|
||||
# Remove the secret
|
||||
custom_secrets.pop(secret_id)
|
||||
|
||||
# Create a new Secrets that preserves provider tokens and remaining secrets
|
||||
updated_secrets = Secrets(
|
||||
custom_secrets=custom_secrets, # type: ignore[arg-type]
|
||||
provider_tokens=existing_secrets.provider_tokens,
|
||||
)
|
||||
|
||||
await secrets_store.store(updated_secrets)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Secret deleted successfully'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong deleting secret: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong deleting secret'},
|
||||
)
|
||||
) -> EditResponse:
|
||||
return await v1_delete_custom_secret(secret_id, secrets_store)
|
||||
|
||||
@@ -15,7 +15,7 @@ from fastapi import (
|
||||
status,
|
||||
)
|
||||
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.server.session.conversation import ServerConversation
|
||||
from openhands.server.utils import get_conversation
|
||||
|
||||
|
||||
@@ -8,20 +8,22 @@
|
||||
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.app_server.settings.settings_router import (
|
||||
load_settings as v1_load_settings,
|
||||
)
|
||||
from openhands.app_server.settings.settings_router import (
|
||||
store_settings as v1_store_settings,
|
||||
)
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.routes.secrets import invalidate_legacy_secrets_store
|
||||
from openhands.server.settings import (
|
||||
GETSettingsModel,
|
||||
)
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_secrets_store,
|
||||
@@ -31,7 +33,6 @@ from openhands.server.user_auth import (
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
from openhands.utils.llm import get_provider_api_base, is_openhands_model
|
||||
|
||||
LITE_LLM_API_URL = os.environ.get(
|
||||
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
|
||||
@@ -47,6 +48,7 @@ app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
404: {'description': 'Settings not found', 'model': dict},
|
||||
401: {'description': 'Invalid token', 'model': dict},
|
||||
},
|
||||
deprecated=True,
|
||||
)
|
||||
async def load_settings(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
@@ -54,110 +56,11 @@ async def load_settings(
|
||||
settings: Settings = Depends(get_user_settings),
|
||||
secrets_store: SecretsStore = Depends(get_secrets_store),
|
||||
) -> GETSettingsModel | JSONResponse:
|
||||
try:
|
||||
if not settings:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Settings not found'},
|
||||
)
|
||||
|
||||
# On initial load, user secrets may not be populated with values migrated from settings store
|
||||
user_secrets = await invalidate_legacy_secrets_store(
|
||||
settings, settings_store, secrets_store
|
||||
)
|
||||
|
||||
# If invalidation is successful, then the returned user secrets holds the most recent values
|
||||
git_providers = (
|
||||
user_secrets.provider_tokens if user_secrets else provider_tokens
|
||||
)
|
||||
|
||||
provider_tokens_set: dict[ProviderType, str | None] = {}
|
||||
if git_providers:
|
||||
for provider_type, provider_token in git_providers.items():
|
||||
if provider_token.token or provider_token.user_id:
|
||||
provider_tokens_set[provider_type] = provider_token.host
|
||||
|
||||
settings_with_token_data = GETSettingsModel(
|
||||
**settings.model_dump(exclude={'secrets_store'}),
|
||||
llm_api_key_set=settings.llm_api_key is not None
|
||||
and bool(settings.llm_api_key),
|
||||
search_api_key_set=settings.search_api_key is not None
|
||||
and bool(settings.search_api_key),
|
||||
provider_tokens_set=provider_tokens_set,
|
||||
)
|
||||
|
||||
# If the base url matches the default for the provider, we don't send it
|
||||
# So that the frontend can display basic mode
|
||||
if is_openhands_model(settings.llm_model):
|
||||
if settings.llm_base_url == LITE_LLM_API_URL:
|
||||
settings_with_token_data.llm_base_url = None
|
||||
elif settings.llm_model and settings.llm_base_url == get_provider_api_base(
|
||||
settings.llm_model
|
||||
):
|
||||
settings_with_token_data.llm_base_url = None
|
||||
|
||||
settings_with_token_data.llm_api_key = None
|
||||
settings_with_token_data.search_api_key = None
|
||||
settings_with_token_data.sandbox_api_key = None
|
||||
return settings_with_token_data
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid token: {e}')
|
||||
# Get user_id from settings if available
|
||||
user_id = getattr(settings, 'user_id', 'unknown') if settings else 'unknown'
|
||||
logger.info(
|
||||
f'Returning 401 Unauthorized - Invalid token for user_id: {user_id}'
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'Invalid token'},
|
||||
)
|
||||
return await v1_load_settings(
|
||||
provider_tokens, settings_store, settings, secrets_store
|
||||
)
|
||||
|
||||
|
||||
async def store_llm_settings(
|
||||
settings: Settings, existing_settings: Settings
|
||||
) -> Settings:
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
# Keep existing LLM settings if not provided
|
||||
if settings.llm_api_key is None:
|
||||
settings.llm_api_key = existing_settings.llm_api_key
|
||||
if settings.llm_model is None:
|
||||
settings.llm_model = existing_settings.llm_model
|
||||
if settings.llm_base_url is None:
|
||||
# Not provided at all (e.g. MCP config save) - preserve existing or auto-detect
|
||||
if existing_settings.llm_base_url:
|
||||
settings.llm_base_url = existing_settings.llm_base_url
|
||||
elif is_openhands_model(settings.llm_model):
|
||||
# OpenHands models use the LiteLLM proxy
|
||||
settings.llm_base_url = LITE_LLM_API_URL
|
||||
elif settings.llm_model:
|
||||
# For non-openhands models, try to get URL from litellm
|
||||
try:
|
||||
api_base = get_provider_api_base(settings.llm_model)
|
||||
if api_base:
|
||||
settings.llm_base_url = api_base
|
||||
else:
|
||||
logger.debug(
|
||||
f'No api_base found in litellm for model: {settings.llm_model}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Failed to get api_base from litellm for model {settings.llm_model}: {e}'
|
||||
)
|
||||
elif settings.llm_base_url == '':
|
||||
# Explicitly cleared by the user (basic view save or advanced view clear)
|
||||
settings.llm_base_url = None
|
||||
# Keep search API key if missing or empty
|
||||
if not settings.search_api_key:
|
||||
settings.search_api_key = existing_settings.search_api_key
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
# NOTE: We use response_model=None for endpoints that return JSONResponse directly.
|
||||
# This is because FastAPI's response_model expects a Pydantic model, but we're returning
|
||||
# a response object directly. We document the possible responses using the 'responses'
|
||||
# parameter and maintain proper type annotations for mypy.
|
||||
@app.post(
|
||||
'/settings',
|
||||
response_model=None,
|
||||
@@ -165,79 +68,10 @@ async def store_llm_settings(
|
||||
200: {'description': 'Settings stored successfully', 'model': dict},
|
||||
500: {'description': 'Error storing settings', 'model': dict},
|
||||
},
|
||||
deprecated=True,
|
||||
)
|
||||
async def store_settings(
|
||||
settings: Settings,
|
||||
settings_store: SettingsStore = Depends(get_user_settings_store),
|
||||
) -> JSONResponse:
|
||||
# Check provider tokens are valid
|
||||
try:
|
||||
existing_settings = await settings_store.load()
|
||||
|
||||
# Convert to Settings model and merge with existing settings
|
||||
if existing_settings:
|
||||
settings = await store_llm_settings(settings, existing_settings)
|
||||
|
||||
# Keep existing analytics consent if not provided
|
||||
if settings.user_consents_to_analytics is None:
|
||||
settings.user_consents_to_analytics = (
|
||||
existing_settings.user_consents_to_analytics
|
||||
)
|
||||
|
||||
# Keep existing disabled_skills if not provided
|
||||
if settings.disabled_skills is None:
|
||||
settings.disabled_skills = existing_settings.disabled_skills
|
||||
|
||||
# Update sandbox config with new settings
|
||||
if settings.remote_runtime_resource_factor is not None:
|
||||
config.sandbox.remote_runtime_resource_factor = (
|
||||
settings.remote_runtime_resource_factor
|
||||
)
|
||||
|
||||
# Update git configuration with new settings
|
||||
git_config_updated = False
|
||||
if settings.git_user_name is not None:
|
||||
config.git_user_name = settings.git_user_name
|
||||
git_config_updated = True
|
||||
if settings.git_user_email is not None:
|
||||
config.git_user_email = settings.git_user_email
|
||||
git_config_updated = True
|
||||
|
||||
# Note: Git configuration will be applied when new sessions are initialized
|
||||
# Existing sessions will continue with their current git configuration
|
||||
if git_config_updated:
|
||||
logger.info(
|
||||
f'Updated global git configuration: name={config.git_user_name}, email={config.git_user_email}'
|
||||
)
|
||||
|
||||
settings = convert_to_settings(settings)
|
||||
await settings_store.store(settings)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'message': 'Settings stored'},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Something went wrong storing settings: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': 'Something went wrong storing settings'},
|
||||
)
|
||||
|
||||
|
||||
def convert_to_settings(settings_with_token_data: Settings) -> Settings:
|
||||
settings_data = settings_with_token_data.model_dump()
|
||||
|
||||
# Filter out additional fields from `SettingsWithTokenData`
|
||||
filtered_settings_data = {
|
||||
key: value
|
||||
for key, value in settings_data.items()
|
||||
if key in Settings.model_fields # Ensures only `Settings` fields are included
|
||||
}
|
||||
|
||||
# Convert the API keys to `SecretStr` instances
|
||||
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
|
||||
filtered_settings_data['search_api_key'] = settings_with_token_data.search_api_key
|
||||
|
||||
# Create a new Settings instance
|
||||
settings = Settings(**filtered_settings_data)
|
||||
return settings
|
||||
return await v1_store_settings(settings, settings_store)
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.async_event_store_wrapper import AsyncEventStoreWrapper
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.serialization import event_to_trajectory
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.utils import get_conversation_metadata
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
@@ -24,12 +24,16 @@ app = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
@app.get('/trajectory')
|
||||
@app.get('/trajectory', deprecated=True)
|
||||
async def get_trajectory(
|
||||
metadata: ConversationMetadata = Depends(get_conversation_metadata),
|
||||
) -> JSONResponse:
|
||||
"""Get trajectory.
|
||||
|
||||
.. deprecated::
|
||||
This endpoint is deprecated. Use the V1 endpoint
|
||||
``GET /app-conversation/{conversation_id}/download`` instead.
|
||||
|
||||
This function retrieves the current trajectory and returns it.
|
||||
Uses the local EventStore which reads events from the file store,
|
||||
so it works with both standalone and nested conversation managers.
|
||||
|
||||
168
openhands/utils/_redact_compat.py
Normal file
168
openhands/utils/_redact_compat.py
Normal file
@@ -0,0 +1,168 @@
|
||||
# TODO(OpenHands/evaluation#418): Delete this file and import directly from
|
||||
# openhands.sdk.utils.redact once openhands-sdk >1.16.1 is released.
|
||||
# These functions are copied from the SDK's redact.py to unblock PRs while
|
||||
# waiting for the next SDK release.
|
||||
#
|
||||
# Source of truth: openhands-sdk/openhands/sdk/utils/redact.py
|
||||
# in repo: https://github.com/OpenHands/software-agent-sdk
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from openhands.sdk.utils.redact import sanitize_dict
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL param redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SENSITIVE_URL_PARAMS = frozenset(
|
||||
{
|
||||
'tavilyapikey',
|
||||
'apikey',
|
||||
'api_key',
|
||||
'token',
|
||||
'access_token',
|
||||
'secret',
|
||||
'key',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_secret_key(key: str) -> bool:
|
||||
key_upper = key.upper()
|
||||
return any(
|
||||
p in key_upper
|
||||
for p in (
|
||||
'AUTHORIZATION',
|
||||
'COOKIE',
|
||||
'CREDENTIAL',
|
||||
'KEY',
|
||||
'PASSWORD',
|
||||
'SECRET',
|
||||
'SESSION',
|
||||
'TOKEN',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def redact_url_params(url: str) -> str:
|
||||
"""Redact sensitive query parameter values from a URL string."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
except Exception:
|
||||
return url
|
||||
if not parsed.query:
|
||||
return url
|
||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
redacted_params: dict[str, list[str]] = {}
|
||||
for param_name, values in params.items():
|
||||
if param_name.lower() in SENSITIVE_URL_PARAMS or _is_secret_key(param_name):
|
||||
redacted_params[param_name] = ['<redacted>'] * len(values)
|
||||
else:
|
||||
redacted_params[param_name] = values
|
||||
redacted_query = urlencode(redacted_params, doseq=True)
|
||||
return urlunparse(parsed._replace(query=redacted_query))
|
||||
|
||||
|
||||
def _walk_redact_urls(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: _walk_redact_urls(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [_walk_redact_urls(item) for item in obj]
|
||||
if isinstance(obj, str) and '?' in obj:
|
||||
return redact_url_params(obj)
|
||||
return obj
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sanitize_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sanitize_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Deep-copy a config dict, redact secret keys and URL query params."""
|
||||
config = copy.deepcopy(config)
|
||||
config = sanitize_dict(config)
|
||||
config = _walk_redact_urls(config)
|
||||
return config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text / string redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_API_KEY_LITERAL_RE = re.compile(
|
||||
r'\b('
|
||||
# OpenRouter / OpenAI / Anthropic
|
||||
r'sk-(?:or-v1|proj|ant-(?:api|oat)\d{2})-[A-Za-z0-9_-]{20,}'
|
||||
r'|gsk_[A-Za-z0-9]{20,}' # GROQ
|
||||
r'|hf_[A-Za-z0-9]{20,}' # HuggingFace
|
||||
r'|tgp_v1_[A-Za-z0-9_-]{20,}' # Together AI
|
||||
r'|ghp_[A-Za-z0-9]{20,}' # GitHub PAT (classic)
|
||||
r'|github_pat_[A-Za-z0-9_]{20,}' # GitHub PAT (fine-grained)
|
||||
r'|sk-oh-[A-Za-z0-9]{20,}' # OpenHands session tokens
|
||||
r'|ctx7sk-[A-Za-z0-9_-]{10,}' # Context7 MCP keys
|
||||
r'|cla_[A-Za-z0-9_-]{20,}' # Claude.ai MCP tokens
|
||||
r'|sntryu_[A-Za-z0-9]{10,}' # Sentry tokens
|
||||
r'|lin_api_[A-Za-z0-9]{10,}' # Linear API tokens
|
||||
r'|tvly-[A-Za-z0-9_-]{10,}' # Tavily keys
|
||||
r'|ATATT3x[A-Za-z0-9_-]{10,}' # Jira/Atlassian tokens
|
||||
r'|xoxb-[A-Za-z0-9_-]{20,}' # Slack bot tokens
|
||||
r'|xoxp-[A-Za-z0-9_-]{20,}' # Slack user tokens
|
||||
r'|Bearer\s+[A-Za-z0-9_.-]{20,}' # Bearer tokens
|
||||
r')'
|
||||
)
|
||||
|
||||
|
||||
def redact_api_key_literals(text: str) -> str:
|
||||
"""Replace bare API key literals from common providers with <redacted>."""
|
||||
return _API_KEY_LITERAL_RE.sub('<redacted>', text)
|
||||
|
||||
|
||||
def redact_text_secrets(text: str) -> str:
|
||||
"""Redact secrets from a string representation of a config object."""
|
||||
# api_key='...' patterns
|
||||
text = re.sub(r"api_key='[^']*'", "api_key='<redacted>'", text)
|
||||
text = re.sub(r'api_key="[^"]*"', 'api_key="<redacted>"', text)
|
||||
|
||||
# Dict entries with sensitive key names
|
||||
text = re.sub(
|
||||
r"('[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD)[A-Z_]*':\s*')[^']*(')",
|
||||
r'\g<1><redacted>\2',
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
r'("[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD)[A-Z_]*":\s*")[^"]*(")',
|
||||
r'\g<1><redacted>\2',
|
||||
text,
|
||||
)
|
||||
|
||||
# URL query params
|
||||
text = re.sub(
|
||||
r'((?:tavilyApiKey|apiKey|api_key|token|access_token|secret|key)=)'
|
||||
r"[^&\s'\")\]]+",
|
||||
r'\g<1><redacted>',
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Authorization header values
|
||||
text = re.sub(
|
||||
r"('Authorization':\s*')[^']*(')",
|
||||
r'\g<1><redacted>\2',
|
||||
text,
|
||||
)
|
||||
|
||||
# X-Session-API-Key header values
|
||||
text = re.sub(
|
||||
r"('X-Session-API-Key':\s*')[^']*(')",
|
||||
r'\g<1><redacted>\2',
|
||||
text,
|
||||
)
|
||||
|
||||
# Bare API key literals
|
||||
text = redact_api_key_literals(text)
|
||||
|
||||
return text
|
||||
2
poetry.lock
generated
2
poetry.lock
generated
@@ -15028,4 +15028,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "c05d3af6c9418c68c85e848deb19b9a8be2e27f7581cfa81cfc0fe04afc2203f"
|
||||
content-hash = "6f88369a1b446dfbe38c9e0cf52e9bdacfb69aad51a9f56548768d160cdafd95"
|
||||
|
||||
@@ -21,7 +21,7 @@ dynamic = [ "version" ]
|
||||
|
||||
# Main dependencies (mirrors [tool.poetry.dependencies] for UV compatibility)
|
||||
dependencies = [
|
||||
"aiohttp>=3.13.3",
|
||||
"aiohttp>=3.13.5",
|
||||
"anthropic[vertex]",
|
||||
"anyio==4.9",
|
||||
"asyncpg>=0.30",
|
||||
@@ -89,7 +89,7 @@ dependencies = [
|
||||
"qtconsole>=5.6.1",
|
||||
"rapidfuzz>=3.9",
|
||||
"redis>=5.2,<7",
|
||||
"requests>=2.32.5",
|
||||
"requests>=2.33",
|
||||
"setuptools>=78.1.1",
|
||||
"shellingham>=1.5.4",
|
||||
"sqlalchemy[asyncio]>=2.0.40",
|
||||
@@ -167,7 +167,7 @@ authlib = ">=1.6.9" # CVE-2026-27962 (fixed in 1.
|
||||
orjson = ">=3.11.6" # Pinned to fix CVE-2025-67221
|
||||
litellm = ">=1.74.3, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
openai = "2.8.0" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711)
|
||||
aiohttp = ">=3.13.3" # Pin to avoid CVE-2025-69223 (vulnerable versions < 3.13.3)
|
||||
aiohttp = ">=3.13.5" # Pin to avoid CVE-2026-22815 (vulnerable versions < 3.13.4)
|
||||
google-genai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
@@ -228,7 +228,7 @@ pypdf = "^6.9.2"
|
||||
pillow = "^12.1.1"
|
||||
starlette = "^0.49.1"
|
||||
urllib3 = "^2.6.3"
|
||||
requests = "^2.32.5"
|
||||
requests = "^2.33.0"
|
||||
setuptools = ">=78.1.1"
|
||||
|
||||
# TODO: These are integrations that should probably be optional
|
||||
|
||||
@@ -12,7 +12,7 @@ from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from openhands.app_server.event.event_router import batch_get_events, router
|
||||
from openhands.server.dependencies import check_session_api_key
|
||||
from openhands.app_server.utils.dependencies import check_session_api_key
|
||||
|
||||
|
||||
def _make_mock_event_service(search_return=None, batch_get_return=None):
|
||||
|
||||
@@ -413,7 +413,7 @@ def _build_integration_test_app(
|
||||
router as sandbox_router,
|
||||
)
|
||||
from openhands.app_server.user.user_router import router as user_router
|
||||
from openhands.server.dependencies import check_session_api_key
|
||||
from openhands.app_server.utils.dependencies import check_session_api_key
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@@ -9,14 +9,14 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
router as secrets_router,
|
||||
)
|
||||
from openhands.integrations.provider import (
|
||||
CustomSecret,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.server.routes.secrets import (
|
||||
app as secrets_app,
|
||||
)
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.storage.secrets.file_secrets_store import FileSecretsStore
|
||||
@@ -26,12 +26,12 @@ from openhands.storage.secrets.file_secrets_store import FileSecretsStore
|
||||
def test_client():
|
||||
"""Create a test client for the settings API."""
|
||||
app = FastAPI()
|
||||
app.include_router(secrets_app)
|
||||
app.include_router(secrets_router)
|
||||
|
||||
# Mock SESSION_API_KEY to None to disable authentication in tests
|
||||
with patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False):
|
||||
# Clear the SESSION_API_KEY to disable auth dependency
|
||||
with patch('openhands.server.dependencies._SESSION_API_KEY', None):
|
||||
with patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None):
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ async def test_load_custom_secrets_names(test_client, file_secrets_store):
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
response = test_client.get('/secrets')
|
||||
print(response)
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -107,7 +107,7 @@ async def test_load_custom_secrets_names_empty(test_client, file_secrets_store):
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Make the GET request
|
||||
response = test_client.get('/api/secrets')
|
||||
response = test_client.get('/secrets')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the response
|
||||
@@ -130,7 +130,7 @@ async def test_add_custom_secret(test_client, file_secrets_store):
|
||||
|
||||
# Make the POST request to add a custom secret
|
||||
add_secret_data = {'name': 'API_KEY', 'value': 'api-key-value', 'description': None}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
response = test_client.post('/secrets', json=add_secret_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify that the settings were stored with the new secret
|
||||
@@ -158,7 +158,7 @@ async def test_create_custom_secret_with_no_existing_secrets(
|
||||
'value': 'new-api-key-value',
|
||||
'description': 'Test API Key',
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data)
|
||||
response = test_client.post('/secrets', json=add_secret_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify that the settings were stored with the new secret
|
||||
@@ -196,7 +196,7 @@ async def test_update_existing_custom_secret(test_client, file_secrets_store):
|
||||
'name': 'API_KEY',
|
||||
'description': None,
|
||||
}
|
||||
response = test_client.put('/api/secrets/API_KEY', json=update_secret_data)
|
||||
response = test_client.put('/secrets/API_KEY', json=update_secret_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the updated secret
|
||||
@@ -236,7 +236,7 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
|
||||
'value': 'api-key-value',
|
||||
'description': None,
|
||||
}
|
||||
response1 = test_client.post('/api/secrets', json=add_secret_data1)
|
||||
response1 = test_client.post('/secrets', json=add_secret_data1)
|
||||
assert response1.status_code == 201
|
||||
|
||||
# Make the POST request to add second custom secret
|
||||
@@ -245,7 +245,7 @@ async def test_add_multiple_custom_secrets(test_client, file_secrets_store):
|
||||
'value': 'db-password-value',
|
||||
'description': None,
|
||||
}
|
||||
response = test_client.post('/api/secrets', json=add_secret_data2)
|
||||
response = test_client.post('/secrets', json=add_secret_data2)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify that the settings were stored with the new secrets
|
||||
@@ -293,7 +293,7 @@ async def test_delete_custom_secret(test_client, file_secrets_store):
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Make the DELETE request to delete a custom secret
|
||||
response = test_client.delete('/api/secrets/API_KEY')
|
||||
response = test_client.delete('/secrets/API_KEY')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored without the deleted secret
|
||||
@@ -331,7 +331,7 @@ async def test_delete_nonexistent_custom_secret(test_client, file_secrets_store)
|
||||
await file_secrets_store.store(user_secrets)
|
||||
|
||||
# Make the DELETE request to delete a nonexistent custom secret
|
||||
response = test_client.delete('/api/secrets/NONEXISTENT_KEY')
|
||||
response = test_client.delete('/secrets/NONEXISTENT_KEY')
|
||||
assert response.status_code == 404
|
||||
|
||||
# Verify that the settings were stored without changes to existing secrets
|
||||
@@ -360,7 +360,7 @@ async def test_add_git_providers_with_host(test_client, file_secrets_store):
|
||||
|
||||
# Mock check_provider_tokens to return empty string (no error)
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.check_provider_tokens',
|
||||
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
|
||||
AsyncMock(return_value=''),
|
||||
):
|
||||
# Add a GitHub provider with a host
|
||||
@@ -369,7 +369,7 @@ async def test_add_git_providers_with_host(test_client, file_secrets_store):
|
||||
'github': {'token': 'new-github-token', 'host': 'github.enterprise.com'}
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=add_provider_data)
|
||||
response = test_client.post('/secrets/git-providers', json=add_provider_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the settings were stored with the new provider token and host
|
||||
@@ -399,7 +399,7 @@ async def test_add_git_providers_update_host_only(test_client, file_secrets_stor
|
||||
|
||||
# Mock check_provider_tokens to return empty string (no error)
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.check_provider_tokens',
|
||||
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
|
||||
AsyncMock(return_value=''),
|
||||
):
|
||||
# Update only the host
|
||||
@@ -411,7 +411,7 @@ async def test_add_git_providers_update_host_only(test_client, file_secrets_stor
|
||||
}
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=update_host_data)
|
||||
response = test_client.post('/secrets/git-providers', json=update_host_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that the host was updated but the token remains the same
|
||||
@@ -447,9 +447,9 @@ async def test_add_git_providers_invalid_token_with_host(
|
||||
'github': {'token': 'invalid-token', 'host': 'github.enterprise.com'}
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=add_provider_data)
|
||||
response = test_client.post('/secrets/git-providers', json=add_provider_data)
|
||||
assert response.status_code == 401
|
||||
assert 'Invalid token' in response.json()['error']
|
||||
assert 'Invalid token' in response.json()['detail']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -461,7 +461,7 @@ async def test_add_multiple_git_providers_with_hosts(test_client, file_secrets_s
|
||||
|
||||
# Mock check_provider_tokens to return empty string (no error)
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.check_provider_tokens',
|
||||
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
|
||||
AsyncMock(return_value=''),
|
||||
):
|
||||
# Add multiple providers with hosts
|
||||
@@ -471,7 +471,7 @@ async def test_add_multiple_git_providers_with_hosts(test_client, file_secrets_s
|
||||
'gitlab': {'token': 'gitlab-token', 'host': 'gitlab.enterprise.com'},
|
||||
}
|
||||
}
|
||||
response = test_client.post('/api/add-git-providers', json=add_providers_data)
|
||||
response = test_client.post('/secrets/git-providers', json=add_providers_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify that both providers were stored with their respective hosts
|
||||
@@ -65,7 +65,7 @@ def test_client():
|
||||
# Create a test client
|
||||
with (
|
||||
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
|
||||
patch('openhands.server.dependencies._SESSION_API_KEY', None),
|
||||
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
|
||||
patch(
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=MockUserAuth(),
|
||||
@@ -96,13 +96,13 @@ async def test_settings_api_endpoints(test_client):
|
||||
}
|
||||
|
||||
# Make the POST request to store settings
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
response = test_client.post('/api/v1/settings', json=settings_data)
|
||||
|
||||
# We're not checking the exact response, just that it doesn't error
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test the GET settings endpoint
|
||||
response = test_client.get('/api/settings')
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test updating with partial settings
|
||||
@@ -112,11 +112,11 @@ async def test_settings_api_endpoints(test_client):
|
||||
'llm_api_key': None, # Should preserve existing value
|
||||
}
|
||||
|
||||
response = test_client.post('/api/settings', json=partial_settings)
|
||||
response = test_client.post('/api/v1/settings', json=partial_settings)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Test the unset-provider-tokens endpoint
|
||||
response = test_client.post('/api/unset-provider-tokens')
|
||||
response = test_client.delete('/api/v1/secrets/git-providers')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@@ -128,11 +128,11 @@ async def test_search_api_key_preservation(test_client):
|
||||
'search_api_key': 'initial-secret-key',
|
||||
'llm_model': 'gpt-4',
|
||||
}
|
||||
response = test_client.post('/api/settings', json=initial_settings)
|
||||
response = test_client.post('/api/v1/settings', json=initial_settings)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify key is set
|
||||
response = test_client.get('/api/settings')
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
assert response.json()['search_api_key_set'] is True
|
||||
|
||||
@@ -142,11 +142,11 @@ async def test_search_api_key_preservation(test_client):
|
||||
'search_api_key': '', # The frontend sends an empty string here
|
||||
'llm_model': 'claude-3-opus',
|
||||
}
|
||||
response = test_client.post('/api/settings', json=update_settings)
|
||||
response = test_client.post('/api/v1/settings', json=update_settings)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 3. Verify the key was NOT wiped out (The Critical Check)
|
||||
response = test_client.get('/api/settings')
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
# If the bug was present, this would be False
|
||||
assert response.json()['search_api_key_set'] is True
|
||||
@@ -163,11 +163,11 @@ async def test_disabled_skills_persistence(test_client):
|
||||
'llm_api_key': 'test-key',
|
||||
'disabled_skills': ['skill_a', 'skill_b'],
|
||||
}
|
||||
response = test_client.post('/api/settings', json=settings_data)
|
||||
response = test_client.post('/api/v1/settings', json=settings_data)
|
||||
assert response.status_code == 200
|
||||
|
||||
# 2. Retrieve and verify
|
||||
response = test_client.get('/api/settings')
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['disabled_skills'] == ['skill_a', 'skill_b']
|
||||
@@ -176,10 +176,10 @@ async def test_disabled_skills_persistence(test_client):
|
||||
update_settings = {
|
||||
'disabled_skills': ['skill_c'],
|
||||
}
|
||||
response = test_client.post('/api/settings', json=update_settings)
|
||||
response = test_client.post('/api/v1/settings', json=update_settings)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = test_client.get('/api/settings')
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['disabled_skills'] == ['skill_c']
|
||||
@@ -188,10 +188,10 @@ async def test_disabled_skills_persistence(test_client):
|
||||
update_settings = {
|
||||
'disabled_skills': [],
|
||||
}
|
||||
response = test_client.post('/api/settings', json=update_settings)
|
||||
response = test_client.post('/api/v1/settings', json=update_settings)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = test_client.get('/api/settings')
|
||||
response = test_client.get('/api/v1/settings')
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data['disabled_skills'] == []
|
||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.secrets.secrets_router import check_provider_tokens
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import OwnerType, Repository
|
||||
@@ -17,7 +18,6 @@ from openhands.resolver.interfaces.issue import Issue
|
||||
from openhands.resolver.interfaces.issue_definitions import ServiceContextIssue
|
||||
from openhands.resolver.send_pull_request import PR_SIGNATURE, send_pull_request
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.routes.secrets import check_provider_tokens
|
||||
from openhands.server.settings import POSTProviderModel
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
@@ -412,9 +412,10 @@ async def test_check_provider_tokens_with_only_bitbucket():
|
||||
|
||||
# Call check_provider_tokens with the patched validate_provider_token
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.validate_provider_token', mock_validate
|
||||
'openhands.app_server.secrets.secrets_router.validate_provider_token',
|
||||
mock_validate,
|
||||
):
|
||||
result = await check_provider_tokens(post_model, None)
|
||||
await check_provider_tokens(post_model, None)
|
||||
|
||||
# Verify that validate_provider_token was called only once (for Bitbucket)
|
||||
assert mock_validate.call_count == 1
|
||||
@@ -423,9 +424,6 @@ async def test_check_provider_tokens_with_only_bitbucket():
|
||||
args, kwargs = mock_validate.call_args
|
||||
assert args[0].get_secret_value() == 'username:app_password'
|
||||
|
||||
# Verify that no error message was returned
|
||||
assert result == ''
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bitbucket_sort_parameter_mapping():
|
||||
|
||||
@@ -9,13 +9,13 @@ from fastapi.testclient import TestClient
|
||||
from httpcore import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.utils.dependencies import check_session_api_key
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Repository,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
from openhands.server.dependencies import check_session_api_key
|
||||
from openhands.server.routes.git import app as git_app
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
|
||||
@@ -6,16 +6,17 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.secrets.secrets_router import (
|
||||
check_provider_tokens,
|
||||
)
|
||||
from openhands.app_server.settings.settings_router import store_llm_settings
|
||||
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
|
||||
from openhands.integrations.provider import ProviderToken
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.routes.secrets import (
|
||||
app as secrets_router,
|
||||
)
|
||||
from openhands.server.routes.secrets import (
|
||||
check_provider_tokens,
|
||||
)
|
||||
from openhands.server.routes.settings import store_llm_settings
|
||||
from openhands.server.settings import POSTProviderModel
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
@@ -39,10 +40,10 @@ def test_client():
|
||||
|
||||
with (
|
||||
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
|
||||
patch('openhands.server.dependencies._SESSION_API_KEY', None),
|
||||
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
|
||||
patch(
|
||||
'openhands.server.routes.secrets.check_provider_tokens',
|
||||
AsyncMock(return_value=''),
|
||||
'openhands.app_server.secrets.secrets_router.check_provider_tokens',
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
client = TestClient(test_app)
|
||||
@@ -77,14 +78,10 @@ async def test_check_provider_tokens_valid():
|
||||
|
||||
# Mock the validate_provider_token function to return GITHUB for valid tokens
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.validate_provider_token'
|
||||
'openhands.app_server.secrets.secrets_router.validate_provider_token'
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = ProviderType.GITHUB
|
||||
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return empty string for valid token
|
||||
assert result == ''
|
||||
await check_provider_tokens(providers, existing_provider_tokens)
|
||||
mock_validate.assert_called_once()
|
||||
|
||||
|
||||
@@ -99,14 +96,14 @@ async def test_check_provider_tokens_invalid():
|
||||
|
||||
# Mock the validate_provider_token function to return None for invalid tokens
|
||||
with patch(
|
||||
'openhands.server.routes.secrets.validate_provider_token'
|
||||
'openhands.app_server.secrets.secrets_router.validate_provider_token'
|
||||
) as mock_validate:
|
||||
mock_validate.return_value = None
|
||||
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
# Should raise error for invalid token
|
||||
with pytest.raises(AuthError):
|
||||
await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return error message for invalid token
|
||||
assert 'Invalid token' in result
|
||||
mock_validate.assert_called_once()
|
||||
|
||||
|
||||
@@ -120,10 +117,7 @@ async def test_check_provider_tokens_wrong_type():
|
||||
# Empty existing provider tokens
|
||||
existing_provider_tokens = {}
|
||||
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return empty string for no providers
|
||||
assert result == ''
|
||||
await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -134,10 +128,7 @@ async def test_check_provider_tokens_no_tokens():
|
||||
# Empty existing provider tokens
|
||||
existing_provider_tokens = {}
|
||||
|
||||
result = await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
# Should return empty string when no tokens provided
|
||||
assert result == ''
|
||||
await check_provider_tokens(providers, existing_provider_tokens)
|
||||
|
||||
|
||||
# Tests for store_llm_settings
|
||||
@@ -339,7 +330,7 @@ async def test_store_llm_settings_litellm_error_logged():
|
||||
)
|
||||
|
||||
# The function should not raise even if litellm fails
|
||||
with patch('openhands.server.routes.settings.logger') as mock_logger:
|
||||
with patch('openhands.app_server.settings.settings_router.logger') as mock_logger:
|
||||
result = await store_llm_settings(settings, existing_settings)
|
||||
|
||||
# llm_base_url should remain None since litellm couldn't find the model
|
||||
|
||||
@@ -65,7 +65,7 @@ class MockUserAuth(UserAuth):
|
||||
def test_client():
|
||||
with (
|
||||
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
|
||||
patch('openhands.server.dependencies._SESSION_API_KEY', None),
|
||||
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
|
||||
patch(
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=MockUserAuth(),
|
||||
|
||||
@@ -3,11 +3,11 @@ from unittest.mock import patch
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.app_server.settings.settings_router import convert_to_settings
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
from openhands.server.routes.settings import convert_to_settings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
|
||||
82
uv.lock
generated
82
uv.lock
generated
@@ -50,7 +50,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.3"
|
||||
version = "3.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
@@ -61,42 +61,42 @@ dependencies = [
|
||||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3788,7 +3788,7 @@ test = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.13.3" },
|
||||
{ name = "aiohttp", specifier = ">=3.13.5" },
|
||||
{ name = "anthropic", extras = ["vertex"] },
|
||||
{ name = "anyio", specifier = "==4.9" },
|
||||
{ name = "asyncpg", specifier = ">=0.30" },
|
||||
@@ -3859,7 +3859,7 @@ requires-dist = [
|
||||
{ name = "qtconsole", specifier = ">=5.6.1" },
|
||||
{ name = "rapidfuzz", specifier = ">=3.9" },
|
||||
{ name = "redis", specifier = ">=5.2,<7" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "requests", specifier = ">=2.33.0" },
|
||||
{ name = "runloop-api-client", marker = "extra == 'third-party-runtimes'", specifier = "==0.50" },
|
||||
{ name = "setuptools", specifier = ">=78.1.1" },
|
||||
{ name = "shellingham", specifier = ">=1.5.4" },
|
||||
@@ -7910,7 +7910,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
version = "2.33.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -7918,9 +7918,9 @@ dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user