openhands-sdk 1.10.0__py3-none-any.whl → 1.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands/sdk/agent/agent.py +60 -27
- openhands/sdk/agent/base.py +1 -1
- openhands/sdk/context/condenser/base.py +36 -3
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -1
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
- openhands/sdk/context/skills/skill.py +2 -25
- openhands/sdk/conversation/conversation.py +5 -0
- openhands/sdk/conversation/impl/local_conversation.py +19 -13
- openhands/sdk/conversation/impl/remote_conversation.py +10 -0
- openhands/sdk/conversation/stuck_detector.py +18 -9
- openhands/sdk/llm/__init__.py +16 -0
- openhands/sdk/llm/auth/__init__.py +28 -0
- openhands/sdk/llm/auth/credentials.py +157 -0
- openhands/sdk/llm/auth/openai.py +762 -0
- openhands/sdk/llm/llm.py +175 -20
- openhands/sdk/llm/options/responses_options.py +8 -7
- openhands/sdk/llm/utils/model_features.py +2 -0
- openhands/sdk/secret/secrets.py +13 -1
- openhands/sdk/workspace/remote/base.py +8 -3
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
- {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +24 -21
- {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +0 -0
- {openhands_sdk-1.10.0.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Credential storage and retrieval for OAuth-based LLM authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import warnings
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from openhands.sdk.logger import get_logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_credentials_dir() -> Path:
|
|
21
|
+
"""Get the directory for storing credentials.
|
|
22
|
+
|
|
23
|
+
Uses XDG_DATA_HOME if set, otherwise defaults to ~/.local/share/openhands.
|
|
24
|
+
"""
|
|
25
|
+
return Path.home() / ".openhands" / "auth"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OAuthCredentials(BaseModel):
|
|
29
|
+
"""OAuth credentials for subscription-based LLM access."""
|
|
30
|
+
|
|
31
|
+
type: Literal["oauth"] = "oauth"
|
|
32
|
+
vendor: str = Field(description="The vendor/provider (e.g., 'openai')")
|
|
33
|
+
access_token: str = Field(description="The OAuth access token")
|
|
34
|
+
refresh_token: str = Field(description="The OAuth refresh token")
|
|
35
|
+
expires_at: int = Field(
|
|
36
|
+
description="Unix timestamp (ms) when the access token expires"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def is_expired(self) -> bool:
|
|
40
|
+
"""Check if the access token is expired."""
|
|
41
|
+
# Add 60 second buffer to avoid edge cases
|
|
42
|
+
# Add 60 second buffer to avoid edge cases where token expires during request
|
|
43
|
+
return self.expires_at < (int(time.time() * 1000) + 60_000)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CredentialStore:
|
|
47
|
+
"""Store and retrieve OAuth credentials for LLM providers."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, credentials_dir: Path | None = None):
|
|
50
|
+
"""Initialize the credential store.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
credentials_dir: Optional custom directory for storing credentials.
|
|
54
|
+
Defaults to ~/.local/share/openhands/auth/
|
|
55
|
+
"""
|
|
56
|
+
self._credentials_dir = credentials_dir or get_credentials_dir()
|
|
57
|
+
logger.info(f"Using credentials directory: {self._credentials_dir}")
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def credentials_dir(self) -> Path:
|
|
61
|
+
"""Get the credentials directory, creating it if necessary."""
|
|
62
|
+
self._credentials_dir.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
# Set directory permissions to owner-only (rwx------)
|
|
64
|
+
if os.name != "nt":
|
|
65
|
+
self._credentials_dir.chmod(0o700)
|
|
66
|
+
return self._credentials_dir
|
|
67
|
+
|
|
68
|
+
def _get_credentials_file(self, vendor: str) -> Path:
|
|
69
|
+
"""Get the path to the credentials file for a vendor."""
|
|
70
|
+
return self.credentials_dir / f"{vendor}_oauth.json"
|
|
71
|
+
|
|
72
|
+
def get(self, vendor: str) -> OAuthCredentials | None:
|
|
73
|
+
"""Get stored credentials for a vendor.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
vendor: The vendor/provider name (e.g., 'openai')
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
OAuthCredentials if found and valid, None otherwise
|
|
80
|
+
"""
|
|
81
|
+
creds_file = self._get_credentials_file(vendor)
|
|
82
|
+
if not creds_file.exists():
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with open(creds_file) as f:
|
|
87
|
+
data = json.load(f)
|
|
88
|
+
return OAuthCredentials.model_validate(data)
|
|
89
|
+
except (json.JSONDecodeError, ValueError):
|
|
90
|
+
# Invalid credentials file, remove it
|
|
91
|
+
creds_file.unlink(missing_ok=True)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def save(self, credentials: OAuthCredentials) -> None:
|
|
95
|
+
"""Save credentials for a vendor.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
credentials: The OAuth credentials to save
|
|
99
|
+
"""
|
|
100
|
+
creds_file = self._get_credentials_file(credentials.vendor)
|
|
101
|
+
with open(creds_file, "w") as f:
|
|
102
|
+
json.dump(credentials.model_dump(), f, indent=2)
|
|
103
|
+
# Set restrictive permissions (owner read/write only)
|
|
104
|
+
# Note: On Windows, NTFS ACLs should be used instead
|
|
105
|
+
if os.name != "nt": # Not Windows
|
|
106
|
+
creds_file.chmod(0o600)
|
|
107
|
+
else:
|
|
108
|
+
warnings.warn(
|
|
109
|
+
"File permissions on Windows should be manually restricted",
|
|
110
|
+
stacklevel=2,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def delete(self, vendor: str) -> bool:
|
|
114
|
+
"""Delete stored credentials for a vendor.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
vendor: The vendor/provider name
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if credentials were deleted, False if they didn't exist
|
|
121
|
+
"""
|
|
122
|
+
creds_file = self._get_credentials_file(vendor)
|
|
123
|
+
if creds_file.exists():
|
|
124
|
+
creds_file.unlink()
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def update_tokens(
|
|
129
|
+
self,
|
|
130
|
+
vendor: str,
|
|
131
|
+
access_token: str,
|
|
132
|
+
refresh_token: str | None,
|
|
133
|
+
expires_in: int,
|
|
134
|
+
) -> OAuthCredentials | None:
|
|
135
|
+
"""Update tokens for an existing credential.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
vendor: The vendor/provider name
|
|
139
|
+
access_token: New access token
|
|
140
|
+
refresh_token: New refresh token (if provided)
|
|
141
|
+
expires_in: Token expiry in seconds
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Updated credentials, or None if no existing credentials found
|
|
145
|
+
"""
|
|
146
|
+
existing = self.get(vendor)
|
|
147
|
+
if existing is None:
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
updated = OAuthCredentials(
|
|
151
|
+
vendor=vendor,
|
|
152
|
+
access_token=access_token,
|
|
153
|
+
refresh_token=refresh_token or existing.refresh_token,
|
|
154
|
+
expires_at=int(time.time() * 1000) + (expires_in * 1000),
|
|
155
|
+
)
|
|
156
|
+
self.save(updated)
|
|
157
|
+
return updated
|