openhands-sdk 1.9.1__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 +90 -16
- openhands/sdk/agent/base.py +33 -46
- openhands/sdk/context/condenser/base.py +36 -3
- openhands/sdk/context/condenser/llm_summarizing_condenser.py +65 -24
- openhands/sdk/context/condenser/prompts/summarizing_prompt.j2 +1 -5
- openhands/sdk/context/prompts/templates/system_message_suffix.j2 +2 -1
- openhands/sdk/context/skills/skill.py +2 -25
- openhands/sdk/context/view.py +108 -122
- openhands/sdk/conversation/__init__.py +2 -0
- openhands/sdk/conversation/conversation.py +18 -3
- openhands/sdk/conversation/exceptions.py +18 -0
- openhands/sdk/conversation/impl/local_conversation.py +211 -36
- openhands/sdk/conversation/impl/remote_conversation.py +151 -12
- openhands/sdk/conversation/stuck_detector.py +18 -9
- openhands/sdk/critic/impl/api/critic.py +10 -7
- openhands/sdk/event/condenser.py +52 -2
- openhands/sdk/git/cached_repo.py +19 -0
- openhands/sdk/hooks/__init__.py +2 -0
- openhands/sdk/hooks/config.py +44 -4
- openhands/sdk/hooks/executor.py +2 -1
- 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 +222 -33
- openhands/sdk/llm/message.py +65 -27
- openhands/sdk/llm/options/chat_options.py +2 -1
- openhands/sdk/llm/options/responses_options.py +8 -7
- openhands/sdk/llm/utils/model_features.py +2 -0
- openhands/sdk/mcp/client.py +53 -6
- openhands/sdk/mcp/tool.py +24 -21
- openhands/sdk/mcp/utils.py +31 -23
- openhands/sdk/plugin/__init__.py +12 -1
- openhands/sdk/plugin/fetch.py +118 -14
- openhands/sdk/plugin/loader.py +111 -0
- openhands/sdk/plugin/plugin.py +155 -13
- openhands/sdk/plugin/types.py +163 -1
- openhands/sdk/secret/secrets.py +13 -1
- openhands/sdk/utils/__init__.py +2 -0
- openhands/sdk/utils/async_utils.py +36 -1
- openhands/sdk/utils/command.py +28 -1
- openhands/sdk/workspace/remote/base.py +8 -3
- openhands/sdk/workspace/remote/remote_workspace_mixin.py +40 -7
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/METADATA +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/RECORD +47 -43
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/WHEEL +1 -1
- {openhands_sdk-1.9.1.dist-info → openhands_sdk-1.11.0.dist-info}/top_level.txt +0 -0
|
@@ -49,13 +49,16 @@ class APIBasedCritic(CriticBase, CriticClient):
|
|
|
49
49
|
messages = LLMConvertibleEvent.events_to_messages(llm_convertible_events)
|
|
50
50
|
|
|
51
51
|
# Serialize messages to dicts for API
|
|
52
|
-
|
|
53
|
-
message.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
formatted_messages = [
|
|
53
|
+
message.to_chat_dict(
|
|
54
|
+
cache_enabled=False,
|
|
55
|
+
vision_enabled=False, # Critic does not support vision currently
|
|
56
|
+
function_calling_enabled=True,
|
|
57
|
+
force_string_serializer=False,
|
|
58
|
+
send_reasoning_content=False,
|
|
59
|
+
)
|
|
60
|
+
for message in messages
|
|
61
|
+
]
|
|
59
62
|
|
|
60
63
|
# Convert ToolDefinition objects to ChatCompletionToolParam format
|
|
61
64
|
tools_for_api = [tool.to_openai_tool() for tool in tools]
|
openhands/sdk/event/condenser.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from pydantic import Field
|
|
2
4
|
from rich.text import Text
|
|
3
5
|
|
|
@@ -22,8 +24,9 @@ class Condensation(Event):
|
|
|
22
24
|
summary_offset: int | None = Field(
|
|
23
25
|
default=None,
|
|
24
26
|
ge=0,
|
|
25
|
-
description="An optional offset to the start of the resulting view"
|
|
26
|
-
" indicating where the summary should be
|
|
27
|
+
description="An optional offset to the start of the resulting view (after"
|
|
28
|
+
" forgotten events have been removed) indicating where the summary should be"
|
|
29
|
+
" inserted. If not provided, the summary will not be inserted into the view.",
|
|
27
30
|
)
|
|
28
31
|
llm_response_id: EventID = Field(
|
|
29
32
|
description=(
|
|
@@ -45,6 +48,53 @@ class Condensation(Event):
|
|
|
45
48
|
text.append(f"{self.summary}\n")
|
|
46
49
|
return text
|
|
47
50
|
|
|
51
|
+
@property
|
|
52
|
+
def summary_event(self) -> CondensationSummaryEvent:
|
|
53
|
+
"""Generates a CondensationSummaryEvent.
|
|
54
|
+
|
|
55
|
+
Since summary events are not part of the main event store and are generated
|
|
56
|
+
dynamically, this property ensures the created event has a unique and consistent
|
|
57
|
+
ID based on the condensation event's ID.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If no summary is present.
|
|
61
|
+
"""
|
|
62
|
+
if self.summary is None:
|
|
63
|
+
raise ValueError("No summary present to generate CondensationSummaryEvent.")
|
|
64
|
+
|
|
65
|
+
# Create a deterministic ID for the summary event.
|
|
66
|
+
# This ID will be unique amongst all auto-generated IDs (by virtue of the
|
|
67
|
+
# "-summary" suffix).
|
|
68
|
+
# These events are not intended to be stored alongside regular events, but the
|
|
69
|
+
# ID is still compatible with the file-based event store.
|
|
70
|
+
summary_id = f"{self.id}-summary"
|
|
71
|
+
|
|
72
|
+
return CondensationSummaryEvent(
|
|
73
|
+
id=summary_id,
|
|
74
|
+
summary=self.summary,
|
|
75
|
+
source=self.source,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def has_summary_metadata(self) -> bool:
|
|
80
|
+
"""Checks if both summary and summary_offset are present."""
|
|
81
|
+
return self.summary is not None and self.summary_offset is not None
|
|
82
|
+
|
|
83
|
+
def apply(self, events: list[LLMConvertibleEvent]) -> list[LLMConvertibleEvent]:
|
|
84
|
+
"""Applies the condensation to a list of events.
|
|
85
|
+
|
|
86
|
+
This method removes events that are marked to be forgotten and returns a new
|
|
87
|
+
list of events. If the summary metadata is present (both summary and offset),
|
|
88
|
+
the corresponding CondensationSummaryEvent will be inserted at the specified
|
|
89
|
+
offset _after_ the forgotten events have been removed.
|
|
90
|
+
"""
|
|
91
|
+
output = [event for event in events if event.id not in self.forgotten_event_ids]
|
|
92
|
+
if self.has_summary_metadata:
|
|
93
|
+
assert self.summary_offset is not None
|
|
94
|
+
summary_event = self.summary_event
|
|
95
|
+
output.insert(self.summary_offset, summary_event)
|
|
96
|
+
return output
|
|
97
|
+
|
|
48
98
|
|
|
49
99
|
class CondensationRequest(Event):
|
|
50
100
|
"""This action is used to request a condensation of the conversation history.
|
openhands/sdk/git/cached_repo.py
CHANGED
|
@@ -170,6 +170,25 @@ class GitHelper:
|
|
|
170
170
|
# origin/HEAD may not be set (e.g., bare clone, or never configured)
|
|
171
171
|
return None
|
|
172
172
|
|
|
173
|
+
def get_head_commit(self, repo_path: Path, timeout: int = 10) -> str:
|
|
174
|
+
"""Get the current HEAD commit SHA.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
repo_path: Path to the repository.
|
|
178
|
+
timeout: Timeout in seconds.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Full 40-character commit SHA of HEAD.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
GitCommandError: If command fails.
|
|
185
|
+
"""
|
|
186
|
+
return run_git_command(
|
|
187
|
+
["git", "rev-parse", "HEAD"],
|
|
188
|
+
cwd=repo_path,
|
|
189
|
+
timeout=timeout,
|
|
190
|
+
)
|
|
191
|
+
|
|
173
192
|
|
|
174
193
|
def try_cached_clone_or_update(
|
|
175
194
|
url: str,
|
openhands/sdk/hooks/__init__.py
CHANGED
|
@@ -6,6 +6,7 @@ during agent execution, enabling deterministic control over agent behavior.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from openhands.sdk.hooks.config import (
|
|
9
|
+
HOOK_EVENT_FIELDS,
|
|
9
10
|
HookConfig,
|
|
10
11
|
HookDefinition,
|
|
11
12
|
HookMatcher,
|
|
@@ -21,6 +22,7 @@ from openhands.sdk.hooks.types import HookDecision, HookEvent, HookEventType
|
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
__all__ = [
|
|
25
|
+
"HOOK_EVENT_FIELDS",
|
|
24
26
|
"HookConfig",
|
|
25
27
|
"HookDefinition",
|
|
26
28
|
"HookMatcher",
|
openhands/sdk/hooks/config.py
CHANGED
|
@@ -22,8 +22,9 @@ def _pascal_to_snake(name: str) -> str:
|
|
|
22
22
|
return result
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
# Valid snake_case field names for hook events
|
|
26
|
-
|
|
25
|
+
# Valid snake_case field names for hook events.
|
|
26
|
+
# This is the single source of truth for hook event types.
|
|
27
|
+
HOOK_EVENT_FIELDS: frozenset[str] = frozenset(
|
|
27
28
|
{
|
|
28
29
|
"pre_tool_use",
|
|
29
30
|
"post_tool_use",
|
|
@@ -188,8 +189,8 @@ class HookConfig(BaseModel):
|
|
|
188
189
|
|
|
189
190
|
if is_pascal_case:
|
|
190
191
|
# Validate that PascalCase key maps to a known field
|
|
191
|
-
if snake_key not in
|
|
192
|
-
valid_types = ", ".join(sorted(
|
|
192
|
+
if snake_key not in HOOK_EVENT_FIELDS:
|
|
193
|
+
valid_types = ", ".join(sorted(HOOK_EVENT_FIELDS))
|
|
193
194
|
raise ValueError(
|
|
194
195
|
f"Unknown event type '{key}'. Valid types: {valid_types}"
|
|
195
196
|
)
|
|
@@ -287,3 +288,42 @@ class HookConfig(BaseModel):
|
|
|
287
288
|
|
|
288
289
|
with open(path, "w") as f:
|
|
289
290
|
json.dump(self.model_dump(mode="json", exclude_defaults=True), f, indent=2)
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def merge(cls, configs: list["HookConfig"]) -> "HookConfig | None":
|
|
294
|
+
"""Merge multiple hook configs by concatenating handlers per event type.
|
|
295
|
+
|
|
296
|
+
Each hook config may have multiple event types (pre_tool_use,
|
|
297
|
+
post_tool_use, etc.). This method combines all matchers from all
|
|
298
|
+
configs for each event type.
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
configs: List of HookConfig objects to merge.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
A merged HookConfig with all matchers concatenated, or None if no configs
|
|
305
|
+
or if the result is empty.
|
|
306
|
+
|
|
307
|
+
Example:
|
|
308
|
+
>>> config1 = HookConfig(pre_tool_use=[HookMatcher(matcher="*")])
|
|
309
|
+
>>> config2 = HookConfig(pre_tool_use=[HookMatcher(matcher="terminal")])
|
|
310
|
+
>>> merged = HookConfig.merge([config1, config2])
|
|
311
|
+
>>> len(merged.pre_tool_use) # Both matchers combined
|
|
312
|
+
2
|
|
313
|
+
"""
|
|
314
|
+
if not configs:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
# Collect all matchers by event type using the canonical field list
|
|
318
|
+
collected: dict[str, list] = {field: [] for field in HOOK_EVENT_FIELDS}
|
|
319
|
+
for config in configs:
|
|
320
|
+
for field in HOOK_EVENT_FIELDS:
|
|
321
|
+
collected[field].extend(getattr(config, field))
|
|
322
|
+
|
|
323
|
+
merged = cls(**collected)
|
|
324
|
+
|
|
325
|
+
# Return None if the merged config is empty
|
|
326
|
+
if merged.is_empty():
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
return merged
|
openhands/sdk/hooks/executor.py
CHANGED
|
@@ -8,6 +8,7 @@ from pydantic import BaseModel
|
|
|
8
8
|
|
|
9
9
|
from openhands.sdk.hooks.config import HookDefinition
|
|
10
10
|
from openhands.sdk.hooks.types import HookDecision, HookEvent
|
|
11
|
+
from openhands.sdk.utils import sanitized_env
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class HookResult(BaseModel):
|
|
@@ -50,7 +51,7 @@ class HookExecutor:
|
|
|
50
51
|
) -> HookResult:
|
|
51
52
|
"""Execute a single hook."""
|
|
52
53
|
# Prepare environment
|
|
53
|
-
hook_env =
|
|
54
|
+
hook_env = sanitized_env()
|
|
54
55
|
hook_env["OPENHANDS_PROJECT_DIR"] = self.working_dir
|
|
55
56
|
hook_env["OPENHANDS_SESSION_ID"] = event.session_id or ""
|
|
56
57
|
hook_env["OPENHANDS_EVENT_TYPE"] = event.event_type
|
openhands/sdk/llm/__init__.py
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
from openhands.sdk.llm.auth import (
|
|
2
|
+
OPENAI_CODEX_MODELS,
|
|
3
|
+
CredentialStore,
|
|
4
|
+
OAuthCredentials,
|
|
5
|
+
OpenAISubscriptionAuth,
|
|
6
|
+
)
|
|
1
7
|
from openhands.sdk.llm.llm import LLM
|
|
2
8
|
from openhands.sdk.llm.llm_registry import LLMRegistry, RegistryEvent
|
|
3
9
|
from openhands.sdk.llm.llm_response import LLMResponse
|
|
@@ -22,11 +28,18 @@ from openhands.sdk.llm.utils.verified_models import VERIFIED_MODELS
|
|
|
22
28
|
|
|
23
29
|
|
|
24
30
|
__all__ = [
|
|
31
|
+
# Auth
|
|
32
|
+
"CredentialStore",
|
|
33
|
+
"OAuthCredentials",
|
|
34
|
+
"OpenAISubscriptionAuth",
|
|
35
|
+
"OPENAI_CODEX_MODELS",
|
|
36
|
+
# Core
|
|
25
37
|
"LLMResponse",
|
|
26
38
|
"LLM",
|
|
27
39
|
"LLMRegistry",
|
|
28
40
|
"RouterLLM",
|
|
29
41
|
"RegistryEvent",
|
|
42
|
+
# Messages
|
|
30
43
|
"Message",
|
|
31
44
|
"MessageToolCall",
|
|
32
45
|
"TextContent",
|
|
@@ -35,10 +48,13 @@ __all__ = [
|
|
|
35
48
|
"RedactedThinkingBlock",
|
|
36
49
|
"ReasoningItemModel",
|
|
37
50
|
"content_to_str",
|
|
51
|
+
# Streaming
|
|
38
52
|
"LLMStreamChunk",
|
|
39
53
|
"TokenCallbackType",
|
|
54
|
+
# Metrics
|
|
40
55
|
"Metrics",
|
|
41
56
|
"MetricsSnapshot",
|
|
57
|
+
# Models
|
|
42
58
|
"VERIFIED_MODELS",
|
|
43
59
|
"UNVERIFIED_MODELS_EXCLUDING_BEDROCK",
|
|
44
60
|
"get_unverified_models",
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Authentication module for LLM subscription-based access.
|
|
2
|
+
|
|
3
|
+
This module provides OAuth-based authentication for LLM providers that support
|
|
4
|
+
subscription-based access (e.g., ChatGPT Plus/Pro for OpenAI Codex models).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from openhands.sdk.llm.auth.credentials import (
|
|
8
|
+
CredentialStore,
|
|
9
|
+
OAuthCredentials,
|
|
10
|
+
)
|
|
11
|
+
from openhands.sdk.llm.auth.openai import (
|
|
12
|
+
OPENAI_CODEX_MODELS,
|
|
13
|
+
OpenAISubscriptionAuth,
|
|
14
|
+
SupportedVendor,
|
|
15
|
+
inject_system_prefix,
|
|
16
|
+
transform_for_subscription,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CredentialStore",
|
|
22
|
+
"OAuthCredentials",
|
|
23
|
+
"OpenAISubscriptionAuth",
|
|
24
|
+
"OPENAI_CODEX_MODELS",
|
|
25
|
+
"SupportedVendor",
|
|
26
|
+
"inject_system_prefix",
|
|
27
|
+
"transform_for_subscription",
|
|
28
|
+
]
|
|
@@ -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
|