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.
@@ -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