ccproxy-api 0.1.2__py3-none-any.whl → 0.1.4__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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/openai/__init__.py +1 -2
- ccproxy/adapters/openai/adapter.py +218 -180
- ccproxy/adapters/openai/streaming.py +247 -65
- ccproxy/api/__init__.py +0 -3
- ccproxy/api/app.py +173 -40
- ccproxy/api/dependencies.py +62 -3
- ccproxy/api/middleware/errors.py +3 -7
- ccproxy/api/middleware/headers.py +0 -2
- ccproxy/api/middleware/logging.py +4 -3
- ccproxy/api/middleware/request_content_logging.py +297 -0
- ccproxy/api/middleware/request_id.py +5 -0
- ccproxy/api/middleware/server_header.py +0 -4
- ccproxy/api/routes/__init__.py +9 -1
- ccproxy/api/routes/claude.py +23 -32
- ccproxy/api/routes/health.py +58 -4
- ccproxy/api/routes/mcp.py +171 -0
- ccproxy/api/routes/metrics.py +4 -8
- ccproxy/api/routes/permissions.py +217 -0
- ccproxy/api/routes/proxy.py +0 -53
- ccproxy/api/services/__init__.py +6 -0
- ccproxy/api/services/permission_service.py +368 -0
- ccproxy/api/ui/__init__.py +6 -0
- ccproxy/api/ui/permission_handler_protocol.py +33 -0
- ccproxy/api/ui/terminal_permission_handler.py +593 -0
- ccproxy/auth/conditional.py +2 -2
- ccproxy/auth/dependencies.py +1 -1
- ccproxy/auth/oauth/models.py +0 -1
- ccproxy/auth/oauth/routes.py +1 -3
- ccproxy/auth/storage/json_file.py +0 -1
- ccproxy/auth/storage/keyring.py +0 -3
- ccproxy/claude_sdk/__init__.py +2 -0
- ccproxy/claude_sdk/client.py +91 -8
- ccproxy/claude_sdk/converter.py +405 -210
- ccproxy/claude_sdk/options.py +76 -29
- ccproxy/claude_sdk/parser.py +200 -0
- ccproxy/claude_sdk/streaming.py +286 -0
- ccproxy/cli/commands/__init__.py +5 -2
- ccproxy/cli/commands/auth.py +2 -4
- ccproxy/cli/commands/permission_handler.py +553 -0
- ccproxy/cli/commands/serve.py +30 -12
- ccproxy/cli/docker/params.py +0 -4
- ccproxy/cli/helpers.py +0 -2
- ccproxy/cli/main.py +5 -16
- ccproxy/cli/options/claude_options.py +19 -1
- ccproxy/cli/options/core_options.py +0 -3
- ccproxy/cli/options/security_options.py +0 -2
- ccproxy/cli/options/server_options.py +3 -2
- ccproxy/config/auth.py +0 -1
- ccproxy/config/claude.py +78 -2
- ccproxy/config/discovery.py +0 -1
- ccproxy/config/docker_settings.py +0 -1
- ccproxy/config/loader.py +1 -4
- ccproxy/config/scheduler.py +20 -0
- ccproxy/config/security.py +7 -2
- ccproxy/config/server.py +5 -0
- ccproxy/config/settings.py +13 -7
- ccproxy/config/validators.py +1 -1
- ccproxy/core/async_utils.py +1 -4
- ccproxy/core/errors.py +45 -1
- ccproxy/core/http_transformers.py +4 -3
- ccproxy/core/interfaces.py +2 -2
- ccproxy/core/logging.py +97 -95
- ccproxy/core/middleware.py +1 -1
- ccproxy/core/proxy.py +1 -1
- ccproxy/core/transformers.py +1 -1
- ccproxy/core/types.py +1 -1
- ccproxy/docker/models.py +1 -1
- ccproxy/docker/protocol.py +0 -3
- ccproxy/models/__init__.py +41 -0
- ccproxy/models/claude_sdk.py +420 -0
- ccproxy/models/messages.py +45 -18
- ccproxy/models/permissions.py +115 -0
- ccproxy/models/requests.py +1 -1
- ccproxy/models/responses.py +29 -2
- ccproxy/observability/access_logger.py +1 -2
- ccproxy/observability/context.py +17 -1
- ccproxy/observability/metrics.py +1 -3
- ccproxy/observability/pushgateway.py +0 -2
- ccproxy/observability/stats_printer.py +2 -4
- ccproxy/observability/storage/duckdb_simple.py +1 -1
- ccproxy/observability/storage/models.py +0 -1
- ccproxy/pricing/cache.py +0 -1
- ccproxy/pricing/loader.py +5 -21
- ccproxy/pricing/updater.py +0 -1
- ccproxy/scheduler/__init__.py +1 -0
- ccproxy/scheduler/core.py +6 -6
- ccproxy/scheduler/manager.py +35 -7
- ccproxy/scheduler/registry.py +1 -1
- ccproxy/scheduler/tasks.py +127 -2
- ccproxy/services/claude_sdk_service.py +220 -328
- ccproxy/services/credentials/manager.py +0 -1
- ccproxy/services/credentials/oauth_client.py +1 -2
- ccproxy/services/proxy_service.py +93 -222
- ccproxy/testing/config.py +1 -1
- ccproxy/testing/mock_responses.py +0 -1
- ccproxy/utils/model_mapping.py +197 -0
- ccproxy/utils/models_provider.py +150 -0
- ccproxy/utils/simple_request_logger.py +284 -0
- ccproxy/utils/version_checker.py +184 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/METADATA +63 -2
- ccproxy_api-0.1.4.dist-info/RECORD +166 -0
- ccproxy/cli/commands/permission.py +0 -128
- ccproxy_api-0.1.2.dist-info/RECORD +0 -150
- /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.2.dist-info → ccproxy_api-0.1.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Permission service for handling permission requests without UI dependencies."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from structlog import get_logger
|
|
9
|
+
|
|
10
|
+
from ccproxy.core.errors import (
|
|
11
|
+
PermissionNotFoundError,
|
|
12
|
+
)
|
|
13
|
+
from ccproxy.models.permissions import (
|
|
14
|
+
EventType,
|
|
15
|
+
PermissionEvent,
|
|
16
|
+
PermissionRequest,
|
|
17
|
+
PermissionStatus,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PermissionService:
|
|
25
|
+
"""Service for managing permission requests without UI dependencies."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, timeout_seconds: int = 30):
|
|
28
|
+
self._timeout_seconds = timeout_seconds
|
|
29
|
+
self._requests: dict[str, PermissionRequest] = {}
|
|
30
|
+
self._expiry_task: asyncio.Task[None] | None = None
|
|
31
|
+
self._shutdown = False
|
|
32
|
+
self._event_queues: list[asyncio.Queue[dict[str, Any]]] = []
|
|
33
|
+
self._lock = asyncio.Lock()
|
|
34
|
+
|
|
35
|
+
async def start(self) -> None:
|
|
36
|
+
if self._expiry_task is None:
|
|
37
|
+
self._expiry_task = asyncio.create_task(self._expiry_checker())
|
|
38
|
+
logger.info("permission_service_started")
|
|
39
|
+
|
|
40
|
+
async def stop(self) -> None:
|
|
41
|
+
self._shutdown = True
|
|
42
|
+
if self._expiry_task:
|
|
43
|
+
self._expiry_task.cancel()
|
|
44
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
45
|
+
await self._expiry_task
|
|
46
|
+
self._expiry_task = None
|
|
47
|
+
logger.info("permission_service_stopped")
|
|
48
|
+
|
|
49
|
+
async def request_permission(self, tool_name: str, input: dict[str, str]) -> str:
|
|
50
|
+
"""Create a new permission request.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
tool_name: Name of the tool requesting permission
|
|
54
|
+
input: Input parameters for the tool
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Permission request ID
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ValueError: If tool_name is empty or input is None
|
|
61
|
+
"""
|
|
62
|
+
# Input validation
|
|
63
|
+
if not tool_name or not tool_name.strip():
|
|
64
|
+
raise ValueError("Tool name cannot be empty")
|
|
65
|
+
if input is None:
|
|
66
|
+
raise ValueError("Input parameters cannot be None")
|
|
67
|
+
|
|
68
|
+
# Sanitize input - ensure all values are strings
|
|
69
|
+
sanitized_input = {k: str(v) for k, v in input.items()}
|
|
70
|
+
|
|
71
|
+
now = datetime.now(UTC)
|
|
72
|
+
request = PermissionRequest(
|
|
73
|
+
tool_name=tool_name.strip(),
|
|
74
|
+
input=sanitized_input,
|
|
75
|
+
created_at=now,
|
|
76
|
+
expires_at=now + timedelta(seconds=self._timeout_seconds),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async with self._lock:
|
|
80
|
+
self._requests[request.id] = request
|
|
81
|
+
|
|
82
|
+
logger.info(
|
|
83
|
+
"permission_request_created",
|
|
84
|
+
request_id=request.id,
|
|
85
|
+
tool_name=tool_name,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
event = PermissionEvent(
|
|
89
|
+
type=EventType.PERMISSION_REQUEST,
|
|
90
|
+
request_id=request.id,
|
|
91
|
+
tool_name=request.tool_name,
|
|
92
|
+
input=request.input,
|
|
93
|
+
created_at=request.created_at.isoformat(),
|
|
94
|
+
expires_at=request.expires_at.isoformat(),
|
|
95
|
+
timeout_seconds=self._timeout_seconds,
|
|
96
|
+
)
|
|
97
|
+
await self._emit_event(event.model_dump(mode="json"))
|
|
98
|
+
|
|
99
|
+
return request.id
|
|
100
|
+
|
|
101
|
+
async def get_status(self, request_id: str) -> PermissionStatus | None:
|
|
102
|
+
"""Get the status of a permission request.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
request_id: ID of the permission request
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Status of the request or None if not found
|
|
109
|
+
"""
|
|
110
|
+
async with self._lock:
|
|
111
|
+
request = self._requests.get(request_id)
|
|
112
|
+
if not request:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
if request.is_expired():
|
|
116
|
+
request.status = PermissionStatus.EXPIRED
|
|
117
|
+
|
|
118
|
+
return request.status
|
|
119
|
+
|
|
120
|
+
async def get_request(self, request_id: str) -> PermissionRequest | None:
|
|
121
|
+
"""Get a permission request by ID.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
request_id: ID of the permission request
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The request or None if not found
|
|
128
|
+
"""
|
|
129
|
+
async with self._lock:
|
|
130
|
+
return self._requests.get(request_id)
|
|
131
|
+
|
|
132
|
+
async def resolve(self, request_id: str, allowed: bool) -> bool:
|
|
133
|
+
"""Manually resolve a permission request.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
request_id: ID of the permission request
|
|
137
|
+
allowed: Whether to allow or deny the request
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if resolved successfully, False if not found or already resolved
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
ValueError: If request_id is empty
|
|
144
|
+
"""
|
|
145
|
+
# Input validation
|
|
146
|
+
if not request_id or not request_id.strip():
|
|
147
|
+
raise ValueError("Request ID cannot be empty")
|
|
148
|
+
|
|
149
|
+
async with self._lock:
|
|
150
|
+
request = self._requests.get(request_id.strip())
|
|
151
|
+
if not request or request.status != PermissionStatus.PENDING:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
request.resolve(allowed)
|
|
156
|
+
except ValueError:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
logger.info(
|
|
160
|
+
"permission_request_resolved",
|
|
161
|
+
request_id=request_id,
|
|
162
|
+
tool_name=request.tool_name,
|
|
163
|
+
allowed=allowed,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Emit resolution event
|
|
167
|
+
event = PermissionEvent(
|
|
168
|
+
type=EventType.PERMISSION_RESOLVED,
|
|
169
|
+
request_id=request_id,
|
|
170
|
+
allowed=allowed,
|
|
171
|
+
resolved_at=request.resolved_at.isoformat()
|
|
172
|
+
if request.resolved_at
|
|
173
|
+
else None,
|
|
174
|
+
)
|
|
175
|
+
await self._emit_event(event.model_dump(mode="json"))
|
|
176
|
+
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
async def _expiry_checker(self) -> None:
|
|
180
|
+
while not self._shutdown:
|
|
181
|
+
try:
|
|
182
|
+
await asyncio.sleep(5)
|
|
183
|
+
|
|
184
|
+
now = datetime.now(UTC)
|
|
185
|
+
expired_ids = []
|
|
186
|
+
expired_events = []
|
|
187
|
+
|
|
188
|
+
async with self._lock:
|
|
189
|
+
for req_id, req in self._requests.items():
|
|
190
|
+
if req.is_expired() and req.status == PermissionStatus.PENDING:
|
|
191
|
+
req.status = PermissionStatus.EXPIRED
|
|
192
|
+
# Signal waiting coroutines that the request is resolved (expired)
|
|
193
|
+
req._resolved_event.set()
|
|
194
|
+
event = PermissionEvent(
|
|
195
|
+
type=EventType.PERMISSION_EXPIRED,
|
|
196
|
+
request_id=req_id,
|
|
197
|
+
expired_at=now.isoformat(),
|
|
198
|
+
)
|
|
199
|
+
expired_events.append(event.model_dump(mode="json"))
|
|
200
|
+
|
|
201
|
+
if self._should_cleanup_request(req, now):
|
|
202
|
+
expired_ids.append(req_id)
|
|
203
|
+
|
|
204
|
+
for req_id in expired_ids:
|
|
205
|
+
del self._requests[req_id]
|
|
206
|
+
|
|
207
|
+
# Emit expired events outside the lock
|
|
208
|
+
for event_data in expired_events:
|
|
209
|
+
await self._emit_event(event_data)
|
|
210
|
+
|
|
211
|
+
if expired_ids:
|
|
212
|
+
logger.info(
|
|
213
|
+
"cleaned_expired_requests",
|
|
214
|
+
count=len(expired_ids),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
except asyncio.CancelledError:
|
|
218
|
+
break
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.error(
|
|
221
|
+
"expiry_checker_error",
|
|
222
|
+
error=str(e),
|
|
223
|
+
exc_info=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _should_cleanup_request(
|
|
227
|
+
self, request: PermissionRequest, now: datetime
|
|
228
|
+
) -> bool:
|
|
229
|
+
"""Check if a resolved request should be cleaned up."""
|
|
230
|
+
if request.status == PermissionStatus.PENDING:
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
cleanup_after = timedelta(minutes=5)
|
|
234
|
+
|
|
235
|
+
if request.resolved_at:
|
|
236
|
+
return (now - request.resolved_at) > cleanup_after
|
|
237
|
+
|
|
238
|
+
if request.status == PermissionStatus.EXPIRED:
|
|
239
|
+
return (now - request.expires_at) > cleanup_after
|
|
240
|
+
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
async def subscribe_to_events(self) -> asyncio.Queue[dict[str, Any]]:
|
|
244
|
+
"""Subscribe to permission events.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
An async queue that will receive events
|
|
248
|
+
"""
|
|
249
|
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
250
|
+
async with self._lock:
|
|
251
|
+
self._event_queues.append(queue)
|
|
252
|
+
return queue
|
|
253
|
+
|
|
254
|
+
async def unsubscribe_from_events(
|
|
255
|
+
self, queue: asyncio.Queue[dict[str, Any]]
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Unsubscribe from permission events.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
queue: The queue to unsubscribe
|
|
261
|
+
"""
|
|
262
|
+
async with self._lock:
|
|
263
|
+
if queue in self._event_queues:
|
|
264
|
+
self._event_queues.remove(queue)
|
|
265
|
+
|
|
266
|
+
async def _emit_event(self, event: dict[str, Any]) -> None:
|
|
267
|
+
"""Emit an event to all subscribers.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
event: The event data to emit
|
|
271
|
+
"""
|
|
272
|
+
async with self._lock:
|
|
273
|
+
queues = list(self._event_queues)
|
|
274
|
+
|
|
275
|
+
if not queues:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
for queue in queues:
|
|
279
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
280
|
+
queue.put_nowait(event)
|
|
281
|
+
|
|
282
|
+
async def get_pending_requests(self) -> list[PermissionRequest]:
|
|
283
|
+
"""Get all pending permission requests.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
List of pending requests
|
|
287
|
+
"""
|
|
288
|
+
async with self._lock:
|
|
289
|
+
pending = []
|
|
290
|
+
now = datetime.now(UTC)
|
|
291
|
+
for request in self._requests.values():
|
|
292
|
+
if request.is_expired():
|
|
293
|
+
request.status = PermissionStatus.EXPIRED
|
|
294
|
+
elif request.status == PermissionStatus.PENDING:
|
|
295
|
+
pending.append(request)
|
|
296
|
+
return pending
|
|
297
|
+
|
|
298
|
+
async def wait_for_permission(
|
|
299
|
+
self, request_id: str, timeout_seconds: int | None = None
|
|
300
|
+
) -> PermissionStatus:
|
|
301
|
+
"""Wait for a permission request to be resolved.
|
|
302
|
+
|
|
303
|
+
This method efficiently blocks until the permission is resolved (allowed/denied/expired)
|
|
304
|
+
or the timeout is reached using an event-driven approach.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
request_id: ID of the permission request to wait for
|
|
308
|
+
timeout_seconds: Optional timeout in seconds. If None, uses request expiration time
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The final status of the permission request
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
asyncio.TimeoutError: If timeout is reached before resolution
|
|
315
|
+
PermissionNotFoundError: If request ID is not found
|
|
316
|
+
"""
|
|
317
|
+
async with self._lock:
|
|
318
|
+
request = self._requests.get(request_id)
|
|
319
|
+
if not request:
|
|
320
|
+
raise PermissionNotFoundError(request_id)
|
|
321
|
+
|
|
322
|
+
if request.status != PermissionStatus.PENDING:
|
|
323
|
+
return request.status
|
|
324
|
+
|
|
325
|
+
if timeout_seconds is None:
|
|
326
|
+
timeout_seconds = request.time_remaining()
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
# Efficiently wait for the event to be set
|
|
330
|
+
await asyncio.wait_for(
|
|
331
|
+
request._resolved_event.wait(), timeout=timeout_seconds
|
|
332
|
+
)
|
|
333
|
+
except TimeoutError as e:
|
|
334
|
+
logger.warning(
|
|
335
|
+
"permission_wait_timeout",
|
|
336
|
+
request_id=request_id,
|
|
337
|
+
timeout_seconds=timeout_seconds,
|
|
338
|
+
)
|
|
339
|
+
# Ensure status is updated to EXPIRED on timeout
|
|
340
|
+
async with self._lock:
|
|
341
|
+
if request.status == PermissionStatus.PENDING:
|
|
342
|
+
request.status = PermissionStatus.EXPIRED
|
|
343
|
+
request._resolved_event.set() # Signal that it's resolved (as expired)
|
|
344
|
+
raise TimeoutError(
|
|
345
|
+
f"Confirmation wait timeout after {timeout_seconds:.1f}s"
|
|
346
|
+
) from e
|
|
347
|
+
|
|
348
|
+
# The event is set, so the status is resolved
|
|
349
|
+
return await self.get_status(request_id) or PermissionStatus.EXPIRED
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# Global instance
|
|
353
|
+
_permission_service: PermissionService | None = None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def get_permission_service() -> PermissionService:
|
|
357
|
+
"""Get the global permission service instance."""
|
|
358
|
+
global _permission_service
|
|
359
|
+
if _permission_service is None:
|
|
360
|
+
_permission_service = PermissionService()
|
|
361
|
+
return _permission_service
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
__all__ = [
|
|
365
|
+
"PermissionService",
|
|
366
|
+
"PermissionRequest",
|
|
367
|
+
"get_permission_service",
|
|
368
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Protocol definition for confirmation handlers."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from ccproxy.api.services.permission_service import PermissionRequest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfirmationHandlerProtocol(Protocol):
|
|
9
|
+
"""Protocol for confirmation request handlers.
|
|
10
|
+
|
|
11
|
+
This protocol defines the interface that all confirmation handlers
|
|
12
|
+
must implement to be compatible with the CLI confirmation system.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
async def handle_permission(self, request: PermissionRequest) -> bool:
|
|
16
|
+
"""Handle a permission request.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
request: The permission request to handle
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
bool: True if the user confirmed, False otherwise
|
|
23
|
+
"""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def cancel_confirmation(self, request_id: str, reason: str = "cancelled") -> None:
|
|
27
|
+
"""Cancel an ongoing confirmation request.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
request_id: The ID of the request to cancel
|
|
31
|
+
reason: The reason for cancellation
|
|
32
|
+
"""
|
|
33
|
+
...
|