Compare commits

..

24 Commits

Author SHA1 Message Date
openhands 1ef9693780 feat: Implement OAuth 2.0 Device Flow backend
Adds backend support for OAuth Device Authorization Grant (RFC 8628)
to enable CLI authentication via 'openhands login' command.

Components added:
- Database migration for device_auth_sessions table
- DeviceAuthStore for managing device authorization sessions
- API endpoints for device code generation and polling
- HTML verification page for user code entry
- Comprehensive test suite

Database schema:
- device_code (primary key)
- user_code (unique, human-readable)
- user_id (nullable until authorized)
- api_key (nullable until authorized)
- created_at, expires_at (timestamps)
- status (pending/authorized/denied/expired)

API endpoints:
- POST /api/v1/auth/device - Request device code
- POST /api/v1/auth/device/token - Poll for authorization
- POST /api/v1/auth/device/authorize - Web authorization endpoint
- GET /device - User verification page

Security features:
- Cryptographically secure device code generation
- Human-readable user codes (no confusable characters)
- 5-minute expiration on device codes
- One-time use codes
- Status tracking to prevent reuse
- Automatic expired session cleanup

Testing:
- 18 comprehensive unit tests
- Tests for all success and error scenarios
- SQLite in-memory database for fast testing
- Platform-agnostic test design

Integration:
- Wired into enterprise SaaS server
- Compatible with existing auth infrastructure
- Graceful degradation if user denies access

This PR works with CLI PR #174 in OpenHands-CLI repository.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-08 17:34:55 +00:00
Bharath A V 16125f2ae9 Refactor(frontend): move settings-service into api folder and update (#11958) 2025-12-08 14:50:51 +00:00
dependabot[bot] d31950c061 chore(deps): bump the version-all group in /frontend with 6 updates (#11957)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 14:46:08 +00:00
Tim O'Farrell db64abc580 Refactor webhook endpoints to use session API key authentication (#11926)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-08 07:40:01 -07:00
Rohit Malhotra ed7adb335c GitHub V1 Callbacks not trigger by v1 enabled flag (#11923)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-08 03:58:45 +00:00
Cesar Garcia 584517edec docs: fix broken architecture diagram link in openhands/README.md (#11924)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2025-12-07 22:15:58 +00:00
Tim O'Farrell 1a983d2978 APP-190 Add browser screenshot support for V1 conversations (#11919)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-12-06 14:04:01 -07:00
Hiep Le d7b36c9579 fix: switching from own model to breaks functionality (#11916) 2025-12-06 11:21:18 +07:00
Tim O'Farrell 72c7d9c497 APP-216 Support multiple git providers in conversation secrets (#11908)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-05 11:50:45 -07:00
Hiep Le 7811a62491 refactor(frontend): remove max_budget_per_task input element for v1 (#11921) 2025-12-06 00:50:40 +07:00
dependabot[bot] 4344f5ad4e chore(deps): bump the version-all group across 1 directory with 9 updates (#11915)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-05 14:59:19 +00:00
Neha Prasad 17821f782e feat: display command in observation block (#11885)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-12-05 14:45:28 +00:00
Neha Prasad e1b283886f fix: conversation tab state sync across browser tabs (#11680)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-12-05 14:35:10 +00:00
chuckbutkus 1d9cf72e39 JPMC Modifications (#11882) 2025-12-04 23:32:20 -05:00
Hiep Le 59ca8bd9a8 refactor: derive deterministic key id from secret itself (#11905) 2025-12-05 01:41:32 +07:00
Tim O'Farrell 3a9aa90c3a Bumped SDK to V1.4.1 (#11903) 2025-12-04 17:52:27 +00:00
sp.wack 0a98f165e2 chore(frontend): isolate MAJOR dependency bumps from #11869 (#11887)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-04 18:16:55 +04:00
Neha Prasad 6ec477dae2 fix: enable terminal scrollback to view command history (#11883) 2025-12-04 17:29:42 +07:00
Hiep Le d0496fea8c chore: update sdk to latest version (#11897)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-12-04 01:36:19 +07:00
Tim O'Farrell 8f91db8ec4 Replace USE_V1_CONVERSATION_API feature flag with user setting (#11893)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-03 09:02:40 -07:00
sp.wack 816d8acf1f chore(frontend): isolate PATCH dependency bumps from #11869 (#11890)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-03 19:55:35 +04:00
sp.wack 97e6cb1340 chore(frontend): isolate MINOR dependency bumps from #11869 (#11888)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-12-03 14:29:18 +00:00
Hiep Le cd9a3b02cf feat(frontend): display command in observation block in ui (#11884) 2025-12-03 19:55:55 +07:00
Marco Dalalba 14695a8f0e refactor/bugfix: simplify hasOpenHandsSuffix with provider lookup for gitlab/azure (#11877)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-12-03 12:34:21 +00:00
89 changed files with 4088 additions and 3352 deletions
@@ -22,6 +22,7 @@ from integrations.utils import (
HOST_URL,
OPENHANDS_RESOLVER_TEMPLATES_DIR,
)
from integrations.v1_utils import get_saas_user_auth
from jinja2 import Environment, FileSystemLoader
from pydantic import SecretStr
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
@@ -164,8 +165,13 @@ class GithubManager(Manager):
)
if await self.is_job_requested(message):
payload = message.message.get('payload', {})
user_id = payload['sender']['id']
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
github_view = await GithubFactory.create_github_view_from_payload(
message, self.token_manager
message, keycloak_user_id
)
logger.info(
f'[GitHub] Creating job for {github_view.user_info.username} in {github_view.full_repo_name}#{github_view.issue_number}'
@@ -282,8 +288,15 @@ class GithubManager(Manager):
f'[Github]: Error summarizing issue solvability: {str(e)}'
)
saas_user_auth = await get_saas_user_auth(
github_view.user_info.keycloak_user_id, self.token_manager
)
await github_view.create_new_conversation(
self.jinja_env, secret_store.provider_tokens, convo_metadata
self.jinja_env,
secret_store.provider_tokens,
convo_metadata,
saas_user_auth,
)
conversation_id = github_view.conversation_id
@@ -292,14 +305,7 @@ class GithubManager(Manager):
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
)
from openhands.server.shared import ConversationStoreImpl, config
conversation_store = await ConversationStoreImpl.get_instance(
config, github_view.user_info.keycloak_user_id
)
metadata = await conversation_store.get_metadata(conversation_id)
if metadata.conversation_version != 'v1':
if not github_view.v1:
# Create a GithubCallbackProcessor
processor = GithubCallbackProcessor(
github_view=github_view,
+23 -72
View File
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from uuid import UUID, uuid4
from github import Github, GithubIntegration
@@ -8,6 +9,7 @@ from integrations.github.github_types import (
WorkflowRunStatus,
)
from integrations.models import Message
from integrations.resolver_context import ResolverUserContext
from integrations.types import ResolverViewInterface, UserData
from integrations.utils import (
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
@@ -17,7 +19,6 @@ from integrations.utils import (
has_exact_mention,
)
from jinja2 import Environment
from pydantic.dataclasses import dataclass
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
from server.auth.token_manager import TokenManager
from server.config import get_config
@@ -34,18 +35,16 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.config import get_app_conversation_service
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.core.logger import openhands_logger as logger
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.sdk.conversation.secret_source import SecretSource
from openhands.server.services.conversation_service import (
initialize_conversation,
start_conversation,
)
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
@@ -55,52 +54,6 @@ from openhands.utils.async_utils import call_sync_from_async
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
class GithubUserContext(UserContext):
"""User context for GitHub integration that provides user info without web request."""
def __init__(self, keycloak_user_id: str, git_provider_tokens: PROVIDER_TOKEN_TYPE):
self.keycloak_user_id = keycloak_user_id
self.git_provider_tokens = git_provider_tokens
self.settings_store = SaasSettingsStore(
user_id=self.keycloak_user_id,
session_maker=session_maker,
config=get_config(),
)
self.secrets_store = SaasSecretsStore(
self.keycloak_user_id, session_maker, get_config()
)
async def get_user_id(self) -> str | None:
return self.keycloak_user_id
async def get_user_info(self) -> UserInfo:
user_settings = await self.settings_store.load()
return UserInfo(
id=self.keycloak_user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
if provider_type == ProviderType.GITHUB and self.git_provider_tokens:
return self.git_provider_tokens.get(ProviderType.GITHUB)
return None
async def get_secrets(self) -> dict[str, SecretSource]:
# Return empty dict for now - GitHub integration handles secrets separately
user_secrets = await self.secrets_store.load()
return dict(user_secrets.custom_secrets) if user_secrets else {}
async def get_mcp_api_key(self) -> str | None:
raise NotImplementedError()
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
"""Get the user's proactive conversation setting.
@@ -134,7 +87,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
return settings.enable_proactive_conversation_starters
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
async def get_user_v1_enabled_setting(user_id: str) -> bool:
"""Get the user's V1 conversation API setting.
Args:
@@ -143,11 +96,6 @@ async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
Returns:
True if V1 conversations are enabled for this user, False otherwise
"""
# If no user ID is provided, we can't check user settings
if not user_id:
return False
config = get_config()
settings_store = SaasSettingsStore(
user_id=user_id, session_maker=session_maker, config=config
@@ -183,6 +131,7 @@ class GithubIssue(ResolverViewInterface):
title: str
description: str
previous_comments: list[Comment]
v1: bool
async def _load_resolver_context(self):
github_service = GithubServiceImpl(
@@ -245,14 +194,17 @@ class GithubIssue(ResolverViewInterface):
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
conversation_metadata: ConversationMetadata,
saas_user_auth: UserAuth,
):
v1_enabled = await get_user_v1_enabled_setting(self.user_info.keycloak_user_id)
logger.info(
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {v1_enabled}'
)
if v1_enabled:
try:
# Use V1 app conversation service
await self._create_v1_conversation(
jinja_env, git_provider_tokens, conversation_metadata
jinja_env, saas_user_auth, conversation_metadata
)
return
@@ -271,6 +223,7 @@ class GithubIssue(ResolverViewInterface):
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the legacy V0 system."""
logger.info('[GitHub V1]: Creating V0 conversation')
custom_secrets = await self._get_user_secrets()
user_instructions, conversation_instructions = await self._get_instructions(
@@ -292,10 +245,12 @@ class GithubIssue(ResolverViewInterface):
async def _create_v1_conversation(
self,
jinja_env: Environment,
git_provider_tokens: PROVIDER_TOKEN_TYPE,
saas_user_auth: UserAuth,
conversation_metadata: ConversationMetadata,
):
"""Create conversation using the new V1 app conversation system."""
logger.info('[GitHub V1]: Creating V1 conversation')
user_instructions, conversation_instructions = await self._get_instructions(
jinja_env
)
@@ -326,10 +281,7 @@ class GithubIssue(ResolverViewInterface):
)
# Set up the GitHub user context for the V1 system
github_user_context = GithubUserContext(
keycloak_user_id=self.user_info.keycloak_user_id,
git_provider_tokens=git_provider_tokens,
)
github_user_context = ResolverUserContext(saas_user_auth=saas_user_auth)
setattr(injector_state, USER_CONTEXT_ATTR, github_user_context)
async with get_app_conversation_service(
@@ -344,6 +296,8 @@ class GithubIssue(ResolverViewInterface):
f'Failed to start V1 conversation: {task.detail}'
)
self.v1 = True
def _create_github_v1_callback_processor(self):
"""Create a V1 callback processor for GitHub integration."""
from openhands.app_server.event_callback.github_v1_callback_processor import (
@@ -806,7 +760,7 @@ class GithubFactory:
@staticmethod
async def create_github_view_from_payload(
message: Message, token_manager: TokenManager
message: Message, keycloak_user_id: str
) -> ResolverViewInterface:
"""Create the appropriate class (GithubIssue or GithubPRComment) based on the payload.
Also return metadata about the event (e.g., action type).
@@ -816,17 +770,10 @@ class GithubFactory:
user_id = payload['sender']['id']
username = payload['sender']['login']
keyloak_user_id = await token_manager.get_user_id_from_idp_user_id(
user_id, ProviderType.GITHUB
)
if keyloak_user_id is None:
logger.warning(f'Got invalid keyloak user id for GitHub User {user_id} ')
selected_repo = GithubFactory.get_full_repo_name(repo_obj)
is_public_repo = not repo_obj.get('private', True)
user_info = UserData(
user_id=user_id, username=username, keycloak_user_id=keyloak_user_id
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
)
installation_id = message.message['installation']
@@ -850,6 +797,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_issue_comment(message):
@@ -875,6 +823,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_pr_comment(message):
@@ -916,6 +865,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
elif GithubFactory.is_inline_pr_comment(message):
@@ -949,6 +899,7 @@ class GithubFactory:
title='',
description='',
previous_comments=[],
v1=False,
)
else:
@@ -0,0 +1,55 @@
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import ProviderType
from openhands.server.user_auth.user_auth import UserAuth
class ResolverUserContext(UserContext):
"""User context for resolver operations that inherits from UserContext."""
def __init__(
self,
saas_user_auth: UserAuth,
):
self.saas_user_auth = saas_user_auth
async def get_user_id(self) -> str | None:
return await self.saas_user_auth.get_user_id()
async def get_user_info(self) -> UserInfo:
user_settings = await self.saas_user_auth.get_user_settings()
user_id = await self.saas_user_auth.get_user_id()
if user_settings:
return UserInfo(
id=user_id,
**user_settings.model_dump(context={'expose_secrets': True}),
)
return UserInfo(id=user_id)
async def get_authenticated_git_url(self, repository: str) -> str:
# This would need to be implemented based on the git provider tokens
# For now, return a basic HTTPS URL
return f'https://github.com/{repository}.git'
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
# Return the appropriate token from git_provider_tokens
provider_tokens = await self.saas_user_auth.get_provider_tokens()
if provider_tokens:
return provider_tokens.get(provider_type)
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, str]:
"""Get secrets for the user, including custom secrets."""
secrets = await self.saas_user_auth.get_secrets()
if secrets:
return dict(secrets.custom_secrets)
return {}
async def get_mcp_api_key(self) -> str | None:
return await self.saas_user_auth.get_mcp_api_key()
+1 -1
View File
@@ -19,7 +19,7 @@ class PRStatus(Enum):
class UserData(BaseModel):
user_id: int
username: str
keycloak_user_id: str | None
keycloak_user_id: str
@dataclass
+20
View File
@@ -0,0 +1,20 @@
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.auth.token_manager import TokenManager
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth.user_auth import UserAuth
async def get_saas_user_auth(
keycloak_user_id: str, token_manager: TokenManager
) -> UserAuth:
offline_token = await token_manager.load_offline_token(keycloak_user_id)
if offline_token is None:
logger.info('no_offline_token_found')
user_auth = SaasUserAuth(
user_id=keycloak_user_id,
refresh_token=SecretStr(offline_token),
)
return user_auth
@@ -0,0 +1,63 @@
"""create device auth table
Revision ID: 084
Revises: 083
Create Date: 2025-12-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '084'
down_revision: Union[str, None] = '083'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create device_auth_sessions table for OAuth Device Flow."""
op.create_table(
'device_auth_sessions',
sa.Column('device_code', sa.String(255), primary_key=True),
sa.Column('user_code', sa.String(10), unique=True, nullable=False),
sa.Column('user_id', sa.String(255), nullable=True),
sa.Column('api_key', sa.String(255), nullable=True),
sa.Column(
'created_at',
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
)
# Create indices for better performance
op.create_index(
'idx_device_auth_user_code',
'device_auth_sessions',
['user_code'],
)
op.create_index(
'idx_device_auth_expires_at',
'device_auth_sessions',
['expires_at'],
)
op.create_index(
'idx_device_auth_status',
'device_auth_sessions',
['status'],
)
def downgrade() -> None:
"""Drop device_auth_sessions table."""
op.drop_index('idx_device_auth_status', table_name='device_auth_sessions')
op.drop_index('idx_device_auth_expires_at', table_name='device_auth_sessions')
op.drop_index('idx_device_auth_user_code', table_name='device_auth_sessions')
op.drop_table('device_auth_sessions')
+161 -119
View File
@@ -201,14 +201,14 @@ files = [
[[package]]
name = "anthropic"
version = "0.72.0"
version = "0.75.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
{file = "anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b"},
{file = "anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb"},
]
[package.dependencies]
@@ -682,37 +682,37 @@ crt = ["awscrt (==0.27.6)"]
[[package]]
name = "browser-use"
version = "0.9.5"
version = "0.10.1"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
{file = "browser_use-0.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
{file = "browser_use-0.10.1-py3-none-any.whl", hash = "sha256:96e603bfc71098175342cdcb0592519e6f244412e740f0254e4389fdd82a977f"},
{file = "browser_use-0.10.1.tar.gz", hash = "sha256:5f211ecfdf1f9fd186160f10df70dedd661821231e30f1bce40939787abab223"},
]
[package.dependencies]
aiohttp = "3.12.15"
anthropic = ">=0.68.1,<1.0.0"
anthropic = ">=0.72.1,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
cdp-use = ">=1.4.0"
cdp-use = ">=1.4.4"
click = ">=8.1.8"
cloudpickle = ">=3.1.1"
google-api-core = ">=2.25.0"
google-api-python-client = ">=2.174.0"
google-auth = ">=2.40.3"
google-auth-oauthlib = ">=1.2.2"
google-genai = ">=1.29.0,<2.0.0"
google-genai = ">=1.50.0,<2.0.0"
groq = ">=0.30.0"
httpx = ">=0.28.1"
inquirerpy = ">=0.3.4"
markdownify = ">=1.2.0"
mcp = ">=1.10.1"
ollama = ">=0.5.1"
openai = ">=1.99.2,<2.0.0"
openai = ">=2.7.2,<3.0.0"
pillow = ">=11.2.1"
portalocker = ">=2.7.0,<3.0.0"
posthog = ">=3.7.0"
@@ -721,6 +721,7 @@ pydantic = ">=2.11.5"
pyobjc = {version = ">=11.0", markers = "platform_system == \"darwin\""}
pyotp = ">=2.9.0"
pypdf = ">=5.7.0"
python-docx = ">=1.2.0"
python-dotenv = ">=1.0.1"
reportlab = ">=4.0.0"
requests = ">=2.32.3"
@@ -850,14 +851,14 @@ files = [
[[package]]
name = "cdp-use"
version = "1.4.3"
version = "1.4.4"
description = "Type safe generator/client library for CDP"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "cdp_use-1.4.3-py3-none-any.whl", hash = "sha256:c48664604470c2579aa1e677c3e3e7e24c4f300c54804c093d935abb50479ecd"},
{file = "cdp_use-1.4.3.tar.gz", hash = "sha256:9029c04bdc49fbd3939d2bf1988ad8d88e260729c7d5e35c2f6c87591f5a10e9"},
{file = "cdp_use-1.4.4-py3-none-any.whl", hash = "sha256:e37e80e067db2653d6fdf953d4ff9e5d80d75daa27b7c6d48c0261cccbef73e1"},
{file = "cdp_use-1.4.4.tar.gz", hash = "sha256:330a848b517006eb9ad1dc468aa6434d913cf0c6918610760c36c3fdfdba0fab"},
]
[package.dependencies]
@@ -2978,28 +2979,29 @@ testing = ["pytest"]
[[package]]
name = "google-genai"
version = "1.32.0"
version = "1.53.0"
description = "GenAI Python SDK"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "google_genai-1.32.0-py3-none-any.whl", hash = "sha256:c0c4b1d45adf3aa99501050dd73da2f0dea09374002231052d81a6765d15e7f6"},
{file = "google_genai-1.32.0.tar.gz", hash = "sha256:349da3f5ff0e981066bd508585fcdd308d28fc4646f318c8f6d1aa6041f4c7e3"},
{file = "google_genai-1.53.0-py3-none-any.whl", hash = "sha256:65a3f99e5c03c372d872cda7419f5940e723374bb12a2f3ffd5e3e56e8eb2094"},
{file = "google_genai-1.53.0.tar.gz", hash = "sha256:938a26d22f3fd32c6eeeb4276ef204ef82884e63af9842ce3eac05ceb39cbd8d"},
]
[package.dependencies]
anyio = ">=4.8.0,<5.0.0"
google-auth = ">=2.14.1,<3.0.0"
google-auth = {version = ">=2.14.1,<3.0.0", extras = ["requests"]}
httpx = ">=0.28.1,<1.0.0"
pydantic = ">=2.0.0,<3.0.0"
pydantic = ">=2.9.0,<3.0.0"
requests = ">=2.28.1,<3.0.0"
tenacity = ">=8.2.3,<9.2.0"
typing-extensions = ">=4.11.0,<5.0.0"
websockets = ">=13.0.0,<15.1.0"
[package.extras]
aiohttp = ["aiohttp (<4.0.0)"]
aiohttp = ["aiohttp (<3.13.3)"]
local-tokenizer = ["protobuf", "sentencepiece (>=0.2.0)"]
[[package]]
name = "google-resumable-media"
@@ -3055,6 +3057,8 @@ files = [
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
@@ -3064,6 +3068,8 @@ files = [
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
@@ -3073,6 +3079,8 @@ files = [
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
@@ -3082,6 +3090,8 @@ files = [
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
@@ -3089,6 +3099,8 @@ files = [
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
@@ -3098,6 +3110,8 @@ files = [
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
@@ -3166,83 +3180,87 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
[[package]]
name = "grpcio"
version = "1.74.0"
version = "1.67.1"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "grpcio-1.74.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:85bd5cdf4ed7b2d6438871adf6afff9af7096486fcf51818a81b77ef4dd30907"},
{file = "grpcio-1.74.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:68c8ebcca945efff9d86d8d6d7bfb0841cf0071024417e2d7f45c5e46b5b08eb"},
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:e154d230dc1bbbd78ad2fdc3039fa50ad7ffcf438e4eb2fa30bce223a70c7486"},
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8978003816c7b9eabe217f88c78bc26adc8f9304bf6a594b02e5a49b2ef9c11"},
{file = "grpcio-1.74.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d7bd6e3929fd2ea7fbc3f562e4987229ead70c9ae5f01501a46701e08f1ad9"},
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:136b53c91ac1d02c8c24201bfdeb56f8b3ac3278668cbb8e0ba49c88069e1bdc"},
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fe0f540750a13fd8e5da4b3eaba91a785eea8dca5ccd2bc2ffe978caa403090e"},
{file = "grpcio-1.74.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4e4181bfc24413d1e3a37a0b7889bea68d973d4b45dd2bc68bb766c140718f82"},
{file = "grpcio-1.74.0-cp310-cp310-win32.whl", hash = "sha256:1733969040989f7acc3d94c22f55b4a9501a30f6aaacdbccfaba0a3ffb255ab7"},
{file = "grpcio-1.74.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e912d3c993a29df6c627459af58975b2e5c897d93287939b9d5065f000249b5"},
{file = "grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31"},
{file = "grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4"},
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce"},
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3"},
{file = "grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182"},
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d"},
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f"},
{file = "grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4"},
{file = "grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b"},
{file = "grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11"},
{file = "grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8"},
{file = "grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6"},
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5"},
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49"},
{file = "grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7"},
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3"},
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707"},
{file = "grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b"},
{file = "grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c"},
{file = "grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc"},
{file = "grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89"},
{file = "grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01"},
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e"},
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91"},
{file = "grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249"},
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362"},
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f"},
{file = "grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20"},
{file = "grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa"},
{file = "grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24"},
{file = "grpcio-1.74.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:4bc5fca10aaf74779081e16c2bcc3d5ec643ffd528d9e7b1c9039000ead73bae"},
{file = "grpcio-1.74.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:6bab67d15ad617aff094c382c882e0177637da73cbc5532d52c07b4ee887a87b"},
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:655726919b75ab3c34cdad39da5c530ac6fa32696fb23119e36b64adcfca174a"},
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a2b06afe2e50ebfd46247ac3ba60cac523f54ec7792ae9ba6073c12daf26f0a"},
{file = "grpcio-1.74.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f251c355167b2360537cf17bea2cf0197995e551ab9da6a0a59b3da5e8704f9"},
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:8f7b5882fb50632ab1e48cb3122d6df55b9afabc265582808036b6e51b9fd6b7"},
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:834988b6c34515545b3edd13e902c1acdd9f2465d386ea5143fb558f153a7176"},
{file = "grpcio-1.74.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22b834cef33429ca6cc28303c9c327ba9a3fafecbf62fae17e9a7b7163cc43ac"},
{file = "grpcio-1.74.0-cp39-cp39-win32.whl", hash = "sha256:7d95d71ff35291bab3f1c52f52f474c632db26ea12700c2ff0ea0532cb0b5854"},
{file = "grpcio-1.74.0-cp39-cp39-win_amd64.whl", hash = "sha256:ecde9ab49f58433abe02f9ed076c7b5be839cf0153883a6d23995937a82392fa"},
{file = "grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1"},
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
]
[package.extras]
protobuf = ["grpcio-tools (>=1.74.0)"]
protobuf = ["grpcio-tools (>=1.67.1)"]
[[package]]
name = "grpcio-status"
version = "1.71.2"
version = "1.67.1"
description = "Status proto mapping for gRPC"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3"},
{file = "grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50"},
{file = "grpcio_status-1.67.1-py3-none-any.whl", hash = "sha256:16e6c085950bdacac97c779e6a502ea671232385e6e37f258884d6883392c2bd"},
{file = "grpcio_status-1.67.1.tar.gz", hash = "sha256:2bf38395e028ceeecfd8866b081f61628114b384da7d51ae064ddc8d766a5d11"},
]
[package.dependencies]
googleapis-common-protos = ">=1.5.5"
grpcio = ">=1.71.2"
grpcio = ">=1.67.1"
protobuf = ">=5.26.1,<6.0dev"
[[package]]
@@ -4540,42 +4558,39 @@ valkey = ["valkey (>=6)"]
[[package]]
name = "litellm"
version = "1.77.7"
version = "1.80.7"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = ">=3.8.1,<4.0, !=3.9.7"
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = []
develop = false
files = [
{file = "litellm-1.80.7-py3-none-any.whl", hash = "sha256:f7d993f78c1e0e4e1202b2a925cc6540b55b6e5fb055dd342d88b145ab3102ed"},
{file = "litellm-1.80.7.tar.gz", hash = "sha256:3977a8d195aef842d01c18bf9e22984829363c6a4b54daf9a43c9dd9f190b42c"},
]
[package.dependencies]
aiohttp = ">=3.10"
click = "*"
fastuuid = ">=0.13.0"
grpcio = ">=1.62.3,<1.68.0"
httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = "^3.1.2"
jsonschema = "^4.22.0"
openai = ">=1.99.5"
pydantic = "^2.5.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.22.0,<5.0.0"
openai = ">=2.8.0"
pydantic = ">=2.5.0,<3.0.0"
python-dotenv = ">=0.2.0"
tiktoken = ">=0.7.0"
tokenizers = "*"
[package.extras]
caching = ["diskcache (>=5.6.1,<6.0.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.20)", "litellm-proxy-extras (==0.2.25)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"]
semantic-router = ["semantic-router ; python_version >= \"3.9\""]
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.22)", "litellm-proxy-extras (==0.4.9)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
utils = ["numpydoc"]
[package.source]
type = "git"
url = "https://github.com/BerriAI/litellm.git"
reference = "v1.77.7.dev9"
resolved_reference = "763d2f8ccdd8412dbe6d4ac0e136d9ac34dcd4c0"
[[package]]
name = "llvmlite"
version = "0.44.0"
@@ -5644,28 +5659,28 @@ pydantic = ">=2.9"
[[package]]
name = "openai"
version = "1.99.9"
version = "2.8.0"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main", "test"]
files = [
{file = "openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a"},
{file = "openai-1.99.9.tar.gz", hash = "sha256:f2082d155b1ad22e83247c3de3958eb4255b20ccf4a1de2e6681b6957b554e92"},
{file = "openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf"},
{file = "openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
httpx = ">=0.23.0,<1"
jiter = ">=0.4.0,<1"
jiter = ">=0.10.0,<1"
pydantic = ">=1.9.0,<3"
sniffio = "*"
tqdm = ">4"
typing-extensions = ">=4.11,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
realtime = ["websockets (>=13,<16)"]
voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
@@ -5820,14 +5835,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.3.0"
version = "1.4.1"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.3.0-py3-none-any.whl", hash = "sha256:2f87f790c740dc3fb81821c5f9fa375af875fbb937ebca3baa6dc5c035035b3c"},
{file = "openhands_agent_server-1.3.0.tar.gz", hash = "sha256:0a83ae77373f5c41d0ba0e22d8f0f6144d54d55784183a50b7c098c96cd5135c"},
{file = "openhands_agent_server-1.4.1-py3-none-any.whl", hash = "sha256:1e621d15215a48e2398e23c58a791347f06c215c2344053aeb26b562c34a44ee"},
{file = "openhands_agent_server-1.4.1.tar.gz", hash = "sha256:03010a5c8d63bbd5b088458eb75308ef16559018140d75a3644ae5bbc3531bbf"},
]
[package.dependencies]
@@ -5835,6 +5850,7 @@ aiosqlite = ">=0.19"
alembic = ">=1.13"
docker = ">=7.1,<8"
fastapi = ">=0.104"
openhands-sdk = "*"
pydantic = ">=2"
sqlalchemy = ">=2"
uvicorn = ">=0.31.1"
@@ -5843,7 +5859,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "0.62.0"
version = "0.0.0-post.5625+0a98f165e"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5879,15 +5895,15 @@ json-repair = "*"
jupyter_kernel_gateway = "*"
kubernetes = "^33.1.0"
libtmux = ">=0.46.2"
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
litellm = ">=1.74.3, <=1.80.7, !=1.64.4, !=1.67.*"
lmnr = "^0.7.20"
memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openai = "2.8.0"
openhands-aci = "0.3.2"
openhands-agent-server = "1.3.0"
openhands-sdk = "1.3.0"
openhands-tools = "1.3.0"
openhands-agent-server = "1.4.1"
openhands-sdk = "1.4.1"
openhands-tools = "1.4.1"
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5943,21 +5959,21 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.3.0"
version = "1.4.1"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.3.0-py3-none-any.whl", hash = "sha256:feee838346f8e60ea3e4d3391de7cb854314eb8b3c9e3dbbb56f98a784aadc56"},
{file = "openhands_sdk-1.3.0.tar.gz", hash = "sha256:2d060803a78de462121b56dea717a66356922deb02276f37b29fae8af66343fb"},
{file = "openhands_sdk-1.4.1-py3-none-any.whl", hash = "sha256:70e453eab7f9ab6b705198c2615fdd844b21e14b29d78afaf62724f4a440bcdc"},
{file = "openhands_sdk-1.4.1.tar.gz", hash = "sha256:37365de25ed57cf8cc2a8003ab4d7a1fe2a40b49c8e8da84a3f1ea2b522eddf2"},
]
[package.dependencies]
deprecation = ">=2.1.0"
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.77.7.dev9"
litellm = ">=1.80.7"
lmnr = ">=0.7.20"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
@@ -5970,14 +5986,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.3.0"
version = "1.4.1"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.3.0-py3-none-any.whl", hash = "sha256:f31056d87c3058ac92709f9161c7c602daeee3ed0cb4439097b43cda105ed03e"},
{file = "openhands_tools-1.3.0.tar.gz", hash = "sha256:3da46f09e28593677d3e17252ce18584fcc13caab1a73213e66bd7edca2cebe0"},
{file = "openhands_tools-1.4.1-py3-none-any.whl", hash = "sha256:8f40189a08bf80eb4a33219ee9ccc528f9c6c4f2d5c9ab807b06c3f3fe21a612"},
{file = "openhands_tools-1.4.1.tar.gz", hash = "sha256:4c0caf87f520a207d9035191c77b7b5c53eeec996350a24ffaf7f740a6566b22"},
]
[package.dependencies]
@@ -5989,6 +6005,7 @@ func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
tom-swe = ">=1.0.3"
[[package]]
name = "openpyxl"
@@ -13305,6 +13322,31 @@ dev = ["tokenizers[testing]"]
docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"]
testing = ["black (==22.3)", "datasets", "numpy", "pytest", "pytest-asyncio", "requests", "ruff"]
[[package]]
name = "tom-swe"
version = "1.0.3"
description = "Theory of Mind modeling for Software Engineering assistants"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "tom_swe-1.0.3-py3-none-any.whl", hash = "sha256:7b1172b29eb5c8fb7f1975016e7b6a238511b9ac2a7a980bd400dcb4e29773f2"},
{file = "tom_swe-1.0.3.tar.gz", hash = "sha256:57c97d0104e563f15bd39edaf2aa6ac4c3e9444afd437fb92458700d22c6c0f5"},
]
[package.dependencies]
jinja2 = ">=3.0.0"
json-repair = ">=0.1.0"
litellm = ">=1.0.0"
pydantic = ">=2.0.0"
python-dotenv = ">=1.0.0"
tiktoken = ">=0.8.0"
tqdm = ">=4.65.0"
[package.extras]
dev = ["aiofiles (>=23.0.0)", "black (>=22.0.0)", "datasets (>=2.0.0)", "fastapi (>=0.104.0)", "httpx (>=0.25.0)", "huggingface-hub (>=0.0.0)", "isort (>=5.0.0)", "mypy (>=1.0.0)", "numpy (>=1.24.0)", "pandas (>=2.0.0)", "pre-commit (>=3.6.0)", "pytest (>=7.0.0)", "pytest-cov (>=6.2.1)", "rich (>=13.0.0)", "ruff (>=0.3.0)", "typing-extensions (>=4.0.0)", "uvicorn (>=0.24.0)"]
search = ["bm25s (>=0.2.0)", "pystemmer (>=2.2.0)"]
[[package]]
name = "toml"
version = "0.10.2"
+8
View File
@@ -25,6 +25,12 @@ from server.routes.api_keys import api_router as api_keys_router # noqa: E402
from server.routes.auth import api_router, oauth_router # noqa: E402
from server.routes.billing import billing_router # noqa: E402
from server.routes.debugging import add_debugging_routes # noqa: E402
from server.routes.device_auth import ( # noqa: E402
device_page_router,
)
from server.routes.device_auth import ( # noqa: E402
router as device_auth_router,
)
from server.routes.email import api_router as email_router # noqa: E402
from server.routes.event_webhook import event_webhook_router # noqa: E402
from server.routes.feedback import router as feedback_router # noqa: E402
@@ -60,6 +66,8 @@ base_app.mount('/internal/metrics', metrics_app())
base_app.include_router(readiness_router) # Add routes for readiness checks
base_app.include_router(api_router) # Add additional route for github auth
base_app.include_router(oauth_router) # Add additional route for oauth callback
base_app.include_router(device_auth_router) # Add routes for OAuth device flow
base_app.include_router(device_page_router) # Add /device verification page
base_app.include_router(saas_user_router) # Add additional route SAAS user calls
base_app.include_router(
billing_router
+469
View File
@@ -0,0 +1,469 @@
"""OAuth 2.0 Device Authorization Grant routes (RFC 8628).
These routes implement the device authorization flow for CLI authentication.
"""
import secrets
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from storage.database import get_db
from storage.device_auth_store import DeviceAuthStore
from openhands.core.logger import openhands_logger as logger
router = APIRouter(prefix='/api/v1/auth')
device_page_router = APIRouter() # No prefix for /device page
class DeviceCodeRequest(BaseModel):
"""Request model for device code generation (not used, endpoint takes no body)."""
pass
class DeviceCodeResponse(BaseModel):
"""Response model for device code generation."""
device_code: str
user_code: str
verification_uri: str
expires_in: int
interval: int
class DeviceTokenRequest(BaseModel):
"""Request model for token polling."""
device_code: str
class DeviceTokenPendingResponse(BaseModel):
"""Response when authorization is still pending."""
status: Literal['pending']
class DeviceTokenSuccessResponse(BaseModel):
"""Response when authorization is complete."""
api_key: str
class DeviceTokenErrorResponse(BaseModel):
"""Response for token request errors."""
error: str
error_description: str | None = None
@router.post('/device', response_model=DeviceCodeResponse)
async def request_device_code(
request: Request,
db: Session = Depends(get_db),
) -> DeviceCodeResponse:
"""Request a device code for CLI authentication.
This is the first step in the OAuth 2.0 Device Flow.
The CLI calls this endpoint to get a device_code and user_code.
Args:
request: FastAPI request
db: Database session
Returns:
Device code, user code, and verification URI
Raises:
HTTPException: On internal server error
"""
try:
store = DeviceAuthStore(db)
# Create a new device authorization session
# Default expiration: 5 minutes (300 seconds)
device_code, user_code, expires_at = store.create_session(expires_in=300)
# Calculate expires_in from expires_at
now = datetime.now(expires_at.tzinfo)
expires_in = int((expires_at - now).total_seconds())
# Get the base URL from the request
base_url = str(request.base_url).rstrip('/')
logger.info(
f'Device code requested: user_code={user_code}, '
f'device_code={device_code[:8]}..., expires_in={expires_in}s'
)
return DeviceCodeResponse(
device_code=device_code,
user_code=user_code,
verification_uri=f'{base_url}/device',
expires_in=expires_in,
interval=5, # Poll every 5 seconds
)
except Exception as e:
logger.error(f'Error generating device code: {e}', exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to generate device code',
)
@router.post(
'/device/token',
response_model=DeviceTokenSuccessResponse | DeviceTokenPendingResponse,
responses={
200: {
'description': 'Authorization successful or pending',
'content': {
'application/json': {
'examples': {
'success': {
'summary': 'Authorization complete',
'value': {'api_key': 'ohsk_...'},
},
'pending': {
'summary': 'Authorization pending',
'value': {'status': 'pending'},
},
}
}
},
},
400: {
'description': 'Error (expired, denied, etc.)',
'model': DeviceTokenErrorResponse,
},
},
)
async def poll_device_token(
token_request: DeviceTokenRequest,
db: Session = Depends(get_db),
) -> DeviceTokenSuccessResponse | DeviceTokenPendingResponse:
"""Poll for device authorization completion.
The CLI repeatedly calls this endpoint to check if the user has
authorized the device.
Args:
token_request: Request containing device_code
db: Database session
Returns:
API key if authorized, pending status otherwise
Raises:
HTTPException: If device code is invalid, expired, or denied
"""
store = DeviceAuthStore(db)
session = store.get_session_by_device_code(token_request.device_code)
if not session:
logger.warning(
f'Invalid device code: {token_request.device_code[:8]}...'
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={'error': 'invalid_grant', 'error_description': 'Invalid device code'},
)
# Check if expired
if store.is_session_expired(token_request.device_code):
logger.info(
f'Expired device code: user_code={session.user_code}, '
f'device_code={token_request.device_code[:8]}...'
)
session.status = 'expired'
db.commit()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'error': 'expired_token',
'error_description': 'Device code has expired',
},
)
# Check if denied
if session.status == 'denied':
logger.info(
f'Denied device authorization: user_code={session.user_code}, '
f'device_code={token_request.device_code[:8]}...'
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
'error': 'access_denied',
'error_description': 'User denied the authorization request',
},
)
# Check if authorized
if session.status == 'authorized' and session.api_key:
logger.info(
f'Device authorized: user_code={session.user_code}, '
f'user_id={session.user_id}, device_code={token_request.device_code[:8]}...'
)
return DeviceTokenSuccessResponse(api_key=session.api_key)
# Still pending
logger.debug(
f'Device authorization pending: user_code={session.user_code}, '
f'device_code={token_request.device_code[:8]}...'
)
return DeviceTokenPendingResponse(status='pending')
# HTML page for device verification
# This is a simple page where users enter their user code
DEVICE_VERIFICATION_HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Authorization - OpenHands Cloud</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 100%;
padding: 40px;
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.logo h1 {
font-size: 28px;
color: #333;
margin-bottom: 8px;
}
.logo p {
color: #666;
font-size: 14px;
}
.form-group {
margin-bottom: 24px;
}
label {
display: block;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
input[type="text"] {
width: 100%;
padding: 12px 16px;
font-size: 18px;
border: 2px solid #e0e0e0;
border-radius: 8px;
text-transform: uppercase;
letter-spacing: 2px;
transition: border-color 0.3s;
}
input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn:active {
transform: translateY(0);
}
.help-text {
margin-top: 20px;
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
font-size: 14px;
color: #666;
}
.help-text strong {
color: #333;
}
.error {
background: #fee;
border: 1px solid #fcc;
color: #c33;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
}
.success {
background: #efe;
border: 1px solid #cfc;
color: #3c3;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">
<h1>🔐 Device Authorization</h1>
<p>OpenHands Cloud</p>
</div>
<form id="deviceForm" method="POST" action="/api/v1/auth/device/authorize">
<div class="form-group">
<label for="userCode">Enter your code:</label>
<input
type="text"
id="userCode"
name="user_code"
placeholder="XXXX-XXXX"
maxlength="9"
pattern="[A-Z0-9]{4}-[A-Z0-9]{4}"
required
autofocus
/>
</div>
<button type="submit" class="btn">Authorize Device</button>
</form>
<div class="help-text">
<strong>What is this?</strong><br>
You're seeing this because you ran <code>openhands login</code> in your terminal.
Enter the code displayed in your terminal to authorize this device.
</div>
</div>
<script>
// Auto-format input with dash
const input = document.getElementById('userCode');
input.addEventListener('input', (e) => {
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
if (value.length > 4) {
value = value.slice(0, 4) + '-' + value.slice(4, 8);
}
e.target.value = value;
});
</script>
</body>
</html>
"""
@device_page_router.get('/device', response_class=HTMLResponse, include_in_schema=False)
async def device_verification_page() -> HTMLResponse:
"""Serve the device verification page.
This page is where users enter their user code to authorize the device.
Returns:
HTML page for device verification
"""
return HTMLResponse(content=DEVICE_VERIFICATION_HTML)
class DeviceAuthorizeRequest(BaseModel):
"""Request model for device authorization."""
user_code: str
@router.post('/device/authorize')
async def authorize_device(
request: DeviceAuthorizeRequest,
db: Session = Depends(get_db),
# TODO: Add authentication dependency here
# current_user: User = Depends(get_current_user),
) -> dict:
"""Authorize a device (called from the web page).
This endpoint is called when a user enters their code and clicks "Authorize"
on the device verification page.
Args:
request: Request containing user_code
db: Database session
Returns:
Success message
Raises:
HTTPException: If user code is invalid or expired
"""
store = DeviceAuthStore(db)
session = store.get_session_by_user_code(request.user_code)
if not session:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid code',
)
# Check if expired
if store.is_session_expired(session.device_code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Code has expired',
)
# TODO: Get actual user ID from authentication
# user_id = current_user.id
user_id = 'temporary_user_id' # Placeholder
# TODO: Generate actual API key
# api_key = generate_api_key_for_user(user_id)
api_key = f'ohsk_demo_{secrets.token_urlsafe(32)}' # Placeholder
# Authorize the session
success = store.authorize_session(request.user_code, user_id, api_key)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to authorize device',
)
logger.info(
f'Device authorized via web: user_code={request.user_code}, '
f'user_id={user_id}'
)
return {
'status': 'success',
'message': 'Device authorized successfully! You can now return to your terminal.',
}
@@ -70,6 +70,11 @@ RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + (
else '/api/conversations/{conversation_id}'
)
RUNTIME_USERNAME = os.getenv('RUNTIME_USERNAME')
SU_TO_USER = os.getenv('SU_TO_USER', 'false')
truthy = {'1', 'true', 't', 'yes', 'y', 'on'}
SU_TO_USER = str(SU_TO_USER.lower() in truthy).lower()
# Time in seconds before a Redis entry is considered expired if not refreshed
_REDIS_ENTRY_TIMEOUT_SECONDS = 300
@@ -772,7 +777,11 @@ class SaasNestedConversationManager(ConversationManager):
env_vars['SERVE_FRONTEND'] = '0'
env_vars['RUNTIME'] = 'local'
# TODO: In the long term we may come up with a more secure strategy for user management within the nested runtime.
env_vars['USER'] = 'openhands' if config.run_as_openhands else 'root'
env_vars['USER'] = (
RUNTIME_USERNAME
if RUNTIME_USERNAME
else ('openhands' if config.run_as_openhands else 'root')
)
env_vars['PERMITTED_CORS_ORIGINS'] = ','.join(PERMITTED_CORS_ORIGINS)
env_vars['port'] = '60000'
# TODO: These values are static in the runtime-api project, but do not get copied into the runtime ENV
@@ -789,6 +798,7 @@ class SaasNestedConversationManager(ConversationManager):
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
env_vars['ENABLE_V1'] = '0'
env_vars['SU_TO_USER'] = SU_TO_USER
# We need this for LLM traces tracking to identify the source of the LLM calls
env_vars['WEB_HOST'] = WEB_HOST
+205
View File
@@ -0,0 +1,205 @@
"""Storage for OAuth Device Authorization sessions."""
import secrets
import string
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import Column, DateTime, String
from sqlalchemy.orm import Session
from storage.database import Base
class DeviceAuthSession(Base):
"""Model for OAuth Device Authorization sessions.
Implements RFC 8628 - OAuth 2.0 Device Authorization Grant.
"""
__tablename__ = 'device_auth_sessions'
device_code = Column(String(255), primary_key=True)
user_code = Column(String(10), unique=True, nullable=False)
user_id = Column(String(255), nullable=True)
api_key = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
status = Column(String(20), nullable=False, default='pending')
class DeviceAuthStore:
"""Store for managing device authorization sessions."""
def __init__(self, session: Session):
"""Initialize the device auth store.
Args:
session: SQLAlchemy session
"""
self.session = session
@staticmethod
def generate_device_code() -> str:
"""Generate a cryptographically secure device code.
Returns:
A 32-character random device code
"""
# Use secrets for cryptographically secure random generation
return secrets.token_urlsafe(32)
@staticmethod
def generate_user_code() -> str:
"""Generate a human-readable user code.
Returns:
An 8-character code in format XXXX-XXXX
"""
# Use uppercase letters and digits, exclude confusable characters
charset = ''.join(set(string.ascii_uppercase + string.digits) - set('0OIL1'))
code = ''.join(secrets.choice(charset) for _ in range(8))
return f'{code[:4]}-{code[4:]}'
def create_session(
self, expires_in: int = 300
) -> tuple[str, str, datetime]:
"""Create a new device authorization session.
Args:
expires_in: Expiration time in seconds (default 5 minutes)
Returns:
Tuple of (device_code, user_code, expires_at)
"""
device_code = self.generate_device_code()
user_code = self.generate_user_code()
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=expires_in)
session = DeviceAuthSession(
device_code=device_code,
user_code=user_code,
created_at=now,
expires_at=expires_at,
status='pending',
)
self.session.add(session)
self.session.commit()
return device_code, user_code, expires_at
def get_session_by_device_code(
self, device_code: str
) -> Optional[DeviceAuthSession]:
"""Get a session by device code.
Args:
device_code: The device code
Returns:
DeviceAuthSession if found, None otherwise
"""
return (
self.session.query(DeviceAuthSession)
.filter(DeviceAuthSession.device_code == device_code)
.first()
)
def get_session_by_user_code(
self, user_code: str
) -> Optional[DeviceAuthSession]:
"""Get a session by user code.
Args:
user_code: The user code
Returns:
DeviceAuthSession if found, None otherwise
"""
return (
self.session.query(DeviceAuthSession)
.filter(DeviceAuthSession.user_code == user_code)
.first()
)
def authorize_session(
self, user_code: str, user_id: str, api_key: str
) -> bool:
"""Authorize a device session.
Args:
user_code: The user code
user_id: The user ID authorizing the device
api_key: The API key to return to the device
Returns:
True if authorization successful, False if session not found or expired
"""
session = self.get_session_by_user_code(user_code)
if not session:
return False
# Check if session is expired
if session.expires_at < datetime.now(timezone.utc):
session.status = 'expired'
self.session.commit()
return False
# Check if already authorized
if session.status != 'pending':
return False
# Authorize the session
session.user_id = user_id
session.api_key = api_key
session.status = 'authorized'
self.session.commit()
return True
def deny_session(self, user_code: str) -> bool:
"""Deny a device authorization request.
Args:
user_code: The user code
Returns:
True if denial successful, False if session not found
"""
session = self.get_session_by_user_code(user_code)
if not session:
return False
session.status = 'denied'
self.session.commit()
return True
def is_session_expired(self, device_code: str) -> bool:
"""Check if a session is expired.
Args:
device_code: The device code
Returns:
True if expired, False otherwise
"""
session = self.get_session_by_device_code(device_code)
if not session:
return True
return session.expires_at < datetime.now(timezone.utc)
def cleanup_expired_sessions(self) -> int:
"""Delete all expired sessions.
Returns:
Number of sessions deleted
"""
count = (
self.session.query(DeviceAuthSession)
.filter(DeviceAuthSession.expires_at < datetime.now(timezone.utc))
.delete()
)
self.session.commit()
return count
+292
View File
@@ -0,0 +1,292 @@
"""Tests for OAuth Device Authorization."""
import pytest
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from storage.database import Base
from storage.device_auth_store import DeviceAuthSession, DeviceAuthStore
@pytest.fixture
def db_session():
"""Create an in-memory SQLite database for testing."""
engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
@pytest.fixture
def device_store(db_session):
"""Create a DeviceAuthStore for testing."""
return DeviceAuthStore(db_session)
def test_generate_device_code(device_store):
"""Test device code generation."""
code = device_store.generate_device_code()
assert isinstance(code, str)
assert len(code) > 0
# Should be URL-safe base64
assert all(c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' for c in code)
def test_generate_user_code(device_store):
"""Test user code generation."""
code = device_store.generate_user_code()
assert isinstance(code, str)
assert len(code) == 9 # XXXX-XXXX
assert code[4] == '-'
# Should not contain confusable characters
assert '0' not in code
assert 'O' not in code
assert 'I' not in code
assert 'L' not in code
assert '1' not in code
def test_create_session(device_store):
"""Test creating a device authorization session."""
device_code, user_code, expires_at = device_store.create_session(expires_in=300)
assert isinstance(device_code, str)
assert isinstance(user_code, str)
assert isinstance(expires_at, datetime)
# Check expiration time is approximately correct
expected_expires = datetime.now(timezone.utc) + timedelta(seconds=300)
assert abs((expires_at - expected_expires).total_seconds()) < 2
# Verify session was saved to database
session = device_store.get_session_by_device_code(device_code)
assert session is not None
assert session.device_code == device_code
assert session.user_code == user_code
assert session.status == 'pending'
def test_get_session_by_device_code(device_store):
"""Test retrieving session by device code."""
device_code, user_code, _ = device_store.create_session()
session = device_store.get_session_by_device_code(device_code)
assert session is not None
assert session.device_code == device_code
assert session.user_code == user_code
# Test with invalid device code
invalid_session = device_store.get_session_by_device_code('invalid_code')
assert invalid_session is None
def test_get_session_by_user_code(device_store):
"""Test retrieving session by user code."""
device_code, user_code, _ = device_store.create_session()
session = device_store.get_session_by_user_code(user_code)
assert session is not None
assert session.device_code == device_code
assert session.user_code == user_code
# Test with invalid user code
invalid_session = device_store.get_session_by_user_code('INVALID')
assert invalid_session is None
def test_authorize_session(device_store):
"""Test authorizing a device session."""
device_code, user_code, _ = device_store.create_session(expires_in=300)
# Authorize the session
success = device_store.authorize_session(
user_code=user_code,
user_id='test_user_123',
api_key='ohsk_test_key',
)
assert success is True
# Verify session was updated
session = device_store.get_session_by_user_code(user_code)
assert session.status == 'authorized'
assert session.user_id == 'test_user_123'
assert session.api_key == 'ohsk_test_key'
def test_authorize_session_invalid_code(device_store):
"""Test authorizing with invalid user code."""
success = device_store.authorize_session(
user_code='INVALID',
user_id='test_user',
api_key='ohsk_test_key',
)
assert success is False
def test_authorize_session_expired(device_store, db_session):
"""Test authorizing an expired session."""
# Create a session that's already expired
device_code = device_store.generate_device_code()
user_code = device_store.generate_user_code()
past_time = datetime.now(timezone.utc) - timedelta(seconds=60)
session = DeviceAuthSession(
device_code=device_code,
user_code=user_code,
created_at=past_time,
expires_at=past_time,
status='pending',
)
db_session.add(session)
db_session.commit()
# Try to authorize
success = device_store.authorize_session(
user_code=user_code,
user_id='test_user',
api_key='ohsk_test_key',
)
assert success is False
# Verify status was updated to expired
session = device_store.get_session_by_user_code(user_code)
assert session.status == 'expired'
def test_authorize_session_already_authorized(device_store):
"""Test authorizing an already authorized session."""
device_code, user_code, _ = device_store.create_session()
# First authorization
success1 = device_store.authorize_session(
user_code=user_code,
user_id='user1',
api_key='key1',
)
assert success1 is True
# Try to authorize again
success2 = device_store.authorize_session(
user_code=user_code,
user_id='user2',
api_key='key2',
)
assert success2 is False
# Verify original authorization is preserved
session = device_store.get_session_by_user_code(user_code)
assert session.user_id == 'user1'
assert session.api_key == 'key1'
def test_deny_session(device_store):
"""Test denying a device session."""
device_code, user_code, _ = device_store.create_session()
success = device_store.deny_session(user_code)
assert success is True
# Verify session was denied
session = device_store.get_session_by_user_code(user_code)
assert session.status == 'denied'
def test_deny_session_invalid_code(device_store):
"""Test denying with invalid user code."""
success = device_store.deny_session('INVALID')
assert success is False
def test_is_session_expired(device_store, db_session):
"""Test checking if session is expired."""
# Create non-expired session
device_code1, _, _ = device_store.create_session(expires_in=300)
assert device_store.is_session_expired(device_code1) is False
# Create expired session
device_code2 = device_store.generate_device_code()
user_code2 = device_store.generate_user_code()
past_time = datetime.now(timezone.utc) - timedelta(seconds=60)
session = DeviceAuthSession(
device_code=device_code2,
user_code=user_code2,
created_at=past_time,
expires_at=past_time,
status='pending',
)
db_session.add(session)
db_session.commit()
assert device_store.is_session_expired(device_code2) is True
# Invalid device code should return True
assert device_store.is_session_expired('invalid') is True
def test_cleanup_expired_sessions(device_store, db_session):
"""Test cleaning up expired sessions."""
# Create some expired sessions
for i in range(3):
device_code = device_store.generate_device_code()
user_code = device_store.generate_user_code()
past_time = datetime.now(timezone.utc) - timedelta(seconds=60)
session = DeviceAuthSession(
device_code=device_code,
user_code=user_code,
created_at=past_time,
expires_at=past_time,
status='pending',
)
db_session.add(session)
# Create some non-expired sessions
for i in range(2):
device_store.create_session(expires_in=300)
db_session.commit()
# Cleanup expired sessions
count = device_store.cleanup_expired_sessions()
assert count == 3
# Verify only non-expired sessions remain
remaining = db_session.query(DeviceAuthSession).count()
assert remaining == 2
def test_user_code_uniqueness(device_store, db_session):
"""Test that user codes are unique."""
# Generate many codes to check for collisions
# Note: With a good charset, collisions should be extremely rare
codes = set()
for _ in range(100):
code = device_store.generate_user_code()
codes.add(code)
# All codes should be unique
assert len(codes) == 100
def test_device_code_security(device_store):
"""Test that device codes are cryptographically secure."""
# Generate many codes and check they don't have obvious patterns
codes = set()
for _ in range(100):
code = device_store.generate_device_code()
codes.add(code)
# All codes should be unique
assert len(codes) == 100
# Codes should be sufficiently long
for code in codes:
assert len(code) >= 32
@@ -1,6 +1,7 @@
from unittest import TestCase, mock
from unittest.mock import MagicMock, patch
import pytest
from integrations.github.github_view import GithubFactory, GithubIssue, get_oh_labels
from integrations.models import Message, SourceType
from integrations.types import UserData
@@ -114,8 +115,10 @@ class TestGithubV1ConversationRouting(TestCase):
title='Test Issue',
description='Test issue description',
previous_comments=[],
v1=False,
)
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
@@ -144,6 +147,7 @@ class TestGithubV1ConversationRouting(TestCase):
)
mock_create_v1.assert_not_called()
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
@@ -172,6 +176,7 @@ class TestGithubV1ConversationRouting(TestCase):
)
mock_create_v0.assert_not_called()
@pytest.mark.asyncio
@patch('integrations.github.github_view.get_user_v1_enabled_setting')
@patch.object(GithubIssue, '_create_v0_conversation')
@patch.object(GithubIssue, '_create_v1_conversation')
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with consent", async () => {
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { createRoutesStub, Outlet } from "react-router";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
@@ -404,7 +404,7 @@ describe("RepoConnector", () => {
ConversationService,
"createConversation",
);
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
createConversationSpy.mockImplementation(() => new Promise(() => { })); // Never resolves to keep loading state
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
@@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { waitFor } from "@testing-library/react";
import { Sidebar } from "#/components/features/sidebar/sidebar";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
@@ -57,7 +57,7 @@ describe("MicroagentsModal - Refresh Button", () => {
});
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe("Refresh Button Rendering", () => {
@@ -74,13 +74,15 @@ describe("MicroagentsModal - Refresh Button", () => {
describe("Refresh Button Functionality", () => {
it("should call refetch when refresh button is clicked", async () => {
const user = userEvent.setup();
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
// Wait for the component to load and render the refresh button
const refreshButton = await screen.findByTestId("refresh-microagents");
refreshSpy.mockClear();
await user.click(refreshButton);
expect(refreshSpy).toHaveBeenCalledTimes(1);
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { screen } from "@testing-library/react";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";
@@ -1,12 +1,26 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import {
describe,
it,
expect,
beforeAll,
beforeEach,
afterAll,
afterEach,
} from "vitest";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useBrowserStore } from "#/stores/browser-store";
import { useCommandStore } from "#/state/command-store";
import {
createMockMessageEvent,
createMockUserMessageEvent,
createMockAgentErrorEvent,
createMockBrowserObservationEvent,
createMockBrowserNavigateActionEvent,
createMockExecuteBashActionEvent,
createMockExecuteBashObservationEvent,
} from "#/mocks/mock-ws-helpers";
import {
ConnectionStatusComponent,
@@ -461,7 +475,7 @@ describe("Conversation WebSocket Handler", () => {
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
function HistoryLoadingComponent() {
const context = useConversationWebSocket();
const { events } = useEventStore();
@@ -474,7 +488,7 @@ describe("Conversation WebSocket Handler", () => {
<div data-testid="expected-event-count">{expectedEventCount}</div>
</div>
);
};
}
// Render with WebSocket context
renderWithWebSocketContext(
@@ -484,7 +498,9 @@ describe("Conversation WebSocket Handler", () => {
);
// Initially should be loading history
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
"true",
);
// Wait for all events to be received
await waitFor(() => {
@@ -523,7 +539,7 @@ describe("Conversation WebSocket Handler", () => {
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
function HistoryLoadingComponent() {
const context = useConversationWebSocket();
return (
@@ -533,7 +549,7 @@ describe("Conversation WebSocket Handler", () => {
</div>
</div>
);
};
}
// Render with WebSocket context
renderWithWebSocketContext(
@@ -583,7 +599,7 @@ describe("Conversation WebSocket Handler", () => {
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
function HistoryLoadingComponent() {
const context = useConversationWebSocket();
const { events } = useEventStore();
@@ -595,7 +611,7 @@ describe("Conversation WebSocket Handler", () => {
<div data-testid="events-received">{events.length}</div>
</div>
);
};
}
// Render with WebSocket context
renderWithWebSocketContext(
@@ -605,7 +621,9 @@ describe("Conversation WebSocket Handler", () => {
);
// Initially should be loading history
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
"true",
);
// Wait for all events to be received
await waitFor(() => {
@@ -621,17 +639,133 @@ describe("Conversation WebSocket Handler", () => {
});
});
// 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
describe("Terminal I/O Integration", () => {
it("should append command to store when ExecuteBashAction event is received", async () => {
const { createMockExecuteBashActionEvent } = await import(
"#/mocks/mock-ws-helpers"
// 9. Browser State Tests (BrowserObservation)
describe("Browser State Integration", () => {
beforeEach(() => {
useBrowserStore.getState().reset();
});
it("should update browser store with screenshot when BrowserObservation event is received", async () => {
// Create a mock BrowserObservation event with screenshot data
const mockBrowserObsEvent = createMockBrowserObservationEvent(
"base64-screenshot-data",
"Page loaded successfully",
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBrowserObsEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the browser store to be updated with screenshot
await waitFor(() => {
const { screenshotSrc } = useBrowserStore.getState();
expect(screenshotSrc).toBe(
"data:image/png;base64,base64-screenshot-data",
);
});
});
it("should update browser store with URL when BrowserNavigateAction followed by BrowserObservation", async () => {
// Create mock events - action first, then observation
const mockBrowserActionEvent = createMockBrowserNavigateActionEvent(
"https://example.com/test-page",
);
const mockBrowserObsEvent = createMockBrowserObservationEvent(
"base64-screenshot-data",
"Page loaded successfully",
);
// Set up MSW to send both events when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send action first, then observation
client.send(JSON.stringify(mockBrowserActionEvent));
client.send(JSON.stringify(mockBrowserObsEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the browser store to be updated with both screenshot and URL
await waitFor(() => {
const { screenshotSrc, url } = useBrowserStore.getState();
expect(screenshotSrc).toBe(
"data:image/png;base64,base64-screenshot-data",
);
expect(url).toBe("https://example.com/test-page");
});
});
it("should not update browser store when BrowserObservation has no screenshot data", async () => {
const initialScreenshot = useBrowserStore.getState().screenshotSrc;
// Create a mock BrowserObservation event WITHOUT screenshot data
const mockBrowserObsEvent = createMockBrowserObservationEvent(
null, // no screenshot
"Browser action completed",
);
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBrowserObsEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Give some time for any potential updates
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
// Screenshot should remain unchanged (empty/initial value)
const { screenshotSrc } = useBrowserStore.getState();
expect(screenshotSrc).toBe(initialScreenshot);
});
});
// 10. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
describe("Terminal I/O Integration", () => {
beforeEach(() => {
useCommandStore.getState().clearTerminal();
});
it("should append command to store when ExecuteBashAction event is received", async () => {
// Create a mock ExecuteBashAction event
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
@@ -667,14 +801,6 @@ describe("Conversation WebSocket Handler", () => {
});
it("should append output to store when ExecuteBashObservation event is received", async () => {
const { createMockExecuteBashObservationEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashObservation event
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
"PASS tests/example.test.js\n ✓ should work (2 ms)",
@@ -1,7 +1,7 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
describe("useSaveSettings", () => {
+21 -8
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-classes-per-file */
import { beforeAll, describe, expect, it, vi, afterEach } from "vitest";
import { useTerminal } from "#/hooks/use-terminal";
import { Command, useCommandStore } from "#/state/command-store";
@@ -45,17 +46,29 @@ describe("useTerminal", () => {
}));
beforeAll(() => {
// mock ResizeObserver
window.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// mock ResizeObserver - use class for Vitest 4 constructor support
window.ResizeObserver = class {
observe = vi.fn();
// mock Terminal
unobserve = vi.fn();
disconnect = vi.fn();
} as unknown as typeof ResizeObserver;
// mock Terminal - use class for Vitest 4 constructor support
vi.mock("@xterm/xterm", async (importOriginal) => ({
...(await importOriginal<typeof import("@xterm/xterm")>()),
Terminal: vi.fn().mockImplementation(() => mockTerminal),
Terminal: class {
loadAddon = mockTerminal.loadAddon;
open = mockTerminal.open;
write = mockTerminal.write;
writeln = mockTerminal.writeln;
dispose = mockTerminal.dispose;
},
}));
});
+1 -1
View File
@@ -10,7 +10,7 @@ import MainApp from "#/routes/root-layout";
import i18n from "#/i18n";
import OptionService from "#/api/option-service/option-service.api";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
describe("frontend/routes/_oh", () => {
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
@@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
import i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import GitSettingsScreen from "#/routes/git-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -6,7 +6,7 @@ import { createRoutesStub } from "react-router";
import { createAxiosNotFoundErrorObject } from "test-utils";
import HomeScreen from "#/routes/home";
import { GitRepository } from "#/types/git";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import GitService from "#/api/git-service/git-service.api";
import OptionService from "#/api/option-service/option-service.api";
import MainApp from "#/routes/root-layout";
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
@@ -1,12 +1,12 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub, Outlet } from "react-router";
import SecretsSettingsScreen from "#/routes/secrets-settings";
import { SecretsService } from "#/api/secrets-service";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -21,25 +21,25 @@ const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
},
];
const RouterStub = createRoutesStub([
{
Component: () => <Outlet />,
path: "/settings",
children: [
{
Component: SecretsSettingsScreen,
path: "/settings/secrets",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/integrations",
},
],
},
]);
const renderSecretsSettings = () => {
const RouterStub = createRoutesStub([
{
Component: () => <Outlet />,
path: "/settings",
children: [
{
Component: SecretsSettingsScreen,
path: "/settings/secrets",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/integrations",
},
],
},
]);
const renderSecretsSettings = () =>
render(<RouterStub initialEntries={["/settings/secrets"]} />, {
return render(<RouterStub initialEntries={["/settings/secrets"]} />, {
wrapper: ({ children }) => (
<QueryClientProvider
client={
@@ -52,6 +52,7 @@ const renderSecretsSettings = () =>
</QueryClientProvider>
),
});
};
beforeEach(() => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
@@ -61,6 +62,10 @@ beforeEach(() => {
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Content", () => {
it("should render the secrets settings screen", () => {
renderSecretsSettings();
@@ -501,6 +506,8 @@ describe("Secret actions", () => {
it("should not submit whitespace secret names or values", async () => {
const createSecretSpy = vi.spyOn(SecretsService, "createSecret");
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
// render form & hide items
@@ -532,9 +539,11 @@ describe("Secret actions", () => {
await userEvent.click(submitButton);
expect(createSecretSpy).not.toHaveBeenCalled();
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).toBeInTheDocument();
await waitFor(() => {
expect(
screen.queryByText("SECRETS$SECRET_VALUE_REQUIRED"),
).toBeInTheDocument();
});
});
it("should not reset ipout values on an invalid submit", async () => {
+1606 -2690
View File
File diff suppressed because it is too large Load Diff
+42 -42
View File
@@ -8,56 +8,56 @@
},
"dependencies": {
"@heroui/react": "2.8.5",
"@heroui/use-infinite-scroll": "^2.2.11",
"@heroui/use-infinite-scroll": "^2.2.12",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@posthog/react": "^1.4.0",
"@react-router/node": "^7.9.3",
"@react-router/serve": "^7.9.3",
"@posthog/react": "^1.5.2",
"@react-router/node": "^7.10.1",
"@react-router/serve": "^7.10.1",
"@react-types/shared": "^3.32.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-query": "^5.90.2",
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.5.3",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.12",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.4",
"@vitejs/plugin-react": "^5.1.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.12.2",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"downshift": "^9.0.10",
"downshift": "^9.0.12",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.22",
"i18next": "^25.5.2",
"framer-motion": "^12.23.25",
"i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.31",
"jose": "^6.1.0",
"lucide-react": "^0.544.0",
"monaco-editor": "^0.53.0",
"posthog-js": "^1.298.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"isbot": "^5.1.32",
"jose": "^6.1.3",
"lucide-react": "^0.556.0",
"monaco-editor": "^0.55.1",
"posthog-js": "^1.302.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^16.0.0",
"react-i18next": "^16.4.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.9.3",
"react-syntax-highlighter": "^15.6.6",
"react-router": "^7.10.1",
"react-syntax-highlighter": "^16.1.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"tailwind-merge": "^3.4.0",
"tailwind-scrollbar": "^4.0.2",
"vite": "^7.1.7",
"vite": "^7.2.7",
"web-vitals": "^5.1.0",
"ws": "^8.18.2",
"zustand": "^5.0.8"
"zustand": "^5.0.9"
},
"scripts": {
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
@@ -96,25 +96,25 @@
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.55.1",
"@react-router/dev": "^7.9.3",
"@playwright/test": "^1.57.0",
"@react-router/dev": "^7.10.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/eslint-plugin-query": "^5.91.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.5.2",
"@types/react": "^19.1.15",
"@types/react-dom": "^19.1.9",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.2.3",
"autoprefixer": "^10.4.21",
"cross-env": "^10.0.0",
"@vitest/coverage-v8": "^4.0.14",
"autoprefixer": "^10.4.22",
"cross-env": "^10.1.0",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
@@ -127,16 +127,16 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.2.0",
"husky": "^9.1.7",
"jsdom": "^27.0.0",
"lint-staged": "^16.2.3",
"jsdom": "^27.3.0",
"lint-staged": "^16.2.7",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.5.0",
"prettier": "^3.7.3",
"stripe": "^20.0.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"vite-plugin-svgr": "^4.5.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.2"
"vitest": "^4.0.14"
},
"packageManager": "npm@10.5.0",
"volta": {
+17 -12
View File
@@ -7,8 +7,8 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.11.1'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const PACKAGE_VERSION = '2.12.3'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
@@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
break
}
case 'MOCK_DEACTIVATE': {
activeClientIds.delete(clientId)
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
@@ -94,6 +89,8 @@ addEventListener('message', async function (event) {
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
@@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been deleted (still remains active until the next reload).
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId))
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId) {
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(event, client, requestId)
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
@@ -202,9 +205,10 @@ async function resolveMainClient(event) {
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId) {
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
@@ -255,6 +259,7 @@ async function getResponse(event, client, requestId) {
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
@@ -1,4 +1,4 @@
import { openHands } from "../api/open-hands-axios";
import { openHands } from "../open-hands-axios";
import { ApiSettings, PostApiSettings } from "./settings.types";
/**
@@ -35,6 +35,7 @@ export type ApiSettings = {
email_verified?: boolean;
git_user_name?: string;
git_user_email?: string;
v1_enabled?: boolean;
};
export type PostApiSettings = ApiSettings & {
@@ -22,6 +22,13 @@ const getCommandObservationContent = (
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
const command = event.observation === "run" ? event.extras.command : null;
if (command) {
return `Command:\n\`\`\`sh\n${command}\n\`\`\`\n\nOutput:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
}
return `Output:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
};
@@ -95,7 +95,7 @@ export function ExpandableMessage({
const statusIconClasses = "h-4 w-4 ml-2 inline";
if (
config?.FEATURE_FLAGS.ENABLE_BILLING &&
config?.FEATURE_FLAGS?.ENABLE_BILLING &&
config?.APP_MODE === "saas" &&
id === I18nKey.STATUS$ERROR_LLM_OUT_OF_CREDITS
) {
@@ -25,7 +25,14 @@ export function AccountSettingsContextMenu({
const { data: config } = useConfig();
const isSaas = config?.APP_MODE === "saas";
const navItems = (isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS).map((item) => ({
// Get navigation items and filter out LLM settings if the feature flag is enabled
let items = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) {
items = items.filter((item) => item.to !== "/settings");
}
const navItems = items.map((item) => ({
...item,
icon: React.cloneElement(item.icon, {
width: 16,
@@ -19,8 +19,10 @@ import {
} from "#/state/conversation-store";
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import { useConversationId } from "#/hooks/use-conversation-id";
export function ConversationTabs() {
const { conversationId } = useConversationId();
const {
selectedTab,
isRightPanelShown,
@@ -30,18 +32,21 @@ export function ConversationTabs() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Persist selectedTab and isRightPanelShown in localStorage
// Persist selectedTab and isRightPanelShown in localStorage per conversation
const [persistedSelectedTab, setPersistedSelectedTab] =
useLocalStorage<ConversationTab | null>(
"conversation-selected-tab",
`conversation-selected-tab-${conversationId}`,
"editor",
);
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
useLocalStorage<boolean>("conversation-right-panel-shown", true);
useLocalStorage<boolean>(
`conversation-right-panel-shown-${conversationId}`,
true,
);
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
"conversation-unpinned-tabs",
`conversation-unpinned-tabs-${conversationId}`,
[],
);
@@ -34,13 +34,7 @@ export function Sidebar() {
const { pathname } = useLocation();
// TODO: Remove HIDE_LLM_SETTINGS check once released
const shouldHideLlmSettings =
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
React.useEffect(() => {
if (shouldHideLlmSettings) return;
if (location.pathname === "/settings") {
setSettingsModalIsOpen(false);
} else if (
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest";
import { getObservationContent } from "../get-observation-content";
import { ObservationEvent } from "#/types/v1/core";
import { BrowserObservation } from "#/types/v1/core/base/observation";
describe("getObservationContent - BrowserObservation", () => {
it("should return output content when available", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "Browser action completed",
error: null,
screenshot_data: "base64data",
},
};
const result = getObservationContent(mockEvent);
expect(result).toContain("**Output:**");
expect(result).toContain("Browser action completed");
});
it("should handle error cases properly", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "",
error: "Browser action failed",
screenshot_data: null,
},
};
const result = getObservationContent(mockEvent);
expect(result).toContain("**Error:**");
expect(result).toContain("Browser action failed");
});
it("should provide default message when no output or error", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "",
error: null,
screenshot_data: "base64data",
},
};
const result = getObservationContent(mockEvent);
expect(result).toBe("Browser action completed successfully.");
});
it("should return output when screenshot_data is null", () => {
const mockEvent: ObservationEvent<BrowserObservation> = {
id: "test-id",
timestamp: "2024-01-01T00:00:00Z",
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "call-id",
action_id: "action-id",
observation: {
kind: "BrowserObservation",
output: "Page loaded successfully",
error: null,
screenshot_data: null,
},
};
const result = getObservationContent(mockEvent);
expect(result).toBe("**Output:**\nPage loaded successfully");
});
});
@@ -71,7 +71,18 @@ const getTerminalObservationContent = (
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
return `Output:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
// Build the output string
let output = "";
// Display the command if available
if (observation.command) {
output += `Command: \`${observation.command}\`\n\n`;
}
// Display the output
output += `Output:\n\`\`\`sh\n${content.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
return output;
};
// Tool Observations
@@ -87,14 +98,16 @@ const getBrowserObservationContent = (
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n")
: "";
: observation.output || "";
let contentDetails = "";
if ("is_error" in observation && observation.is_error) {
contentDetails += `**Error:**\n${textContent}`;
} else {
if (observation.error) {
contentDetails += `**Error:**\n${observation.error}`;
} else if (textContent) {
contentDetails += `**Output:**\n${textContent}`;
} else {
contentDetails += "Browser action completed successfully.";
}
if (contentDetails.length > MAX_CONTENT_LENGTH) {
@@ -118,7 +118,7 @@ const renderUserMessageWithSkillReady = (
);
} catch (error) {
// If skill ready event creation fails, just render the user message
console.error("Failed to create skill ready event:", error);
// Failed to create skill ready event, fallback to user message
return (
<UserAssistantEventMessage
event={messageEvent}
@@ -14,6 +14,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
import { useCommandStore } from "#/state/command-store";
import { useBrowserStore } from "#/stores/browser-store";
import {
isV1Event,
isAgentErrorEvent,
@@ -27,6 +28,8 @@ import {
isExecuteBashObservationEvent,
isConversationErrorEvent,
isPlanningFileEditorObservationEvent,
isBrowserObservationEvent,
isBrowserNavigateActionEvent,
} from "#/types/v1/type-guards";
import { ConversationStateUpdateEventStats } from "#/types/v1/core/events/conversation-state-event";
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
@@ -383,6 +386,22 @@ export function ConversationWebSocketProvider({
.join("\n");
appendOutput(textContent);
}
// Handle BrowserObservation events - update browser store with screenshot
if (isBrowserObservationEvent(event)) {
const { screenshot_data: screenshotData } = event.observation;
if (screenshotData) {
const screenshotSrc = screenshotData.startsWith("data:")
? screenshotData
: `data:image/png;base64,${screenshotData}`;
useBrowserStore.getState().setScreenshotSrc(screenshotSrc);
}
}
// Handle BrowserNavigateAction events - update browser store with URL
if (isBrowserNavigateActionEvent(event)) {
useBrowserStore.getState().setUrl(event.action.url);
}
}
} catch (error) {
// eslint-disable-next-line no-console
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -4,8 +4,8 @@ import V1ConversationService from "#/api/conversation-service/v1-conversation-se
import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings";
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
import { useTracking } from "#/hooks/use-tracking";
import { useSettings } from "#/hooks/query/use-settings";
interface CreateConversationVariables {
query?: string;
@@ -34,6 +34,7 @@ interface CreateConversationResponse extends Partial<Conversation> {
export const useCreateConversation = () => {
const queryClient = useQueryClient();
const { trackConversationCreated } = useTracking();
const { data: settings } = useSettings();
return useMutation({
mutationKey: ["create-conversation"],
@@ -50,7 +51,7 @@ export const useCreateConversation = () => {
agentType,
} = variables;
const useV1 = USE_V1_CONVERSATION_API() && !createMicroagent;
const useV1 = !!settings?.V1_ENABLED && !createMicroagent;
if (useV1) {
// Use V1 API - creates a conversation start task
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPConfig } from "#/types/settings";
export function useDeleteMcpServer() {
@@ -1,9 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { PostSettings } from "#/types/settings";
import { PostApiSettings } from "#/settings-service/settings.types";
import { PostApiSettings } from "#/api/settings-service/settings.types";
import { useSettings } from "../query/use-settings";
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
@@ -35,6 +35,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME,
git_user_email:
settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
v1_enabled: settings.V1_ENABLED,
};
await SettingsService.saveSettings(apiSettings);
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
type MCPServerType = "sse" | "stdio" | "shttp";
+1 -1
View File
@@ -13,6 +13,6 @@ export const useBalance = () => {
enabled:
!isOnTosPage &&
config?.APP_MODE === "saas" &&
config?.FEATURE_FLAGS.ENABLE_BILLING,
config?.FEATURE_FLAGS?.ENABLE_BILLING,
});
};
+2 -1
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import SettingsService from "#/settings-service/settings-service.api";
import SettingsService from "#/api/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { Settings } from "#/types/settings";
@@ -36,6 +36,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
GIT_USER_EMAIL:
apiSettings.git_user_email || DEFAULT_SETTINGS.GIT_USER_EMAIL,
IS_NEW_USER: false,
V1_ENABLED: apiSettings.v1_enabled ?? DEFAULT_SETTINGS.V1_ENABLED,
};
};
+8 -4
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
import { useSettings } from "#/hooks/query/use-settings";
/**
* Hook to fetch in-progress V1 conversation start tasks
@@ -13,13 +13,17 @@ import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
* @param limit Maximum number of tasks to return (max 100)
* @returns Query result with array of in-progress start tasks
*/
export const useStartTasks = (limit = 10) =>
useQuery({
export const useStartTasks = (limit = 10) => {
const { data: settings } = useSettings();
const isV1Enabled = settings?.V1_ENABLED;
return useQuery({
queryKey: ["start-tasks", "search", limit],
queryFn: () => V1ConversationService.searchStartTasks(limit),
enabled: USE_V1_CONVERSATION_API(),
enabled: isV1Enabled,
select: (tasks) =>
tasks.filter(
(task) => task.status !== "READY" && task.status !== "ERROR",
),
});
};
+2 -1
View File
@@ -44,7 +44,7 @@ export const useTerminal = () => {
new Terminal({
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
fontSize: 14,
scrollback: 1000,
scrollback: 10000,
scrollSensitivity: 1,
fastScrollModifier: "alt",
fastScrollSensitivity: 5,
@@ -62,6 +62,7 @@ export const useTerminal = () => {
terminal.current.open(ref.current);
// Hide cursor for read-only terminal using ANSI escape sequence
terminal.current.write("\x1b[?25l");
fitAddon.current?.fit();
}
}
};
+1 -1
View File
@@ -7,7 +7,7 @@ import { Provider } from "#/types/settings";
import {
ApiSettings,
PostApiSettings,
} from "#/settings-service/settings.types";
} from "#/api/settings-service/settings.types";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
+52
View File
@@ -184,3 +184,55 @@ export const createMockExecuteBashObservationEvent = (
},
action_id: "bash-action-123",
});
/**
* Creates a mock BrowserObservation event for testing browser state handling
*/
export const createMockBrowserObservationEvent = (
screenshotData: string | null = "base64-screenshot-data",
output: string = "Browser action completed",
error: string | null = null,
) => ({
id: "browser-obs-123",
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "browser_navigate",
tool_call_id: "browser-call-456",
observation: {
kind: "BrowserObservation",
output,
error,
screenshot_data: screenshotData,
},
action_id: "browser-action-123",
});
/**
* Creates a mock BrowserNavigateAction event for testing browser URL extraction
*/
export const createMockBrowserNavigateActionEvent = (
url: string = "https://example.com",
) => ({
id: "browser-action-123",
timestamp: new Date().toISOString(),
source: "agent",
thought: [{ type: "text", text: "Navigating to URL" }],
thinking_blocks: [],
action: {
kind: "BrowserNavigateAction",
url,
new_tab: false,
},
tool_name: "browser_navigate",
tool_call_id: "browser-call-456",
tool_call: {
id: "browser-call-456",
type: "function",
function: {
name: "browser_navigate",
arguments: JSON.stringify({ url, new_tab: false }),
},
},
llm_response_id: "llm-response-789",
security_risk: { level: "low" },
});
+14 -12
View File
@@ -239,18 +239,20 @@ function AppSettingsScreen() {
</SettingsSwitch>
)}
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
type="number"
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
onChange={checkIfMaxBudgetPerTaskHasChanged}
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-full max-w-[680px]" // Match the width of the language field
/>
{!settings?.V1_ENABLED && (
<SettingsInput
testId="max-budget-per-task-input"
name="max-budget-per-task-input"
type="number"
label={t(I18nKey.SETTINGS$MAX_BUDGET_PER_CONVERSATION)}
defaultValue={settings.MAX_BUDGET_PER_TASK?.toString() || ""}
onChange={checkIfMaxBudgetPerTaskHasChanged}
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-full max-w-[680px]" // Match the width of the language field
/>
)}
<div className="border-t border-t-tertiary pt-6 mt-2">
<h3 className="text-lg font-medium mb-2">
+2 -3
View File
@@ -28,7 +28,6 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { getProviderId } from "#/utils/map-provider";
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
interface OpenHandsApiKeyHelpProps {
testId: string;
@@ -119,8 +118,8 @@ function LlmSettingsScreen() {
const isSaasMode = config?.APP_MODE === "saas";
const shouldUseOpenHandsKey = isOpenHandsProvider && isSaasMode;
// Determine if we should hide the agent dropdown when V1 conversation API feature flag is enabled
const isV1Enabled = USE_V1_CONVERSATION_API();
// Determine if we should hide the agent dropdown when V1 conversation API is enabled
const isV1Enabled = settings?.V1_ENABLED;
React.useEffect(() => {
const determineWhetherToToggleAdvancedSettings = () => {
+22 -2
View File
@@ -34,6 +34,15 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
return redirect("/settings");
}
// If LLM settings are hidden and user tries to access the LLM settings page
if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS && pathname === "/settings") {
// Redirect to the first available settings page
if (isSaas) {
return redirect("/settings/user");
}
return redirect("/settings/mcp");
}
return null;
};
@@ -52,13 +61,24 @@ function SettingsScreen() {
} else {
items.push(...OSS_NAV_ITEMS);
}
// Filter out LLM settings if the feature flag is enabled
if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) {
return items.filter((item) => item.to !== "/settings");
}
return items;
}, [isSaas]);
}, [isSaas, config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS]);
// Current section title for the main content area
const currentSectionTitle = useMemo(() => {
const currentItem = navItems.find((item) => item.to === location.pathname);
return currentItem ? currentItem.text : "SETTINGS$NAV_LLM";
if (currentItem) {
return currentItem.text;
}
// Default to the first available navigation item if current page is not found
return navItems.length > 0 ? navItems[0].text : "SETTINGS$TITLE";
}, [navItems, location.pathname]);
return (
+1
View File
@@ -31,6 +31,7 @@ export const DEFAULT_SETTINGS: Settings = {
},
GIT_USER_NAME: "openhands",
GIT_USER_EMAIL: "openhands@all-hands.dev",
V1_ENABLED: false,
};
/**
+41 -3
View File
@@ -61,10 +61,48 @@ interface ConversationActions {
type ConversationStore = ConversationState & ConversationActions;
// Helper function to get initial right panel state from localStorage
const getConversationIdFromLocation = (): string | null => {
if (typeof window === "undefined") {
return null;
}
const match = window.location.pathname.match(/\/conversations\/([^/]+)/);
return match ? match[1] : null;
};
const parseStoredBoolean = (value: string | null): boolean | null => {
if (value === null) {
return null;
}
try {
return JSON.parse(value);
} catch {
return null;
}
};
const getInitialRightPanelState = (): boolean => {
const stored = localStorage.getItem("conversation-right-panel-shown");
return stored !== null ? JSON.parse(stored) : true;
if (typeof window === "undefined") {
return true;
}
const conversationId = getConversationIdFromLocation();
const keysToCheck = conversationId
? [`conversation-right-panel-shown-${conversationId}`]
: [];
// Fallback to legacy global key for users who haven't switched tabs yet
keysToCheck.push("conversation-right-panel-shown");
for (const key of keysToCheck) {
const parsed = parseStoredBoolean(localStorage.getItem(key));
if (parsed !== null) {
return parsed;
}
}
return true;
};
export const useConversationStore = create<ConversationStore>()(
+3 -6
View File
@@ -31,8 +31,7 @@ export interface CommandAction extends OpenHandsActionEvent<"run"> {
};
}
export interface AssistantMessageAction
extends OpenHandsActionEvent<"message"> {
export interface AssistantMessageAction extends OpenHandsActionEvent<"message"> {
source: "agent";
args: {
thought: string;
@@ -87,8 +86,7 @@ export interface BrowseAction extends OpenHandsActionEvent<"browse"> {
};
}
export interface BrowseInteractiveAction
extends OpenHandsActionEvent<"browse_interactive"> {
export interface BrowseInteractiveAction extends OpenHandsActionEvent<"browse_interactive"> {
source: "agent";
timeout: number;
args: {
@@ -162,8 +160,7 @@ export interface MCPAction extends OpenHandsActionEvent<"call_tool_mcp"> {
};
}
export interface TaskTrackingAction
extends OpenHandsActionEvent<"task_tracking"> {
export interface TaskTrackingAction extends OpenHandsActionEvent<"task_tracking"> {
source: "agent";
args: {
command: string;
+6 -4
View File
@@ -30,14 +30,16 @@ interface OpenHandsBaseEvent {
timestamp: string; // ISO 8601
}
export interface OpenHandsActionEvent<T extends OpenHandsEventType>
extends OpenHandsBaseEvent {
export interface OpenHandsActionEvent<
T extends OpenHandsEventType,
> extends OpenHandsBaseEvent {
action: T;
args: Record<string, unknown>;
}
export interface OpenHandsObservationEvent<T extends OpenHandsEventType>
extends OpenHandsBaseEvent {
export interface OpenHandsObservationEvent<
T extends OpenHandsEventType,
> extends OpenHandsBaseEvent {
cause: number;
observation: T;
content: string;
+7 -14
View File
@@ -1,8 +1,7 @@
import { AgentState } from "../agent-state";
import { OpenHandsObservationEvent } from "./base";
export interface AgentStateChangeObservation
extends OpenHandsObservationEvent<"agent_state_changed"> {
export interface AgentStateChangeObservation extends OpenHandsObservationEvent<"agent_state_changed"> {
source: "agent";
extras: {
agent_state: AgentState;
@@ -19,8 +18,7 @@ export interface CommandObservation extends OpenHandsObservationEvent<"run"> {
};
}
export interface IPythonObservation
extends OpenHandsObservationEvent<"run_ipython"> {
export interface IPythonObservation extends OpenHandsObservationEvent<"run_ipython"> {
source: "agent";
extras: {
code: string;
@@ -28,8 +26,7 @@ export interface IPythonObservation
};
}
export interface DelegateObservation
extends OpenHandsObservationEvent<"delegate"> {
export interface DelegateObservation extends OpenHandsObservationEvent<"delegate"> {
source: "agent";
extras: {
outputs: Record<string, unknown>;
@@ -53,8 +50,7 @@ export interface BrowseObservation extends OpenHandsObservationEvent<"browse"> {
};
}
export interface BrowseInteractiveObservation
extends OpenHandsObservationEvent<"browse_interactive"> {
export interface BrowseInteractiveObservation extends OpenHandsObservationEvent<"browse_interactive"> {
source: "agent";
extras: {
url: string;
@@ -103,8 +99,7 @@ export interface ErrorObservation extends OpenHandsObservationEvent<"error"> {
};
}
export interface AgentThinkObservation
extends OpenHandsObservationEvent<"think"> {
export interface AgentThinkObservation extends OpenHandsObservationEvent<"think"> {
source: "agent";
extras: {
thought: string;
@@ -141,14 +136,12 @@ export interface MCPObservation extends OpenHandsObservationEvent<"mcp"> {
};
}
export interface UserRejectedObservation
extends OpenHandsObservationEvent<"user_rejected"> {
export interface UserRejectedObservation extends OpenHandsObservationEvent<"user_rejected"> {
source: "agent";
extras: Record<string, unknown>;
}
export interface TaskTrackingObservation
extends OpenHandsObservationEvent<"task_tracking"> {
export interface TaskTrackingObservation extends OpenHandsObservationEvent<"task_tracking"> {
source: "agent";
extras: {
command: string;
+1
View File
@@ -63,6 +63,7 @@ export type Settings = {
EMAIL_VERIFIED?: boolean;
GIT_USER_NAME?: string;
GIT_USER_EMAIL?: string;
V1_ENABLED?: boolean;
};
export type PostSettings = Settings & {
+7 -14
View File
@@ -91,8 +91,7 @@ export interface FileEditorAction extends ActionBase<"FileEditorAction"> {
view_range: [number, number] | null;
}
export interface StrReplaceEditorAction
extends ActionBase<"StrReplaceEditorAction"> {
export interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
@@ -134,8 +133,7 @@ export interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
task_list: TaskItem[];
}
export interface BrowserNavigateAction
extends ActionBase<"BrowserNavigateAction"> {
export interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
/**
* The URL to navigate to
*/
@@ -168,16 +166,14 @@ export interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
text: string;
}
export interface BrowserGetStateAction
extends ActionBase<"BrowserGetStateAction"> {
export interface BrowserGetStateAction extends ActionBase<"BrowserGetStateAction"> {
/**
* Whether to include a screenshot of the current page. Default: False
*/
include_screenshot: boolean;
}
export interface BrowserGetContentAction
extends ActionBase<"BrowserGetContentAction"> {
export interface BrowserGetContentAction extends ActionBase<"BrowserGetContentAction"> {
/**
* Whether to include links in the content (default: False)
*/
@@ -199,21 +195,18 @@ export interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
// No additional properties - this action has no parameters
}
export interface BrowserListTabsAction
extends ActionBase<"BrowserListTabsAction"> {
export interface BrowserListTabsAction extends ActionBase<"BrowserListTabsAction"> {
// No additional properties - this action has no parameters
}
export interface BrowserSwitchTabAction
extends ActionBase<"BrowserSwitchTabAction"> {
export interface BrowserSwitchTabAction extends ActionBase<"BrowserSwitchTabAction"> {
/**
* 4 Character Tab ID of the tab to switch to (from browser_list_tabs)
*/
tab_id: string;
}
export interface BrowserCloseTabAction
extends ActionBase<"BrowserCloseTabAction"> {
export interface BrowserCloseTabAction extends ActionBase<"BrowserCloseTabAction"> {
/**
* 4 Character Tab ID of the tab to close (from browser_list_tabs)
*/
+9 -18
View File
@@ -6,8 +6,7 @@ import {
ImageContent,
} from "./common";
export interface MCPToolObservation
extends ObservationBase<"MCPToolObservation"> {
export interface MCPToolObservation extends ObservationBase<"MCPToolObservation"> {
/**
* Content returned from the MCP tool converted to LLM Ready TextContent or ImageContent
*/
@@ -22,8 +21,7 @@ export interface MCPToolObservation
tool_name: string;
}
export interface FinishObservation
extends ObservationBase<"FinishObservation"> {
export interface FinishObservation extends ObservationBase<"FinishObservation"> {
/**
* Content returned from the finish action as a list of TextContent/ImageContent objects.
*/
@@ -41,8 +39,7 @@ export interface ThinkObservation extends ObservationBase<"ThinkObservation"> {
content: string;
}
export interface BrowserObservation
extends ObservationBase<"BrowserObservation"> {
export interface BrowserObservation extends ObservationBase<"BrowserObservation"> {
/**
* The output message from the browser operation
*/
@@ -57,8 +54,7 @@ export interface BrowserObservation
screenshot_data: string | null;
}
export interface ExecuteBashObservation
extends ObservationBase<"ExecuteBashObservation"> {
export interface ExecuteBashObservation extends ObservationBase<"ExecuteBashObservation"> {
/**
* Content returned from the tool as a list of TextContent/ImageContent objects.
*/
@@ -85,8 +81,7 @@ export interface ExecuteBashObservation
metadata: CmdOutputMetadata;
}
export interface TerminalObservation
extends ObservationBase<"TerminalObservation"> {
export interface TerminalObservation extends ObservationBase<"TerminalObservation"> {
/**
* Content returned from the terminal as a list of TextContent/ImageContent objects.
*/
@@ -113,8 +108,7 @@ export interface TerminalObservation
metadata: CmdOutputMetadata;
}
export interface FileEditorObservation
extends ObservationBase<"FileEditorObservation"> {
export interface FileEditorObservation extends ObservationBase<"FileEditorObservation"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
@@ -146,8 +140,7 @@ export interface FileEditorObservation
}
// Keep StrReplaceEditorObservation as a separate interface for backward compatibility
export interface StrReplaceEditorObservation
extends ObservationBase<"StrReplaceEditorObservation"> {
export interface StrReplaceEditorObservation extends ObservationBase<"StrReplaceEditorObservation"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
@@ -178,8 +171,7 @@ export interface StrReplaceEditorObservation
error: string | null;
}
export interface TaskTrackerObservation
extends ObservationBase<"TaskTrackerObservation"> {
export interface TaskTrackerObservation extends ObservationBase<"TaskTrackerObservation"> {
/**
* The formatted task list or status message.
*/
@@ -194,8 +186,7 @@ export interface TaskTrackerObservation
task_list: TaskItem[];
}
export interface PlanningFileEditorObservation
extends ObservationBase<"PlanningFileEditorObservation"> {
export interface PlanningFileEditorObservation extends ObservationBase<"PlanningFileEditorObservation"> {
/**
* Content returned from the tool as a list of TextContent/ImageContent objects.
*/
@@ -80,22 +80,19 @@ interface ConversationStateUpdateEventBase extends BaseEvent {
}
// Narrowed interfaces for full state update event
export interface ConversationStateUpdateEventFullState
extends ConversationStateUpdateEventBase {
export interface ConversationStateUpdateEventFullState extends ConversationStateUpdateEventBase {
key: "full_state";
value: ConversationState;
}
// Narrowed interface for agent status update event
export interface ConversationStateUpdateEventAgentStatus
extends ConversationStateUpdateEventBase {
export interface ConversationStateUpdateEventAgentStatus extends ConversationStateUpdateEventBase {
key: "execution_status";
value: V1ExecutionStatus;
}
// Narrowed interface for stats update event
export interface ConversationStateUpdateEventStats
extends ConversationStateUpdateEventBase {
export interface ConversationStateUpdateEventStats extends ConversationStateUpdateEventBase {
key: "stats";
value: ConversationStats;
}
@@ -21,8 +21,9 @@ export interface ObservationBaseEvent extends BaseEvent {
}
// Main observation event interface
export interface ObservationEvent<T extends Observation = Observation>
extends ObservationBaseEvent {
export interface ObservationEvent<
T extends Observation = Observation,
> extends ObservationBaseEvent {
/**
* The observation (tool call) sent to LLM
*/
+18
View File
@@ -7,6 +7,8 @@ import {
ExecuteBashObservation,
PlanningFileEditorObservation,
TerminalObservation,
BrowserObservation,
BrowserNavigateAction,
} from "./core";
import { AgentErrorEvent } from "./core/events/observation-event";
import { MessageEvent } from "./core/events/message-event";
@@ -126,6 +128,22 @@ export const isPlanningFileEditorObservationEvent = (
isObservationEvent(event) &&
event.observation.kind === "PlanningFileEditorObservation";
/**
* Type guard function to check if an observation event is a BrowserObservation
*/
export const isBrowserObservationEvent = (
event: OpenHandsEvent,
): event is ObservationEvent<BrowserObservation> =>
isObservationEvent(event) && event.observation.kind === "BrowserObservation";
/**
* Type guard function to check if an action event is a BrowserNavigateAction
*/
export const isBrowserNavigateActionEvent = (
event: OpenHandsEvent,
): event is ActionEvent<BrowserNavigateAction> =>
isActionEvent(event) && event.action.kind === "BrowserNavigateAction";
/**
* Type guard function to check if an event is a system prompt event
*/
-2
View File
@@ -17,6 +17,4 @@ export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
export const USE_V1_CONVERSATION_API = () =>
loadFeatureFlag("USE_V1_CONVERSATION_API");
export const USE_PLANNING_AGENT = () => loadFeatureFlag("USE_PLANNING_AGENT");
+10 -10
View File
@@ -606,10 +606,15 @@ export const shouldIncludeRepository = (
* @returns The query string for searching OpenHands repositories
*/
export const getOpenHandsQuery = (provider: Provider | null): string => {
if (provider === "gitlab") {
return "openhands-config";
}
return ".openhands";
const providerRepositorySuffix: Record<string, string> = {
gitlab: "openhands-config",
azure_devops: "openhands-config",
default: ".openhands",
} as const;
return provider && provider in providerRepositorySuffix
? providerRepositorySuffix[provider]
: providerRepositorySuffix.default;
};
/**
@@ -621,12 +626,7 @@ export const getOpenHandsQuery = (provider: Provider | null): string => {
export const hasOpenHandsSuffix = (
repo: GitRepository,
provider: Provider | null,
): boolean => {
if (provider === "gitlab") {
return repo.full_name.endsWith("/openhands-config");
}
return repo.full_name.endsWith("/.openhands");
};
): boolean => repo.full_name.endsWith(`/${getOpenHandsQuery(provider)}`);
/**
* Build headers for V1 API requests that require session authentication
+1 -2
View File
@@ -2,8 +2,7 @@
This directory contains the core components of OpenHands.
This diagram provides an overview of the roles of each component and how they communicate and collaborate.
![OpenHands System Architecture Diagram (July 4, 2024)](../docs/static/img/system_architecture_overview.png)
For an overview of the system architecture, see the [architecture documentation](https://docs.openhands.dev/usage/architecture/backend) (v0 backend architecture).
## Classes
@@ -102,6 +102,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
sandbox_startup_poll_frequency: int
httpx_client: httpx.AsyncClient
web_url: str | None
openhands_provider_base_url: str | None
access_token_hard_timeout: timedelta | None
app_mode: str | None = None
keycloak_auth_cookie: str | None = None
@@ -526,13 +527,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
if not request.llm_model and parent_info.llm_model:
request.llm_model = parent_info.llm_model
async def _setup_secrets_for_git_provider(
self, git_provider: ProviderType | None, user: UserInfo
) -> dict:
"""Set up secrets for git provider authentication.
async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict:
"""Set up secrets for all git provider authentication.
Args:
git_provider: The git provider type (GitHub, GitLab, etc.)
user: User information containing authentication details
Returns:
@@ -540,35 +538,42 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
"""
secrets = await self.user_context.get_secrets()
if not git_provider:
# Get all provider tokens from user authentication
provider_tokens = await self.user_context.get_provider_tokens()
if not provider_tokens:
return secrets
secret_name = f'{git_provider.name}_TOKEN'
# Create secrets for each provider token
for provider_type, provider_token in provider_tokens.items():
if not provider_token.token:
continue
if self.web_url:
# Create an access token for web-based authentication
access_token = self.jwt_service.create_jws_token(
payload={
'user_id': user.id,
'provider_type': git_provider.value,
},
expires_in=self.access_token_hard_timeout,
)
headers = {'X-Access-Token': access_token}
secret_name = f'{provider_type.name}_TOKEN'
# Include keycloak_auth cookie in headers if app_mode is SaaS
if self.app_mode == 'saas' and self.keycloak_auth_cookie:
headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
if self.web_url:
# Create an access token for web-based authentication
access_token = self.jwt_service.create_jws_token(
payload={
'user_id': user.id,
'provider_type': provider_type.value,
},
expires_in=self.access_token_hard_timeout,
)
headers = {'X-Access-Token': access_token}
secrets[secret_name] = LookupSecret(
url=self.web_url + '/api/v1/webhooks/secrets',
headers=headers,
)
else:
# Use static token for environments without web URL access
static_token = await self.user_context.get_latest_token(git_provider)
if static_token:
secrets[secret_name] = StaticSecret(value=static_token)
# Include keycloak_auth cookie in headers if app_mode is SaaS
if self.app_mode == 'saas' and self.keycloak_auth_cookie:
headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
secrets[secret_name] = LookupSecret(
url=self.web_url + '/api/v1/webhooks/secrets',
headers=headers,
)
else:
# Use static token for environments without web URL access
static_token = await self.user_context.get_latest_token(provider_type)
if static_token:
secrets[secret_name] = StaticSecret(value=static_token)
return secrets
@@ -586,9 +591,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
"""
# Configure LLM
model = llm_model or user.llm_model
base_url = user.llm_base_url
if model and model.startswith('openhands/'):
base_url = user.llm_base_url or self.openhands_provider_base_url
llm = LLM(
model=model,
base_url=user.llm_base_url,
base_url=base_url,
api_key=user.llm_api_key,
usage_id='agent',
)
@@ -768,8 +776,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
user = await self.user_context.get_user_info()
workspace = LocalWorkspace(working_dir=working_dir)
# Set up secrets for git provider
secrets = await self._setup_secrets_for_git_provider(git_provider, user)
# Set up secrets for all git providers
secrets = await self._setup_secrets_for_git_providers(user)
# Configure LLM and MCP
llm, mcp_config = await self._configure_llm_and_mcp(user, llm_model)
@@ -1078,6 +1086,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
httpx_client=httpx_client,
web_url=web_url,
openhands_provider_base_url=config.openhands_provider_base_url,
access_token_hard_timeout=access_token_hard_timeout,
app_mode=app_mode,
keycloak_auth_cookie=keycloak_auth_cookie,
+9
View File
@@ -74,6 +74,11 @@ def get_default_web_url() -> str | None:
return f'https://{web_host}'
def get_openhands_provider_base_url() -> str | None:
"""Return the base URL for the OpenHands provider, if configured."""
return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None
def _get_default_lifespan():
# Check legacy parameters for saas mode. If we are in SAAS mode do not apply
# OSS alembic migrations
@@ -88,6 +93,10 @@ class AppServerConfig(OpenHandsModel):
default_factory=get_default_web_url,
description='The URL where OpenHands is running (e.g., http://localhost:3000)',
)
openhands_provider_base_url: str | None = Field(
default_factory=get_openhands_provider_base_url,
description='Base URL for the OpenHands provider',
)
# Dependency Injection Injectors
event: EventServiceInjector | None = None
event_callback: EventCallbackServiceInjector | None = None
@@ -60,16 +60,22 @@ _logger = logging.getLogger(__name__)
async def valid_sandbox(
sandbox_id: str,
user_context: UserContext = Depends(as_admin),
session_api_key: str = Depends(
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
),
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
sandbox_info = await sandbox_service.get_sandbox(sandbox_id)
if sandbox_info is None or sandbox_info.session_api_key != session_api_key:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
if session_api_key is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
)
sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
if sandbox_info is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
)
return sandbox_info
@@ -94,7 +100,7 @@ async def valid_conversation(
return app_conversation_info
@router.post('/{sandbox_id}/conversations')
@router.post('/conversations')
async def on_conversation_update(
conversation_info: ConversationInfo,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
@@ -125,7 +131,7 @@ async def on_conversation_update(
return Success()
@router.post('/{sandbox_id}/events/{conversation_id}')
@router.post('/events/{conversation_id}')
async def on_event(
events: list[Event],
conversation_id: UUID,
@@ -260,6 +260,29 @@ class DockerSandboxService(SandboxService):
except (NotFound, APIError):
return None
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
"""Get a single sandbox by session API key."""
try:
# Get all containers with our prefix
all_containers = self.docker_client.containers.list(all=True)
for container in all_containers:
if container.name and container.name.startswith(
self.container_name_prefix
):
# Check if this container has the matching session API key
env_vars = self._get_container_env_vars(container)
container_session_key = env_vars.get(SESSION_API_KEY_VARIABLE)
if container_session_key == session_api_key:
return await self._container_to_checked_sandbox_info(container)
return None
except (NotFound, APIError):
return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
# Enforce sandbox limits by cleaning up old sandboxes
@@ -285,8 +308,7 @@ class DockerSandboxService(SandboxService):
env_vars = sandbox_spec.initial_env.copy()
env_vars[SESSION_API_KEY_VARIABLE] = session_api_key
env_vars[WEBHOOK_CALLBACK_VARIABLE] = (
f'http://host.docker.internal:{self.host_port}'
f'/api/v1/webhooks/{container_name}'
f'http://host.docker.internal:{self.host_port}/api/v1/webhooks'
)
# Prepare port mappings and add port environment variables
@@ -275,6 +275,17 @@ class ProcessSandboxService(SandboxService):
return await self._process_to_sandbox_info(sandbox_id, process_info)
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
"""Get a single sandbox by session API key."""
# Search through all processes to find one with matching session_api_key
for sandbox_id, process_info in _processes.items():
if process_info.session_api_key == session_api_key:
return await self._process_to_sandbox_info(sandbox_id, process_info)
return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
# Get sandbox spec
@@ -240,9 +240,7 @@ class RemoteSandboxService(SandboxService):
# If a public facing url is defined, add a callback to the agent server environment.
if self.web_url:
environment[WEBHOOK_CALLBACK_VARIABLE] = (
f'{self.web_url}/api/v1/webhooks/{sandbox_id}'
)
environment[WEBHOOK_CALLBACK_VARIABLE] = f'{self.web_url}/api/v1/webhooks'
# We specify CORS settings only if there is a public facing url - otherwise
# we are probably in local development and the only url in use is localhost
environment[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url
@@ -301,6 +299,27 @@ class RemoteSandboxService(SandboxService):
return None
return await self._to_sandbox_info(stored_sandbox)
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> Union[SandboxInfo, None]:
"""Get a single sandbox by session API key."""
# Get all stored sandboxes for the current user
stmt = await self._secure_select()
result = await self.db_session.execute(stmt)
stored_sandboxes = result.scalars().all()
# Check each sandbox's runtime data for matching session_api_key
for stored_sandbox in stored_sandboxes:
try:
runtime = await self._get_runtime(stored_sandbox.id)
if runtime and runtime.get('session_api_key') == session_api_key:
return await self._to_sandbox_info(stored_sandbox, runtime)
except Exception:
# Continue checking other sandboxes if one fails
continue
return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox by creating a remote runtime."""
try:
@@ -25,6 +25,12 @@ class SandboxService(ABC):
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
"""Get a single sandbox. Return None if the sandbox was not found."""
@abstractmethod
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
"""Get a single sandbox by session API key. Return None if the sandbox was not found."""
async def batch_get_sandboxes(
self, sandbox_ids: list[str]
) -> list[SandboxInfo | None]:
@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:5f62cee-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:37c4b35-python'
class SandboxSpecService(ABC):
@@ -9,7 +9,11 @@ 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, UserContextInjector
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import ProviderHandler, ProviderType
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderHandler,
ProviderType,
)
from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
@@ -44,6 +48,9 @@ class AuthUserContext(UserContext):
self._user_info = user_info
return user_info
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return await self.user_auth.get_provider_tokens()
async def get_provider_handler(self):
provider_handler = self._provider_handler
if not provider_handler:
@@ -5,7 +5,7 @@ from fastapi import Request
from openhands.app_server.errors import OpenHandsError
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import ProviderType
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.sdk.conversation.secret_source import SecretSource
@@ -24,6 +24,9 @@ class SpecifyUserContext(UserContext):
async def get_authenticated_git_url(self, repository: str) -> str:
raise NotImplementedError()
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
raise NotImplementedError()
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
raise NotImplementedError()
+5 -1
View File
@@ -4,7 +4,7 @@ from openhands.app_server.services.injector import Injector
from openhands.app_server.user.user_models import (
UserInfo,
)
from openhands.integrations.provider import ProviderType
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
from openhands.sdk.conversation.secret_source import SecretSource
from openhands.sdk.utils.models import DiscriminatedUnionMixin
@@ -26,6 +26,10 @@ class UserContext(ABC):
async def get_authenticated_git_url(self, repository: str) -> str:
"""Get the provider tokens for the user"""
@abstractmethod
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
"""Get the latest tokens for all provider types"""
@abstractmethod
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
"""Get the latest token for the provider type given"""
@@ -1,3 +1,4 @@
import hashlib
import os
from datetime import datetime
from pathlib import Path
@@ -30,8 +31,14 @@ def get_default_encryption_keys(workspace_dir: Path) -> list[EncryptionKey]:
"""Generate default encryption keys."""
master_key = os.getenv('JWT_SECRET')
if master_key:
# Derive a deterministic key ID from the secret itself.
# This ensures all pods using the same JWT_SECRET get the same key ID,
# which is critical for multi-pod deployments where tokens may be
# created by one pod and verified by another.
key_id = base62.encodebytes(hashlib.sha256(master_key.encode()).digest())
return [
EncryptionKey(
id=key_id,
key=SecretStr(master_key),
active=True,
notes='jwt secret master key',
+3 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import os
from pydantic import (
BaseModel,
ConfigDict,
@@ -48,7 +50,7 @@ class Settings(BaseModel):
email_verified: bool | None = None
git_user_name: str | None = None
git_user_email: str | None = None
v1_enabled: bool | None = None
v1_enabled: bool | None = Field(default=bool(os.getenv('V1_ENABLED') == '1'))
model_config = ConfigDict(
validate_assignment=True,
Generated
+181 -107
View File
@@ -254,14 +254,14 @@ files = [
[[package]]
name = "anthropic"
version = "0.72.0"
version = "0.75.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
{file = "anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b"},
{file = "anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb"},
]
[package.dependencies]
@@ -1205,34 +1205,37 @@ botocore = ["botocore"]
[[package]]
name = "browser-use"
version = "0.8.0"
version = "0.10.1"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
{file = "browser_use-0.8.0-py3-none-any.whl", hash = "sha256:b7c299e38ec1c1aec42a236cc6ad2268a366226940d6ff9d88ed461afd5a1cc3"},
{file = "browser_use-0.8.0.tar.gz", hash = "sha256:2136eb3251424f712a08ee379c9337237c2f93b29b566807db599cf94e6abb5e"},
{file = "browser_use-0.10.1-py3-none-any.whl", hash = "sha256:96e603bfc71098175342cdcb0592519e6f244412e740f0254e4389fdd82a977f"},
{file = "browser_use-0.10.1.tar.gz", hash = "sha256:5f211ecfdf1f9fd186160f10df70dedd661821231e30f1bce40939787abab223"},
]
[package.dependencies]
aiohttp = "3.12.15"
anthropic = ">=0.68.1,<1.0.0"
anthropic = ">=0.72.1,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
cdp-use = ">=1.4.0"
cdp-use = ">=1.4.4"
click = ">=8.1.8"
cloudpickle = ">=3.1.1"
google-api-core = ">=2.25.0"
google-api-python-client = ">=2.174.0"
google-auth = ">=2.40.3"
google-auth-oauthlib = ">=1.2.2"
google-genai = ">=1.29.0,<2.0.0"
google-genai = ">=1.50.0,<2.0.0"
groq = ">=0.30.0"
html2text = ">=2025.4.15"
httpx = ">=0.28.1"
inquirerpy = ">=0.3.4"
markdownify = ">=1.2.0"
mcp = ">=1.10.1"
ollama = ">=0.5.1"
openai = ">=1.99.2,<2.0.0"
openai = ">=2.7.2,<3.0.0"
pillow = ">=11.2.1"
portalocker = ">=2.7.0,<3.0.0"
posthog = ">=3.7.0"
@@ -1241,19 +1244,24 @@ pydantic = ">=2.11.5"
pyobjc = {version = ">=11.0", markers = "platform_system == \"darwin\""}
pyotp = ">=2.9.0"
pypdf = ">=5.7.0"
python-docx = ">=1.2.0"
python-dotenv = ">=1.0.1"
reportlab = ">=4.0.0"
requests = ">=2.32.3"
rich = ">=14.0.0"
screeninfo = {version = ">=0.8.1", markers = "platform_system != \"darwin\""}
typing-extensions = ">=4.12.2"
uuid7 = ">=0.1.0"
[package.extras]
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "oci (>=2.126.4)", "textual (>=3.2.0)"]
aws = ["boto3 (>=1.38.45)"]
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
cli = ["textual (>=3.2.0)"]
cli-oci = ["oci (>=2.126.4)", "textual (>=3.2.0)"]
code = ["matplotlib (>=3.9.0)", "numpy (>=2.3.2)", "pandas (>=2.2.0)", "tabulate (>=0.9.0)"]
eval = ["anyio (>=4.9.0)", "datamodel-code-generator (>=0.26.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
oci = ["oci (>=2.126.4)"]
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
[[package]]
@@ -1494,14 +1502,14 @@ files = [
[[package]]
name = "cdp-use"
version = "1.4.3"
version = "1.4.4"
description = "Type safe generator/client library for CDP"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "cdp_use-1.4.3-py3-none-any.whl", hash = "sha256:c48664604470c2579aa1e677c3e3e7e24c4f300c54804c093d935abb50479ecd"},
{file = "cdp_use-1.4.3.tar.gz", hash = "sha256:9029c04bdc49fbd3939d2bf1988ad8d88e260729c7d5e35c2f6c87591f5a10e9"},
{file = "cdp_use-1.4.4-py3-none-any.whl", hash = "sha256:e37e80e067db2653d6fdf953d4ff9e5d80d75daa27b7c6d48c0261cccbef73e1"},
{file = "cdp_use-1.4.4.tar.gz", hash = "sha256:330a848b517006eb9ad1dc468aa6434d913cf0c6918610760c36c3fdfdba0fab"},
]
[package.dependencies]
@@ -3802,28 +3810,28 @@ testing = ["pytest"]
[[package]]
name = "google-genai"
version = "1.45.0"
version = "1.53.0"
description = "GenAI Python SDK"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "google_genai-1.45.0-py3-none-any.whl", hash = "sha256:e755295063e5fd5a4c44acff782a569e37fa8f76a6c75d0ede3375c70d916b7f"},
{file = "google_genai-1.45.0.tar.gz", hash = "sha256:96ec32ae99a30b5a1b54cb874b577ec6e41b5d5b808bf0f10ed4620e867f9386"},
{file = "google_genai-1.53.0-py3-none-any.whl", hash = "sha256:65a3f99e5c03c372d872cda7419f5940e723374bb12a2f3ffd5e3e56e8eb2094"},
{file = "google_genai-1.53.0.tar.gz", hash = "sha256:938a26d22f3fd32c6eeeb4276ef204ef82884e63af9842ce3eac05ceb39cbd8d"},
]
[package.dependencies]
anyio = ">=4.8.0,<5.0.0"
google-auth = ">=2.14.1,<3.0.0"
google-auth = {version = ">=2.14.1,<3.0.0", extras = ["requests"]}
httpx = ">=0.28.1,<1.0.0"
pydantic = ">=2.0.0,<3.0.0"
pydantic = ">=2.9.0,<3.0.0"
requests = ">=2.28.1,<3.0.0"
tenacity = ">=8.2.3,<9.2.0"
typing-extensions = ">=4.11.0,<5.0.0"
websockets = ">=13.0.0,<15.1.0"
[package.extras]
aiohttp = ["aiohttp (<4.0.0)"]
aiohttp = ["aiohttp (<3.13.3)"]
local-tokenizer = ["protobuf", "sentencepiece (>=0.2.0)"]
[[package]]
@@ -3991,67 +3999,71 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
[[package]]
name = "grpcio"
version = "1.72.1"
version = "1.67.1"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.9"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "grpcio-1.72.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:ce2706ff37be7a6de68fbc4c3f8dde247cab48cc70fee5fedfbc9cd923b4ee5a"},
{file = "grpcio-1.72.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7db9e15ee7618fbea748176a67d347f3100fa92d36acccd0e7eeb741bc82f72a"},
{file = "grpcio-1.72.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:8d6e7764181ba4a8b74aa78c98a89c9f3441068ebcee5d6f14c44578214e0be3"},
{file = "grpcio-1.72.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:237bb619ba33594006025e6f114f62e60d9563afd6f8e89633ee384868e26687"},
{file = "grpcio-1.72.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7f1d8a442fd242aa432c8e1b8411c79ebc409dad2c637614d726e226ce9ed0c"},
{file = "grpcio-1.72.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f2359bd4bba85bf94fd9ab8802671b9637a6803bb673d221157a11523a52e6a8"},
{file = "grpcio-1.72.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3269cfca37570a420a57a785f2a5d4234c5b12aced55f8843dafced2d3f8c9a6"},
{file = "grpcio-1.72.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:06c023d86398714d6257194c21f2bc0b58a53ce45cee87dd3c54c7932c590e17"},
{file = "grpcio-1.72.1-cp310-cp310-win32.whl", hash = "sha256:06dbe54eeea5f9dfb3e7ca2ff66c715ff5fc96b07a1feb322122fe14cb42f6aa"},
{file = "grpcio-1.72.1-cp310-cp310-win_amd64.whl", hash = "sha256:ba593aa2cd52f4468ba29668c83f893d88c128198d6b1273ca788ef53e3ae5fe"},
{file = "grpcio-1.72.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:4e112c083f90c330b0eaa78a633fb206d49c20c443926e827f8cac9eb9d2ea32"},
{file = "grpcio-1.72.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c6f7e3275832adab7384193f78b8c1a98b82541562fa08d7244e8a6b4b5c78a4"},
{file = "grpcio-1.72.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:dd03c8847c47ef7ac5455aafdfb5e553ecf84f228282bd6106762b379f27c25c"},
{file = "grpcio-1.72.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7497dbdf220b88b66004e2630fb2b1627df5e279db970d3cc20f70d39dce978d"},
{file = "grpcio-1.72.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c2cde3ae8ae901317c049394ed8d3c6964de6b814ae65fc68636a7337b63aa"},
{file = "grpcio-1.72.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7a66cef4bc1db81a54108a849e95650da640c9bc1901957bf7d3b1eeb3251ee8"},
{file = "grpcio-1.72.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fc0435ad45d540597f78978e3fd5515b448193f51f9065fb67dda566336e0f5f"},
{file = "grpcio-1.72.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:524bad78d610fa1f9f316d47b3aab1ff89d438ba952ee34e3e335ca80a27ba96"},
{file = "grpcio-1.72.1-cp311-cp311-win32.whl", hash = "sha256:409ee0abf7e74bbf88941046142452cf3d1f3863d34e11e8fd2b07375170c730"},
{file = "grpcio-1.72.1-cp311-cp311-win_amd64.whl", hash = "sha256:ea483e408fac55569c11158c3e6d6d6a8c3b0f798b68f1c10db9b22c5996e19b"},
{file = "grpcio-1.72.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:65a5ef28e5852bd281c6d01a923906e8036736e95e370acab8626fcbec041e67"},
{file = "grpcio-1.72.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:9e5c594a6c779d674204fb9bdaa1e7b71666ff10b34a62e7769fc6868b5d7511"},
{file = "grpcio-1.72.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:d324f4bdb990d852d79b38c59a12d24fcd47cf3b1a38f2e4d2b6d0b1031bc818"},
{file = "grpcio-1.72.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:841db55dd29cf2f4121b853b2f89813a1b6175163fbb92c5945fb1b0ca259ef2"},
{file = "grpcio-1.72.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00da930aa2711b955a538e835096aa365a4b7f2701bdc2ce1febb242a103f8a1"},
{file = "grpcio-1.72.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4b657773480267fbb7ad733fa85abc103c52ab62e5bc97791faf82c53836eefc"},
{file = "grpcio-1.72.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a08b483f17a6abca2578283a7ae3aa8d4d90347242b0de2898bdb27395c3f20b"},
{file = "grpcio-1.72.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:299f3ea4e03c1d0548f4a174b48d612412f92c667f2100e30a079ab76fdaa813"},
{file = "grpcio-1.72.1-cp312-cp312-win32.whl", hash = "sha256:addc721a3708ff789da1bf69876018dc730c1ec9d3d3cb6912776a00c535a5bc"},
{file = "grpcio-1.72.1-cp312-cp312-win_amd64.whl", hash = "sha256:22ea2aa92a60dff231ba5fcd7f0220a33c2218e556009996f858eeafe294d1c2"},
{file = "grpcio-1.72.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:294be6e9c323a197434569a41e0fb5b5aa0962fd5d55a3dc890ec5df985f611a"},
{file = "grpcio-1.72.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:41ec164dac8df2862f67457d9cdf8d8f8b6a4ca475a3ed1ba6547fff98d93717"},
{file = "grpcio-1.72.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:761736f75c6ddea3732d97eaabe70c616271f5f542a8be95515135fdd1a638f6"},
{file = "grpcio-1.72.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:082003cb93618964c111c70d69b60ac0dc6566d4c254c9b2a775faa2965ba8f8"},
{file = "grpcio-1.72.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8660f736da75424949c14f7c8b1ac60a25b2f37cabdec95181834b405373e8a7"},
{file = "grpcio-1.72.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2ada1abe2ad122b42407b2bfd79d6706a4940d4797f44bd740f5c98ca1ecda9b"},
{file = "grpcio-1.72.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0db2766d0c482ee740abbe7d00a06cc4fb54f7e5a24d3cf27c3352be18a2b1e8"},
{file = "grpcio-1.72.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4bdb404d9c2187260b34e2b22783c204fba8a9023a166cf77376190d9cf5a08"},
{file = "grpcio-1.72.1-cp313-cp313-win32.whl", hash = "sha256:bb64722c3124c906a5b66e50a90fd36442642f653ba88a24f67d08e94bca59f3"},
{file = "grpcio-1.72.1-cp313-cp313-win_amd64.whl", hash = "sha256:329cc6ff5b431df9614340d3825b066a1ff0a5809a01ba2e976ef48c65a0490b"},
{file = "grpcio-1.72.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:8941b83addd503c1982090b4631804d0ff1edbbc6c85c9c20ed503b1dc65fef9"},
{file = "grpcio-1.72.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:d29b80290c5eda561a4c291d6d5b4315a2a5095ab37061118d6e0781858aca0a"},
{file = "grpcio-1.72.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:4ca56d955564db749c9c6d75e9c4c777854e22b2482d247fb6c5a02d5f28ea78"},
{file = "grpcio-1.72.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b08a3ef14d2b01eef13882c6d3a2d8fb5fcd73db81bd1e3ab69d4ee75215433a"},
{file = "grpcio-1.72.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7df49801b3b323e4a21047979e3834cd286b32ee5ceee46f5217826274721f"},
{file = "grpcio-1.72.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9717617ba2ff65c058ef53b0d5e50f03e8350f0c5597f93bb5c980a31db990c8"},
{file = "grpcio-1.72.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:212db80b1e8aa7792d51269bfb32164e2333a9bb273370ace3ed2a378505cb01"},
{file = "grpcio-1.72.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a0d19947d4480af5f363f077f221e665931f479e2604280ac4eafe6daa71f77"},
{file = "grpcio-1.72.1-cp39-cp39-win32.whl", hash = "sha256:7622ef647dc911ed010a817d9be501df4ae83495b8e5cdd35b555bdcf3880a3e"},
{file = "grpcio-1.72.1-cp39-cp39-win_amd64.whl", hash = "sha256:f8d8fa7cd2a7f1b4207e215dec8bc07f1202682d9a216ebe028185c15faece30"},
{file = "grpcio-1.72.1.tar.gz", hash = "sha256:87f62c94a40947cec1a0f91f95f5ba0aa8f799f23a1d42ae5be667b6b27b959c"},
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
]
[package.extras]
protobuf = ["grpcio-tools (>=1.72.1)"]
protobuf = ["grpcio-tools (>=1.67.1)"]
[[package]]
name = "grpcio-status"
@@ -4434,6 +4446,25 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "inquirerpy"
version = "0.3.4"
description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"},
{file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"},
]
[package.dependencies]
pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
version = "0.7.0"
@@ -5609,25 +5640,26 @@ types-tqdm = "*"
[[package]]
name = "litellm"
version = "1.77.7"
version = "1.80.7"
description = "Library to easily interface with LLM API providers"
optional = false
python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
{file = "litellm-1.77.7-py3-none-any.whl", hash = "sha256:1b3a1b17bd521a0ad25226fb62a912602c803922aabb4a16adf83834673be574"},
{file = "litellm-1.77.7.tar.gz", hash = "sha256:e3398fb2575b98726e787c0a1481daed5938d58cafdcd96fbca80c312221af3e"},
{file = "litellm-1.80.7-py3-none-any.whl", hash = "sha256:f7d993f78c1e0e4e1202b2a925cc6540b55b6e5fb055dd342d88b145ab3102ed"},
{file = "litellm-1.80.7.tar.gz", hash = "sha256:3977a8d195aef842d01c18bf9e22984829363c6a4b54daf9a43c9dd9f190b42c"},
]
[package.dependencies]
aiohttp = ">=3.10"
click = "*"
fastuuid = ">=0.13.0"
grpcio = ">=1.62.3,<1.68.0"
httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.22.0,<5.0.0"
openai = ">=1.99.5"
openai = ">=2.8.0"
pydantic = ">=2.5.0,<3.0.0"
python-dotenv = ">=0.2.0"
tiktoken = ">=0.7.0"
@@ -5635,10 +5667,10 @@ tokenizers = "*"
[package.extras]
caching = ["diskcache (>=5.6.1,<6.0.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.20)", "litellm-proxy-extras (==0.2.25)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"]
semantic-router = ["semantic-router ; python_version >= \"3.9\""]
proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.22)", "litellm-proxy-extras (==0.4.9)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.14\""]
utils = ["numpydoc"]
[[package]]
@@ -5908,14 +5940,14 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "markdownify"
version = "1.1.0"
version = "1.2.2"
description = "Convert HTML to markdown."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef"},
{file = "markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd"},
{file = "markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a"},
{file = "markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09"},
]
[package.dependencies]
@@ -7191,28 +7223,28 @@ pydantic = ">=2.9"
[[package]]
name = "openai"
version = "1.99.9"
version = "2.8.0"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main", "evaluation"]
files = [
{file = "openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a"},
{file = "openai-1.99.9.tar.gz", hash = "sha256:f2082d155b1ad22e83247c3de3958eb4255b20ccf4a1de2e6681b6957b554e92"},
{file = "openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf"},
{file = "openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
httpx = ">=0.23.0,<1"
jiter = ">=0.4.0,<1"
jiter = ">=0.10.0,<1"
pydantic = ">=1.9.0,<3"
sniffio = "*"
tqdm = ">4"
typing-extensions = ">=4.11,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
realtime = ["websockets (>=13,<16)"]
voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
@@ -7347,14 +7379,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.3.0"
version = "1.4.1"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.3.0-py3-none-any.whl", hash = "sha256:2f87f790c740dc3fb81821c5f9fa375af875fbb937ebca3baa6dc5c035035b3c"},
{file = "openhands_agent_server-1.3.0.tar.gz", hash = "sha256:0a83ae77373f5c41d0ba0e22d8f0f6144d54d55784183a50b7c098c96cd5135c"},
{file = "openhands_agent_server-1.4.1-py3-none-any.whl", hash = "sha256:1e621d15215a48e2398e23c58a791347f06c215c2344053aeb26b562c34a44ee"},
{file = "openhands_agent_server-1.4.1.tar.gz", hash = "sha256:03010a5c8d63bbd5b088458eb75308ef16559018140d75a3644ae5bbc3531bbf"},
]
[package.dependencies]
@@ -7362,6 +7394,7 @@ aiosqlite = ">=0.19"
alembic = ">=1.13"
docker = ">=7.1,<8"
fastapi = ">=0.104"
openhands-sdk = "*"
pydantic = ">=2"
sqlalchemy = ">=2"
uvicorn = ">=0.31.1"
@@ -7370,21 +7403,21 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
version = "1.3.0"
version = "1.4.1"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.3.0-py3-none-any.whl", hash = "sha256:feee838346f8e60ea3e4d3391de7cb854314eb8b3c9e3dbbb56f98a784aadc56"},
{file = "openhands_sdk-1.3.0.tar.gz", hash = "sha256:2d060803a78de462121b56dea717a66356922deb02276f37b29fae8af66343fb"},
{file = "openhands_sdk-1.4.1-py3-none-any.whl", hash = "sha256:70e453eab7f9ab6b705198c2615fdd844b21e14b29d78afaf62724f4a440bcdc"},
{file = "openhands_sdk-1.4.1.tar.gz", hash = "sha256:37365de25ed57cf8cc2a8003ab4d7a1fe2a40b49c8e8da84a3f1ea2b522eddf2"},
]
[package.dependencies]
deprecation = ">=2.1.0"
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.77.7.dev9"
litellm = ">=1.80.7"
lmnr = ">=0.7.20"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
@@ -7397,14 +7430,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.3.0"
version = "1.4.1"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.3.0-py3-none-any.whl", hash = "sha256:f31056d87c3058ac92709f9161c7c602daeee3ed0cb4439097b43cda105ed03e"},
{file = "openhands_tools-1.3.0.tar.gz", hash = "sha256:3da46f09e28593677d3e17252ce18584fcc13caab1a73213e66bd7edca2cebe0"},
{file = "openhands_tools-1.4.1-py3-none-any.whl", hash = "sha256:8f40189a08bf80eb4a33219ee9ccc528f9c6c4f2d5c9ab807b06c3f3fe21a612"},
{file = "openhands_tools-1.4.1.tar.gz", hash = "sha256:4c0caf87f520a207d9035191c77b7b5c53eeec996350a24ffaf7f740a6566b22"},
]
[package.dependencies]
@@ -7416,6 +7449,7 @@ func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
tom-swe = ">=1.0.3"
[[package]]
name = "openpyxl"
@@ -7928,6 +7962,21 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
name = "pfzy"
version = "0.3.4"
description = "Python port of the fzy fuzzy string matching algorithm"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"},
{file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"},
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
version = "1.31.5"
@@ -14969,6 +15018,31 @@ dev = ["tokenizers[testing]"]
docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"]
testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"]
[[package]]
name = "tom-swe"
version = "1.0.3"
description = "Theory of Mind modeling for Software Engineering assistants"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "tom_swe-1.0.3-py3-none-any.whl", hash = "sha256:7b1172b29eb5c8fb7f1975016e7b6a238511b9ac2a7a980bd400dcb4e29773f2"},
{file = "tom_swe-1.0.3.tar.gz", hash = "sha256:57c97d0104e563f15bd39edaf2aa6ac4c3e9444afd437fb92458700d22c6c0f5"},
]
[package.dependencies]
jinja2 = ">=3.0.0"
json-repair = ">=0.1.0"
litellm = ">=1.0.0"
pydantic = ">=2.0.0"
python-dotenv = ">=1.0.0"
tiktoken = ">=0.8.0"
tqdm = ">=4.65.0"
[package.extras]
dev = ["aiofiles (>=23.0.0)", "black (>=22.0.0)", "datasets (>=2.0.0)", "fastapi (>=0.104.0)", "httpx (>=0.25.0)", "huggingface-hub (>=0.0.0)", "isort (>=5.0.0)", "mypy (>=1.0.0)", "numpy (>=1.24.0)", "pandas (>=2.0.0)", "pre-commit (>=3.6.0)", "pytest (>=7.0.0)", "pytest-cov (>=6.2.1)", "rich (>=13.0.0)", "ruff (>=0.3.0)", "typing-extensions (>=4.0.0)", "uvicorn (>=0.24.0)"]
search = ["bm25s (>=0.2.0)", "pystemmer (>=2.2.0)"]
[[package]]
name = "toml"
version = "0.10.2"
@@ -16748,4 +16822,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "52eda0cd6b8a2e245057c005edcf93daf4151e99d019477c8b74ddd0940e890f"
content-hash = "c208fcc692f74540f7b6e822136002dd0f079a3d8d1b93227a5bb07a7f4432cb"
+5 -5
View File
@@ -26,8 +26,8 @@ build = "build_vscode.py" # Build VSCode extension during Poetry build
[tool.poetry.dependencies]
python = "^3.12,<3.14"
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
openai = "1.99.9" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711)
litellm = ">=1.74.3, <=1.80.7, !=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.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-genai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
@@ -116,9 +116,9 @@ pybase62 = "^1.0.0"
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" }
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" }
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" }
openhands-sdk = "1.3.0"
openhands-agent-server = "1.3.0"
openhands-tools = "1.3.0"
openhands-sdk = "1.4.1"
openhands-agent-server = "1.4.1"
openhands-tools = "1.4.1"
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"
@@ -28,6 +28,8 @@ class TestLiveStatusAppConversationService:
"""Set up test fixtures."""
# Create mock dependencies
self.mock_user_context = Mock(spec=UserContext)
self.mock_user_auth = Mock()
self.mock_user_context.user_auth = self.mock_user_auth
self.mock_jwt_service = Mock()
self.mock_sandbox_service = Mock()
self.mock_sandbox_spec_service = Mock()
@@ -50,6 +52,7 @@ class TestLiveStatusAppConversationService:
sandbox_startup_poll_frequency=1,
httpx_client=self.mock_httpx_client,
web_url='https://test.example.com',
openhands_provider_base_url='https://provider.example.com',
access_token_hard_timeout=None,
app_mode='test',
keycloak_auth_cookie=None,
@@ -64,6 +67,7 @@ class TestLiveStatusAppConversationService:
self.mock_user.confirmation_mode = False
self.mock_user.search_api_key = None # Default to None
self.mock_user.condenser_max_size = None # Default to None
self.mock_user.llm_base_url = 'https://api.openai.com/v1'
# Mock sandbox
self.mock_sandbox = Mock(spec=SandboxInfo)
@@ -71,67 +75,83 @@ class TestLiveStatusAppConversationService:
self.mock_sandbox.status = SandboxStatus.RUNNING
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_no_provider(self):
"""Test _setup_secrets_for_git_provider with no git provider."""
async def test_setup_secrets_for_git_providers_no_provider_tokens(self):
"""Test _setup_secrets_for_git_providers with no provider tokens."""
# Arrange
base_secrets = {'existing': 'secret'}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_user_context.get_provider_tokens = AsyncMock(return_value=None)
# Act
result = await self.service._setup_secrets_for_git_provider(
None, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert result == base_secrets
self.mock_user_context.get_secrets.assert_called_once()
self.mock_user_context.get_provider_tokens.assert_called_once()
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_with_web_url(self):
"""Test _setup_secrets_for_git_provider with web URL (creates access token)."""
async def test_setup_secrets_for_git_providers_with_web_url(self):
"""Test _setup_secrets_for_git_providers with web URL (creates access token)."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
git_provider = ProviderType.GITHUB
# Mock provider tokens
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITHUB_TOKEN' in result
assert 'GITLAB_TOKEN' in result
assert isinstance(result['GITHUB_TOKEN'], LookupSecret)
assert isinstance(result['GITLAB_TOKEN'], LookupSecret)
assert (
result['GITHUB_TOKEN'].url
== 'https://test.example.com/api/v1/webhooks/secrets'
)
assert result['GITHUB_TOKEN'].headers['X-Access-Token'] == 'test_access_token'
self.mock_jwt_service.create_jws_token.assert_called_once_with(
payload={
'user_id': self.mock_user.id,
'provider_type': git_provider.value,
},
expires_in=None,
)
# Should be called twice, once for each provider
assert self.mock_jwt_service.create_jws_token.call_count == 2
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_with_saas_mode(self):
"""Test _setup_secrets_for_git_provider with SaaS mode (includes keycloak cookie)."""
async def test_setup_secrets_for_git_providers_with_saas_mode(self):
"""Test _setup_secrets_for_git_providers with SaaS mode (includes keycloak cookie)."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
self.service.app_mode = 'saas'
self.service.keycloak_auth_cookie = 'test_cookie'
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
git_provider = ProviderType.GITLAB
# Mock provider tokens
provider_tokens = {
ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITLAB_TOKEN' in result
@@ -141,40 +161,60 @@ class TestLiveStatusAppConversationService:
assert lookup_secret.headers['Cookie'] == 'keycloak_auth=test_cookie'
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_without_web_url(self):
"""Test _setup_secrets_for_git_provider without web URL (uses static token)."""
async def test_setup_secrets_for_git_providers_without_web_url(self):
"""Test _setup_secrets_for_git_providers without web URL (uses static token)."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
self.service.web_url = None
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_user_context.get_latest_token.return_value = 'static_token_value'
git_provider = ProviderType.GITHUB
# Mock provider tokens
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITHUB_TOKEN' in result
assert isinstance(result['GITHUB_TOKEN'], StaticSecret)
assert result['GITHUB_TOKEN'].value.get_secret_value() == 'static_token_value'
self.mock_user_context.get_latest_token.assert_called_once_with(git_provider)
self.mock_user_context.get_latest_token.assert_called_once_with(
ProviderType.GITHUB
)
@pytest.mark.asyncio
async def test_setup_secrets_for_git_provider_no_static_token(self):
"""Test _setup_secrets_for_git_provider when no static token is available."""
async def test_setup_secrets_for_git_providers_no_static_token(self):
"""Test _setup_secrets_for_git_providers when no static token is available."""
# Arrange
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
self.service.web_url = None
base_secrets = {}
self.mock_user_context.get_secrets.return_value = base_secrets
self.mock_user_context.get_latest_token.return_value = None
git_provider = ProviderType.GITHUB
# Mock provider tokens
provider_tokens = {
ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
}
self.mock_user_context.get_provider_tokens = AsyncMock(
return_value=provider_tokens
)
# Act
result = await self.service._setup_secrets_for_git_provider(
git_provider, self.mock_user
)
result = await self.service._setup_secrets_for_git_providers(self.mock_user)
# Assert
assert 'GITHUB_TOKEN' not in result
@@ -203,6 +243,70 @@ class TestLiveStatusAppConversationService:
assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp'
assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self):
"""openhands/* model uses user.llm_base_url when provided."""
# Arrange
self.mock_user.llm_model = 'openhands/special'
self.mock_user.llm_base_url = 'https://user-llm.example.com'
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, self.mock_user.llm_model
)
# Assert
assert llm.base_url == 'https://user-llm.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_uses_provider_default(self):
"""openhands/* model falls back to configured provider base URL."""
# Arrange
self.mock_user.llm_model = 'openhands/default'
self.mock_user.llm_base_url = None
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, self.mock_user.llm_model
)
# Assert
assert llm.base_url == 'https://provider.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_no_base_urls(self):
"""openhands/* model sets base_url to None when no sources available."""
# Arrange
self.mock_user.llm_model = 'openhands/default'
self.mock_user.llm_base_url = None
self.service.openhands_provider_base_url = None
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(
self.mock_user, self.mock_user.llm_model
)
# Assert
assert llm.base_url is None
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self):
"""Non-openhands model ignores provider base URL and uses user base URL."""
# Arrange
self.mock_user.llm_model = 'gpt-4'
self.mock_user.llm_base_url = 'https://user-llm.example.com'
self.service.openhands_provider_base_url = 'https://provider.example.com'
self.mock_user_context.get_mcp_api_key.return_value = None
# Act
llm, _ = await self.service._configure_llm_and_mcp(self.mock_user, None)
# Assert
assert llm.base_url == 'https://user-llm.example.com'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_with_user_default_model(self):
"""Test _configure_llm_and_mcp using user's default model."""
@@ -677,7 +781,7 @@ class TestLiveStatusAppConversationService:
mock_agent = Mock(spec=Agent)
mock_final_request = Mock(spec=StartConversationRequest)
self.service._setup_secrets_for_git_provider = AsyncMock(
self.service._setup_secrets_for_git_providers = AsyncMock(
return_value=mock_secrets
)
self.service._configure_llm_and_mcp = AsyncMock(
@@ -705,8 +809,8 @@ class TestLiveStatusAppConversationService:
# Assert
assert result == mock_final_request
self.service._setup_secrets_for_git_provider.assert_called_once_with(
ProviderType.GITHUB, self.mock_user
self.service._setup_secrets_for_git_providers.assert_called_once_with(
self.mock_user
)
self.service._configure_llm_and_mcp.assert_called_once_with(
self.mock_user, 'gpt-4'
@@ -291,9 +291,7 @@ class TestEnvironmentInitialization:
)
# Verify
expected_webhook_url = (
'https://web.example.com/api/v1/webhooks/test-sandbox-123'
)
expected_webhook_url = 'https://web.example.com/api/v1/webhooks'
assert environment['EXISTING_VAR'] == 'existing_value'
assert environment[WEBHOOK_CALLBACK_VARIABLE] == expected_webhook_url
assert environment[ALLOW_CORS_ORIGINS_VARIABLE] == 'https://web.example.com'
@@ -27,6 +27,7 @@ class MockSandboxService(SandboxService):
def __init__(self):
self.search_sandboxes_mock = AsyncMock()
self.get_sandbox_mock = AsyncMock()
self.get_sandbox_by_session_api_key_mock = AsyncMock()
self.start_sandbox_mock = AsyncMock()
self.resume_sandbox_mock = AsyncMock()
self.pause_sandbox_mock = AsyncMock()
@@ -40,6 +41,11 @@ class MockSandboxService(SandboxService):
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
return await self.get_sandbox_mock(sandbox_id)
async def get_sandbox_by_session_api_key(
self, session_api_key: str
) -> SandboxInfo | None:
return await self.get_sandbox_by_session_api_key_mock(session_api_key)
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
return await self.start_sandbox_mock(sandbox_spec_id)
@@ -188,6 +188,7 @@ class TestExperimentManagerIntegration:
sandbox_startup_poll_frequency=1,
httpx_client=httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@@ -203,7 +204,7 @@ class TestExperimentManagerIntegration:
with (
patch.object(
service,
'_setup_secrets_for_git_provider',
'_setup_secrets_for_git_providers',
return_value={},
),
patch.object(
@@ -2166,6 +2166,7 @@ async def test_delete_v1_conversation_with_sub_conversations():
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@@ -2287,6 +2288,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations():
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@@ -2438,6 +2440,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error():
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
openhands_provider_base_url=None,
access_token_hard_timeout=None,
)